loreli 0.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1597 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +217 -0
- package/packages/knowledge/src/index.js +243 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +323 -0
- package/packages/mcp/instructions.md +126 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- package/index.js +0 -8
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { join, dirname } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { Workflow } from 'loreli/workflow';
|
|
4
|
+
import { pathFor, reset } from 'loreli/workspace';
|
|
5
|
+
import { pick } from 'loreli/identity';
|
|
6
|
+
import { logger } from 'loreli/log';
|
|
7
|
+
import { mark, has, parse } from 'loreli/marker';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const log = logger('action');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build workspace context used to regenerate MCP scaffolding after branch resets.
|
|
14
|
+
*
|
|
15
|
+
* The context mirrors what factory/create uses at spawn time. Reusing the same
|
|
16
|
+
* shape ensures reset() can restore CLI-facing config files that generic skills
|
|
17
|
+
* rely on (`loreli tools ...`) without embedding ad-hoc backend assumptions.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} orchestrator - Orchestrator instance.
|
|
20
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
21
|
+
* @param {string} agent - Agent identity name.
|
|
22
|
+
* @returns {object}
|
|
23
|
+
*/
|
|
24
|
+
function workspaceContext(orchestrator, repo, agent) {
|
|
25
|
+
const context = {
|
|
26
|
+
session: orchestrator.sessionId,
|
|
27
|
+
agent,
|
|
28
|
+
repo,
|
|
29
|
+
home: orchestrator.storage?.home,
|
|
30
|
+
denied: orchestrator.cfg?.get?.('agents.disallowedTools') ?? []
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (process.env.GITHUB_TOKEN) context.token = process.env.GITHUB_TOKEN;
|
|
34
|
+
|
|
35
|
+
return context;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build reset()/checkout() options that preserve workspace scaffolding.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} orchestrator - Orchestrator instance.
|
|
42
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
43
|
+
* @param {string} agent - Agent identity name.
|
|
44
|
+
* @returns {{context: object, descriptors: object[]}}
|
|
45
|
+
*/
|
|
46
|
+
function workspaceOptions(orchestrator, repo, agent) {
|
|
47
|
+
const context = workspaceContext(orchestrator, repo, agent);
|
|
48
|
+
const descriptors = orchestrator.backendRegistry?.scaffoldAll?.(context) ?? [];
|
|
49
|
+
return { context, descriptors };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Action workflow for Loreli's orchestration pipeline.
|
|
54
|
+
*
|
|
55
|
+
* Manages action agents — issue claiming, work dispatch,
|
|
56
|
+
* and rework from human feedback.
|
|
57
|
+
* Also owns the claim protocol (claim/claimant) that was previously
|
|
58
|
+
* in the hub package.
|
|
59
|
+
*
|
|
60
|
+
* @extends Workflow
|
|
61
|
+
*/
|
|
62
|
+
export class ActionWorkflow extends Workflow {
|
|
63
|
+
/** @type {string} Agent role this workflow manages. */
|
|
64
|
+
static role = 'action';
|
|
65
|
+
|
|
66
|
+
/** @type {string} Mustache template path for action prompts. */
|
|
67
|
+
static template = join(__dirname, '..', 'prompts', 'action.md');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Guard flag to prevent overlapping dispatch runs.
|
|
71
|
+
* The dispatch handler is async and may span multiple ticks.
|
|
72
|
+
*
|
|
73
|
+
* @type {boolean}
|
|
74
|
+
*/
|
|
75
|
+
_dispatching = false;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Determine if a claim is stale — the claimant agent is dead,
|
|
79
|
+
* dormant, inactive past stall timeout, or unregistered.
|
|
80
|
+
*
|
|
81
|
+
* Returns `true` for local dead/dormant/stalled agents (immediate release),
|
|
82
|
+
* `'foreign'` for agents not in the local map (requires proof-of-life),
|
|
83
|
+
* or `false` if the agent is alive and working.
|
|
84
|
+
*
|
|
85
|
+
* @param {string|null} claimer - Agent name from claim marker.
|
|
86
|
+
* @returns {boolean|string} true=stale, 'foreign'=needs proof-of-life, false=alive.
|
|
87
|
+
*/
|
|
88
|
+
_stale(claimer) {
|
|
89
|
+
if (!claimer) return false;
|
|
90
|
+
const entry = this.orchestrator.agents.get(claimer);
|
|
91
|
+
if (!entry) {
|
|
92
|
+
if (this.orchestrator._removed?.has(claimer)) return true;
|
|
93
|
+
return 'foreign';
|
|
94
|
+
}
|
|
95
|
+
if (entry.state === 'dormant') return true;
|
|
96
|
+
|
|
97
|
+
// Claims from agents that exceeded the stall window block the
|
|
98
|
+
// queue even though they are not making progress; treat them as
|
|
99
|
+
// stale so dispatch can release and reassign work.
|
|
100
|
+
const last = this.orchestrator._lastActivity?.get(claimer);
|
|
101
|
+
if (!last) return false;
|
|
102
|
+
const elapsed = Date.now() - new Date(last).getTime();
|
|
103
|
+
if (!Number.isFinite(elapsed)) return false;
|
|
104
|
+
|
|
105
|
+
const timeout = this.orchestrator.stallTimeout
|
|
106
|
+
?? this.orchestrator.cfg?.get?.('timeouts.stall')
|
|
107
|
+
?? 600000;
|
|
108
|
+
|
|
109
|
+
return elapsed > timeout;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Evaluate a foreign claim through the proof-of-life protocol.
|
|
114
|
+
*
|
|
115
|
+
* Delegates to the base Workflow `check()` method which implements
|
|
116
|
+
* the unified, status-aware PoL gate.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
119
|
+
* @param {number} number - Issue number.
|
|
120
|
+
* @param {string} agent - Foreign agent name.
|
|
121
|
+
* @returns {Promise<string>} 'active'|'requested'|'pending'|'release'.
|
|
122
|
+
*/
|
|
123
|
+
async _checkForeignClaim(repo, number, agent) {
|
|
124
|
+
return this.check(repo, number, agent);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Report action demand: how many unclaimed issues need an agent vs
|
|
129
|
+
* how many action agents are currently active. Caches the issue
|
|
130
|
+
* fetch per tick so _dispatch() doesn't duplicate the API call.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
133
|
+
* @returns {Promise<{workload: number, supply: number, deficit: number}>}
|
|
134
|
+
*/
|
|
135
|
+
async demand(repo) {
|
|
136
|
+
const openIssues = await this.hub.issues(repo, { state: 'open', labels: ['loreli'] });
|
|
137
|
+
|
|
138
|
+
let workload = 0;
|
|
139
|
+
for (const issue of openIssues) {
|
|
140
|
+
if (issue.labels?.includes('loreli:parent')) continue;
|
|
141
|
+
if (issue.labels?.includes('loreli:blocked')) continue;
|
|
142
|
+
const claimer = await this.claimant(repo, issue.number);
|
|
143
|
+
if (!claimer || this._stale(claimer) === true) workload++;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const supply = this.agents().filter(function active(a) {
|
|
147
|
+
return a.state !== 'dormant' && this._stale(a.identity?.name) !== true;
|
|
148
|
+
}, this).length;
|
|
149
|
+
|
|
150
|
+
return { workload, supply, deficit: Math.max(0, workload - supply) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Register event listeners on the orchestrator.
|
|
155
|
+
* Listens for `removed` events to release claimed issues.
|
|
156
|
+
*
|
|
157
|
+
* @returns {Record<string, function>} Event listener map.
|
|
158
|
+
*/
|
|
159
|
+
events() {
|
|
160
|
+
const self = this;
|
|
161
|
+
return {
|
|
162
|
+
/**
|
|
163
|
+
* When an agent is removed, release any issues it had claimed.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} event - The removed event.
|
|
166
|
+
* @param {object} event.agent - The removed agent instance.
|
|
167
|
+
*/
|
|
168
|
+
async removed({ agent }) {
|
|
169
|
+
if (agent) await self.reassign(agent);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Register reactor handlers for the orchestrator's tick loop.
|
|
176
|
+
*
|
|
177
|
+
* The `dispatch` handler checks for dormant action agents and
|
|
178
|
+
* unclaimed issues on each tick. When a dormant agent is found,
|
|
179
|
+
* its workspace is reset to the configured base branch, it is respawned, and
|
|
180
|
+
* dispatched to the next unclaimed issue.
|
|
181
|
+
*
|
|
182
|
+
* @returns {Record<string, function>} Handler map.
|
|
183
|
+
*/
|
|
184
|
+
reactor() {
|
|
185
|
+
const self = this;
|
|
186
|
+
return {
|
|
187
|
+
async dispatch(repo) { await self._dispatch(repo); }
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Dispatch available action agents to unclaimed issues.
|
|
193
|
+
*
|
|
194
|
+
* Runs on each reactor tick. Finds dormant or idle spawned agents,
|
|
195
|
+
* checks for unclaimed issues, and dispatches. Dormant agents need
|
|
196
|
+
* a workspace reset and respawn; spawned agents that were never
|
|
197
|
+
* given work are already alive and ready. Only one dispatch cycle
|
|
198
|
+
* runs at a time via the _dispatching guard.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
201
|
+
* @returns {Promise<void>}
|
|
202
|
+
*/
|
|
203
|
+
async _dispatch(repo) {
|
|
204
|
+
if (this._dispatching) return;
|
|
205
|
+
this._dispatching = true;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const openIssues = await this.hub.issues(repo, { state: 'open', labels: ['loreli'] });
|
|
209
|
+
const maxClaims = this.orchestrator.cfg?.get?.('watch.maxClaims') ?? 3;
|
|
210
|
+
const unclaimed = [];
|
|
211
|
+
for (const issue of openIssues) {
|
|
212
|
+
if (issue.labels?.includes('loreli:parent')) continue;
|
|
213
|
+
if (issue.labels?.includes('loreli:blocked')) continue;
|
|
214
|
+
const claimer = await this.claimant(repo, issue.number);
|
|
215
|
+
if (!claimer) {
|
|
216
|
+
unclaimed.push(issue);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const stale = this._stale(claimer);
|
|
220
|
+
|
|
221
|
+
if (stale === 'foreign') {
|
|
222
|
+
// Foreign claim — use proof-of-life protocol instead of
|
|
223
|
+
// immediate release to prevent duplicate work in multi-
|
|
224
|
+
// orchestrator setups.
|
|
225
|
+
try {
|
|
226
|
+
const verdict = await this._checkForeignClaim(repo, issue.number, claimer);
|
|
227
|
+
if (verdict === 'release') {
|
|
228
|
+
log.info(`dispatch: proof-of-life expired for ${claimer} on #${issue.number} — releasing`);
|
|
229
|
+
const releaseMarker = mark('release', { agent: claimer });
|
|
230
|
+
await this.hub.as(this.orchestrator.clientIdentity, 'orchestrator')
|
|
231
|
+
.comment(repo, issue.number, `${releaseMarker}\nClaim released — no proof of life from \`${claimer}\`.`);
|
|
232
|
+
unclaimed.push(issue);
|
|
233
|
+
} else {
|
|
234
|
+
log.debug(`dispatch: foreign claim by ${claimer} on #${issue.number} — ${verdict}`);
|
|
235
|
+
}
|
|
236
|
+
} catch (err) { log.warn(`dispatch: foreign claim check failed for #${issue.number}: ${err.message}`); }
|
|
237
|
+
} else if (stale === true) {
|
|
238
|
+
log.info(`dispatch: releasing stale claim from ${claimer} on #${issue.number}`);
|
|
239
|
+
try {
|
|
240
|
+
const releaseMarker = mark('release', { agent: claimer });
|
|
241
|
+
await this.hub.as(this.orchestrator.clientIdentity, 'orchestrator')
|
|
242
|
+
.comment(repo, issue.number, `${releaseMarker}\nClaim released — agent \`${claimer}\` is no longer active or is stalled.`);
|
|
243
|
+
} catch (err) { log.warn(`dispatch: stale release failed for #${issue.number}: ${err.message}`); }
|
|
244
|
+
unclaimed.push(issue);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!unclaimed.length) return;
|
|
248
|
+
|
|
249
|
+
// scale() is the sole spawn authority — dispatch only assigns
|
|
250
|
+
// work to agents that already exist (dormant or spawned).
|
|
251
|
+
const available = this.agents().filter(function ready(a) {
|
|
252
|
+
return a.state === 'dormant' || a.state === 'spawned';
|
|
253
|
+
});
|
|
254
|
+
if (!available.length) return;
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < Math.min(unclaimed.length, available.length); i++) {
|
|
257
|
+
const issue = unclaimed[i];
|
|
258
|
+
const agent = available[i];
|
|
259
|
+
|
|
260
|
+
// Circuit breaker: count release markers since the last reset point
|
|
261
|
+
// (circuit-breaker escalation or system restart marker). After
|
|
262
|
+
// maxClaims releases, the issue is escalated to HITL. The high-
|
|
263
|
+
// water mark ensures that human label removal and system restarts
|
|
264
|
+
// reset the counter without needing a separate reset mechanism.
|
|
265
|
+
const comments = await this.hub.comments(repo, issue.number);
|
|
266
|
+
let gateIdx = -1;
|
|
267
|
+
for (let j = comments.length - 1; j >= 0; j--) {
|
|
268
|
+
if (has(comments[j].body, 'circuit-breaker') || has(comments[j].body, 'restart')) {
|
|
269
|
+
gateIdx = j;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const releases = comments.slice(gateIdx + 1)
|
|
274
|
+
.filter(function isRelease(c) { return has(c.body, 'release'); }).length;
|
|
275
|
+
if (releases >= maxClaims) {
|
|
276
|
+
log.warn(`circuit-breaker: issue #${issue.number} released ${releases} times — escalating to HITL`);
|
|
277
|
+
try {
|
|
278
|
+
await this.hub.ensure(repo, [
|
|
279
|
+
{ name: 'loreli:blocked', color: 'b60205', description: 'Issue blocked — circuit breaker tripped' },
|
|
280
|
+
{ name: 'loreli:needs-attention', color: 'e11d48', description: 'Requires human attention' }
|
|
281
|
+
]);
|
|
282
|
+
await this.hub.label(repo, issue.number, ['loreli:blocked', 'loreli:needs-attention']);
|
|
283
|
+
const reviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
|
|
284
|
+
const mentions = reviewers.length
|
|
285
|
+
? reviewers.map(function mention(r) { return `@${r}`; }).join(' ') + ' '
|
|
286
|
+
: '';
|
|
287
|
+
const cbMarker = mark('circuit-breaker', { releases: String(releases) });
|
|
288
|
+
await this.hub.as(this.orchestrator.clientIdentity, 'orchestrator')
|
|
289
|
+
.comment(repo, issue.number, `${cbMarker}\n${mentions}### Circuit breaker tripped\n\nThis issue has been claimed and released ${releases} times without a successful PR. Loreli has paused automatic retries to prevent further token waste.\n\n**What happened:**\nLoreli assigned an agent to this issue ${releases} times. Each attempt ended with the agent releasing the issue — either because the work could not be completed, the PR received changes-requested feedback from a reviewer and the action agent was no longer available, or an unrecoverable error occurred.\n\n**To resolve:**\n1. Check the closed PRs linked to this issue for patterns in the agent failures\n2. Review the issue description — it may be ambiguous, underspecified, or infeasible for an AI agent\n3. Update the issue with clearer acceptance criteria or a simpler scope if needed\n4. Remove the \`loreli:blocked\` label from this issue — Loreli will automatically retry with a fresh agent\n5. If this issue should not be automated, close it instead`);
|
|
290
|
+
} catch (err) { log.warn(`circuit-breaker: HITL escalation failed for #${issue.number}: ${err.message}`); }
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Optimistic claim: post first, verify we won, then set up.
|
|
295
|
+
// This eliminates the TOCTOU race where another participant
|
|
296
|
+
// claims between our check and our claim.
|
|
297
|
+
const visible = agent.identity.claim?.() ?? `Claimed by **${agent.identity.name}**`;
|
|
298
|
+
const won = await this.claimFirst(repo, issue.number, 'claim', agent.identity, agent.role, visible);
|
|
299
|
+
if (!won) {
|
|
300
|
+
log.info(`dispatch: lost claim race for #${issue.number} — skipping`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
log.info(`dispatch: ${agent.identity.name} claimed #${issue.number}: ${issue.title}`);
|
|
304
|
+
|
|
305
|
+
// Both spawned and dormant agents need the per-issue branch.
|
|
306
|
+
// reset() switches the workspace to `${name}/issue-${issue}` so
|
|
307
|
+
// the agent's commits land on the correct remote branch for the PR.
|
|
308
|
+
const home = this.orchestrator.storage?.home;
|
|
309
|
+
const wsRoot = home ? `${home}/workspaces` : undefined;
|
|
310
|
+
const cwd = agent.cwd ?? pathFor(agent.identity.name, wsRoot);
|
|
311
|
+
const base = this.orchestrator.cfg?.get?.('merge.base') ?? 'main';
|
|
312
|
+
const options = workspaceOptions(this.orchestrator, repo, agent.identity.name);
|
|
313
|
+
try {
|
|
314
|
+
await reset(cwd, agent.identity.name, issue.number, base, options);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
log.warn(`dispatch: workspace reset failed for ${agent.identity.name}: ${err.message} — skipping issue`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Dormant agents need respawning; spawned agents are already alive.
|
|
321
|
+
if (agent.state === 'dormant') {
|
|
322
|
+
await agent.spawn();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const issueBranch = `${agent.identity.name}/issue-${issue.number}`;
|
|
326
|
+
|
|
327
|
+
const prompt = await this.render({
|
|
328
|
+
name: agent.identity.name,
|
|
329
|
+
repo,
|
|
330
|
+
branch: issueBranch,
|
|
331
|
+
faction: agent.identity.faction,
|
|
332
|
+
provider: agent.identity.provider,
|
|
333
|
+
model: agent.identity.model,
|
|
334
|
+
issue: `#${issue.number}: ${issue.title}\n\n${issue.body}`,
|
|
335
|
+
labels: agent.identity.labels?.('action')
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await this.saveTask(agent.identity.name, { type: 'work_issue', issue: issue.number });
|
|
339
|
+
|
|
340
|
+
if (agent.identity.labels) {
|
|
341
|
+
try {
|
|
342
|
+
await this.hub.label(repo, issue.number, agent.identity.labels(agent.role));
|
|
343
|
+
} catch (err) { log.warn(`dispatch: labeling failed for issue #${issue.number}: ${err.message}`); }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
await agent.send(prompt);
|
|
347
|
+
this.orchestrator.activity(agent.identity.name);
|
|
348
|
+
}
|
|
349
|
+
} catch (err) {
|
|
350
|
+
log.error(`dispatch failed: ${err.message}`);
|
|
351
|
+
} finally {
|
|
352
|
+
this._dispatching = false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Claim Protocol ────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Post a claim comment on an issue.
|
|
360
|
+
*
|
|
361
|
+
* First-comment-wins: the agent that posts a claim marker first is
|
|
362
|
+
* the assignee. Other agents skip already-claimed issues. The marker
|
|
363
|
+
* is machine-readable; the visible text is themed via identity.claim().
|
|
364
|
+
*
|
|
365
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
366
|
+
* @param {number} number - Issue number.
|
|
367
|
+
* @param {object} identity - Agent identity with name.
|
|
368
|
+
* @param {string} role - Agent role for hub scoping.
|
|
369
|
+
* @returns {Promise<void>}
|
|
370
|
+
*/
|
|
371
|
+
async claim(repo, number, identity, role) {
|
|
372
|
+
const scoped = this.hub.as(identity, role);
|
|
373
|
+
const marker = mark('claim', { agent: identity.name });
|
|
374
|
+
const visible = identity.claim?.() ?? `Claimed by **${identity.name}**`;
|
|
375
|
+
await scoped.comment(repo, number, `${marker}\n${visible}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Determine who claimed an issue by parsing claim and release comments.
|
|
380
|
+
*
|
|
381
|
+
* Walks all comments in chronological order. A `claim` marker sets the
|
|
382
|
+
* claimant; a subsequent `release` marker clears it. The last lifecycle
|
|
383
|
+
* marker wins, so claim → release → re-claim works correctly.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
386
|
+
* @param {number} number - Issue number.
|
|
387
|
+
* @returns {Promise<string|null>} Claim agent name, or null if unclaimed/released.
|
|
388
|
+
*/
|
|
389
|
+
async claimant(repo, number) {
|
|
390
|
+
const allComments = await this.hub.comments(repo, number);
|
|
391
|
+
|
|
392
|
+
let agent = null;
|
|
393
|
+
for (const c of allComments) {
|
|
394
|
+
if (has(c.body, 'claim')) {
|
|
395
|
+
const parsed = parse(c.body, 'claim')?.agent;
|
|
396
|
+
if (parsed) agent = parsed;
|
|
397
|
+
}
|
|
398
|
+
if (has(c.body, 'release') || has(c.body, 'restart')) {
|
|
399
|
+
agent = null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Piggyback: record discovered name in the orchestrator's taken
|
|
404
|
+
// set so identity acquisition avoids collisions. Zero API cost —
|
|
405
|
+
// this data was already fetched for the claim check.
|
|
406
|
+
if (agent) this.orchestrator.takenNames?.add(agent);
|
|
407
|
+
|
|
408
|
+
return agent;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── Reassign ─────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Release all issues claimed by a dead/dying agent so they become
|
|
415
|
+
* available for re-claim.
|
|
416
|
+
*
|
|
417
|
+
* Operates via GitHub: finds issues with this agent's claim,
|
|
418
|
+
* removes labels, and posts a release comment.
|
|
419
|
+
*
|
|
420
|
+
* @param {object} agent - The agent being removed.
|
|
421
|
+
* @returns {Promise<number>} Number of issues released.
|
|
422
|
+
*/
|
|
423
|
+
async reassign(agent) {
|
|
424
|
+
if (!this.hub) return 0;
|
|
425
|
+
|
|
426
|
+
const repo = this.orchestrator.repo;
|
|
427
|
+
if (!repo) return 0;
|
|
428
|
+
|
|
429
|
+
let released = 0;
|
|
430
|
+
try {
|
|
431
|
+
const open = await this.hub.issues(repo, { state: 'open' });
|
|
432
|
+
const agentLabels = agent.identity?.labels?.(agent.role) ?? [];
|
|
433
|
+
const agentName = agent.identity?.name;
|
|
434
|
+
|
|
435
|
+
for (const issue of open) {
|
|
436
|
+
const claimer = await this.claimant(repo, issue.number);
|
|
437
|
+
if (claimer !== agentName) continue;
|
|
438
|
+
|
|
439
|
+
const scoped = this.hub.as(agent.identity, agent.role);
|
|
440
|
+
try {
|
|
441
|
+
const releaseMarker = mark('release', { agent: agentName });
|
|
442
|
+
const visible = agent.identity?.release?.() ?? `**Released** — agent \`${agentName}\` was terminated. This issue is available for re-claim.`;
|
|
443
|
+
await scoped.comment(repo, issue.number, `${releaseMarker}\n${visible}`);
|
|
444
|
+
} catch (err) { log.warn(`reassign: release comment failed for issue #${issue.number}: ${err.message}`); }
|
|
445
|
+
|
|
446
|
+
if (agentLabels.length) {
|
|
447
|
+
try {
|
|
448
|
+
const current = issue.labels ?? [];
|
|
449
|
+
const remaining = current.filter(function keep(l) { return !agentLabels.includes(l); });
|
|
450
|
+
if (remaining.length < current.length) {
|
|
451
|
+
await this.hub.label(repo, issue.number, remaining);
|
|
452
|
+
}
|
|
453
|
+
} catch (err) { log.warn(`reassign: label removal failed for issue #${issue.number}: ${err.message}`); }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
released++;
|
|
457
|
+
log.info(`released issue #${issue.number} from ${agentName}`);
|
|
458
|
+
}
|
|
459
|
+
} catch (err) {
|
|
460
|
+
log.warn(`reassign failed for ${agent.identity?.name}: ${err.message}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return released;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── Work ──────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Dispatch the work cycle to action agents.
|
|
470
|
+
*
|
|
471
|
+
* Fetches unclaimed issues, assigns them round-robin to action
|
|
472
|
+
* agents, pairs each with an opposing-provider reviewer for
|
|
473
|
+
* adversarial cross-model review, claims issues on GitHub, and
|
|
474
|
+
* renders/sends the action prompt.
|
|
475
|
+
*
|
|
476
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
477
|
+
* @returns {Promise<Array<{issue: number, agent: string, reviewer: string|null}>>}
|
|
478
|
+
* @throws {Error} When no action agents are available.
|
|
479
|
+
*/
|
|
480
|
+
async work(repo) {
|
|
481
|
+
if (this._dispatching) return [];
|
|
482
|
+
this._dispatching = true;
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
return await this._work(repo);
|
|
486
|
+
} finally {
|
|
487
|
+
this._dispatching = false;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Internal work implementation, guarded by _dispatching in work().
|
|
493
|
+
*
|
|
494
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
495
|
+
* @returns {Promise<Array<{issue: number, agent: string, reviewer: string|null}>>}
|
|
496
|
+
*/
|
|
497
|
+
async _work(repo) {
|
|
498
|
+
log.info(`work cycle: ${repo}`);
|
|
499
|
+
const actions = this.agents().filter(function active(a) {
|
|
500
|
+
return a.state !== 'dormant' && this._stale(a.identity?.name) !== true;
|
|
501
|
+
}, this);
|
|
502
|
+
if (!actions.length) {
|
|
503
|
+
log.debug('work: no action agents available — scale() will fill demand');
|
|
504
|
+
return [];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const reviewers = [...this.orchestrator.agents.values()]
|
|
508
|
+
.filter(function isReviewer(a) { return a.role === 'reviewer'; });
|
|
509
|
+
|
|
510
|
+
// Fetch unclaimed loreli issues — filtering by label avoids
|
|
511
|
+
// fetching comments for every open issue in the repository.
|
|
512
|
+
const openIssues = await this.hub.issues(repo, { state: 'open', labels: ['loreli'] });
|
|
513
|
+
const unclaimed = [];
|
|
514
|
+
for (const issue of openIssues) {
|
|
515
|
+
if (issue.labels?.includes('loreli:parent')) continue;
|
|
516
|
+
if (issue.labels?.includes('loreli:blocked')) continue;
|
|
517
|
+
const claimer = await this.claimant(repo, issue.number);
|
|
518
|
+
if (!claimer) {
|
|
519
|
+
unclaimed.push(issue);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const stale = this._stale(claimer);
|
|
523
|
+
if (stale === 'foreign') {
|
|
524
|
+
try {
|
|
525
|
+
const verdict = await this._checkForeignClaim(repo, issue.number, claimer);
|
|
526
|
+
if (verdict === 'release') {
|
|
527
|
+
log.info(`work: proof-of-life expired for ${claimer} on #${issue.number} — releasing`);
|
|
528
|
+
const releaseMarker = mark('release', { agent: claimer });
|
|
529
|
+
await this.hub.as(this.orchestrator.clientIdentity, 'orchestrator')
|
|
530
|
+
.comment(repo, issue.number, `${releaseMarker}\nClaim released — no proof of life from \`${claimer}\`.`);
|
|
531
|
+
unclaimed.push(issue);
|
|
532
|
+
} else {
|
|
533
|
+
log.debug(`work: foreign claim by ${claimer} on #${issue.number} — ${verdict}`);
|
|
534
|
+
}
|
|
535
|
+
} catch (err) { log.warn(`work: foreign claim check failed for #${issue.number}: ${err.message}`); }
|
|
536
|
+
} else if (stale === true) {
|
|
537
|
+
log.info(`work: releasing stale claim from ${claimer} on #${issue.number}`);
|
|
538
|
+
try {
|
|
539
|
+
const releaseMarker = mark('release', { agent: claimer });
|
|
540
|
+
await this.hub.as(this.orchestrator.clientIdentity, 'orchestrator')
|
|
541
|
+
.comment(repo, issue.number, `${releaseMarker}\nClaim released — agent \`${claimer}\` is no longer active or is stalled.`);
|
|
542
|
+
} catch (err) { log.warn(`work: stale release failed for #${issue.number}: ${err.message}`); }
|
|
543
|
+
unclaimed.push(issue);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const assignments = [];
|
|
548
|
+
for (let i = 0; i < Math.min(unclaimed.length, actions.length); i++) {
|
|
549
|
+
const issue = unclaimed[i];
|
|
550
|
+
const agent = actions[i];
|
|
551
|
+
|
|
552
|
+
// Pair with an opposing-provider reviewer if one exists already.
|
|
553
|
+
// scale() handles spawning reviewers — we do not enlist inline.
|
|
554
|
+
const reviewer = this.pair(agent.identity, reviewers);
|
|
555
|
+
|
|
556
|
+
// Switch to per-issue branch before the agent starts working.
|
|
557
|
+
// This ensures each issue gets its own remote branch for its PR.
|
|
558
|
+
const home = this.orchestrator.storage?.home;
|
|
559
|
+
const wsRoot = home ? `${home}/workspaces` : undefined;
|
|
560
|
+
const cwd = agent.cwd ?? pathFor(agent.identity.name, wsRoot);
|
|
561
|
+
const base = this.orchestrator.cfg?.get?.('merge.base') ?? 'main';
|
|
562
|
+
const options = workspaceOptions(this.orchestrator, repo, agent.identity.name);
|
|
563
|
+
try {
|
|
564
|
+
await reset(cwd, agent.identity.name, issue.number, base, options);
|
|
565
|
+
} catch (err) {
|
|
566
|
+
log.warn(`work: branch reset failed for ${agent.identity.name}: ${err.message} — skipping issue`);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const issueBranch = `${agent.identity.name}/issue-${issue.number}`;
|
|
571
|
+
|
|
572
|
+
const prompt = await this.render({
|
|
573
|
+
name: agent.identity.name,
|
|
574
|
+
repo,
|
|
575
|
+
branch: issueBranch,
|
|
576
|
+
faction: agent.identity.faction,
|
|
577
|
+
provider: agent.identity.provider,
|
|
578
|
+
model: agent.identity.model,
|
|
579
|
+
issue: `#${issue.number}: ${issue.title}\n\n${issue.body}`,
|
|
580
|
+
labels: agent.identity.labels?.('action')
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// Optimistic claim: post first, verify we won.
|
|
584
|
+
const visible = agent.identity.claim?.() ?? `Claimed by **${agent.identity.name}**`;
|
|
585
|
+
const won = await this.claimFirst(repo, issue.number, 'claim', agent.identity, agent.role, visible);
|
|
586
|
+
if (!won) {
|
|
587
|
+
log.info(`work: lost claim race for #${issue.number} — skipping`);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
log.info(`${agent.identity.name} claimed #${issue.number}: ${issue.title}`);
|
|
591
|
+
|
|
592
|
+
// Write claimed issue to session so the agent's MCP tools can auto-reference it
|
|
593
|
+
await this.saveTask(agent.identity.name, { type: 'work_issue', issue: issue.number });
|
|
594
|
+
|
|
595
|
+
// Apply agent labels to the claimed issue
|
|
596
|
+
if (agent.identity.labels) {
|
|
597
|
+
try {
|
|
598
|
+
await this.hub.label(repo, issue.number, agent.identity.labels(agent.role));
|
|
599
|
+
} catch (err) { log.warn(`work: labeling failed for issue #${issue.number}: ${err.message}`); }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
await agent.send(prompt);
|
|
603
|
+
this.orchestrator.activity(agent.identity.name);
|
|
604
|
+
|
|
605
|
+
assignments.push({
|
|
606
|
+
issue: issue.number,
|
|
607
|
+
agent: agent.identity.name,
|
|
608
|
+
reviewer: reviewer?.identity?.name ?? null
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return assignments;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ── Rework ────────────────────────────────────────────
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Handle human feedback on a gated PR by dispatching to an
|
|
619
|
+
* existing action agent or spawning a fresh one.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
622
|
+
* @param {number} pr - Pull request number.
|
|
623
|
+
* @param {Array<{author: string, body: string}>} feedback - Human comments.
|
|
624
|
+
* @returns {Promise<{reworkAgent: string, hitlAt: string}>}
|
|
625
|
+
*/
|
|
626
|
+
async rework(repo, pr, feedback) {
|
|
627
|
+
log.info(`rework PR #${pr} on ${repo} with ${feedback.length} feedback items`);
|
|
628
|
+
|
|
629
|
+
const cfg = this.orchestrator.cfg;
|
|
630
|
+
const reviewers = cfg?.get?.('reviewers') ?? [];
|
|
631
|
+
|
|
632
|
+
// Try existing action agent first
|
|
633
|
+
let agent = this.agents()[0];
|
|
634
|
+
|
|
635
|
+
// If no agents exist (gate killed them all), spawn a fresh one
|
|
636
|
+
if (!agent) {
|
|
637
|
+
log.info('no agents available for rework — spawning fresh action agent');
|
|
638
|
+
|
|
639
|
+
await this.orchestrator.backendRegistry.discover();
|
|
640
|
+
const provider = this.orchestrator.backendRegistry.providers()[0] ?? 'anthropic';
|
|
641
|
+
|
|
642
|
+
agent = await this.enlist(provider, 'action', {
|
|
643
|
+
theme: pick(cfg?.get?.('theme')),
|
|
644
|
+
config: cfg
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Fetch PR details so the rework prompt includes branch context.
|
|
649
|
+
// Without this, the agent cannot identify which branch to work on.
|
|
650
|
+
let prDetails;
|
|
651
|
+
try {
|
|
652
|
+
prDetails = await this.hub.pull(repo, pr);
|
|
653
|
+
} catch (err) {
|
|
654
|
+
log.warn(`rework: failed to fetch PR #${pr}: ${err.message}`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
await this.dispatch(agent, {
|
|
658
|
+
name: agent.identity.name,
|
|
659
|
+
repo,
|
|
660
|
+
branch: prDetails?.head ?? `${agent.identity.name}/work`,
|
|
661
|
+
faction: agent.identity.faction,
|
|
662
|
+
provider: agent.identity.provider,
|
|
663
|
+
model: agent.identity.model,
|
|
664
|
+
labels: agent.identity.labels?.('action'),
|
|
665
|
+
hitl: {
|
|
666
|
+
feedback: feedback.map(function mapFeedback(f) {
|
|
667
|
+
return { author: f.author, body: f.body };
|
|
668
|
+
}),
|
|
669
|
+
reviewer: reviewers[0] ?? 'reviewer'
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
this.orchestrator.activity(agent.identity.name);
|
|
673
|
+
|
|
674
|
+
// Re-request review after rework
|
|
675
|
+
const hitlAt = new Date().toISOString();
|
|
676
|
+
if (reviewers.length) {
|
|
677
|
+
await this.hub.request(repo, pr, reviewers);
|
|
678
|
+
await this.hub.assign(repo, pr, reviewers);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
log.info(`rework agent: ${agent.identity.name}`);
|
|
682
|
+
return { reworkAgent: agent.identity.name, hitlAt };
|
|
683
|
+
}
|
|
684
|
+
}
|