loreli 0.0.0 → 1.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.
Files changed (88) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +670 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +74 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/src/index.js +656 -0
  8. package/packages/agent/README.md +517 -0
  9. package/packages/agent/src/backends/claude.js +287 -0
  10. package/packages/agent/src/backends/codex.js +278 -0
  11. package/packages/agent/src/backends/cursor.js +294 -0
  12. package/packages/agent/src/backends/index.js +329 -0
  13. package/packages/agent/src/base.js +138 -0
  14. package/packages/agent/src/cli.js +198 -0
  15. package/packages/agent/src/factory.js +119 -0
  16. package/packages/agent/src/index.js +12 -0
  17. package/packages/agent/src/models.js +141 -0
  18. package/packages/agent/src/output.js +62 -0
  19. package/packages/agent/src/session.js +162 -0
  20. package/packages/agent/src/trace.js +186 -0
  21. package/packages/config/README.md +833 -0
  22. package/packages/config/src/defaults.js +134 -0
  23. package/packages/config/src/index.js +192 -0
  24. package/packages/config/src/schema.js +273 -0
  25. package/packages/config/src/validate.js +160 -0
  26. package/packages/context/README.md +165 -0
  27. package/packages/context/src/index.js +198 -0
  28. package/packages/hub/README.md +338 -0
  29. package/packages/hub/src/base.js +154 -0
  30. package/packages/hub/src/github.js +1558 -0
  31. package/packages/hub/src/index.js +79 -0
  32. package/packages/hub/src/labels.js +48 -0
  33. package/packages/identity/README.md +288 -0
  34. package/packages/identity/src/index.js +620 -0
  35. package/packages/identity/src/themes/avatar.js +217 -0
  36. package/packages/identity/src/themes/digimon.js +217 -0
  37. package/packages/identity/src/themes/dragonball.js +217 -0
  38. package/packages/identity/src/themes/lotr.js +217 -0
  39. package/packages/identity/src/themes/marvel.js +217 -0
  40. package/packages/identity/src/themes/pokemon.js +217 -0
  41. package/packages/identity/src/themes/starwars.js +217 -0
  42. package/packages/identity/src/themes/transformers.js +217 -0
  43. package/packages/identity/src/themes/zelda.js +217 -0
  44. package/packages/knowledge/README.md +237 -0
  45. package/packages/knowledge/src/index.js +412 -0
  46. package/packages/log/README.md +93 -0
  47. package/packages/log/src/index.js +252 -0
  48. package/packages/marker/README.md +200 -0
  49. package/packages/marker/src/index.js +184 -0
  50. package/packages/mcp/README.md +279 -0
  51. package/packages/mcp/instructions.md +121 -0
  52. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  53. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  54. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  55. package/packages/mcp/scaffolding/loreli.yml +453 -0
  56. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
  57. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
  58. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
  59. package/packages/mcp/scaffolding/pull-request.md +23 -0
  60. package/packages/mcp/src/index.js +571 -0
  61. package/packages/mcp/src/tools/agents.js +429 -0
  62. package/packages/mcp/src/tools/context.js +199 -0
  63. package/packages/mcp/src/tools/github.js +1199 -0
  64. package/packages/mcp/src/tools/hitl.js +149 -0
  65. package/packages/mcp/src/tools/index.js +17 -0
  66. package/packages/mcp/src/tools/start.js +835 -0
  67. package/packages/mcp/src/tools/status.js +146 -0
  68. package/packages/mcp/src/tools/work.js +124 -0
  69. package/packages/orchestrator/README.md +192 -0
  70. package/packages/orchestrator/src/index.js +1226 -0
  71. package/packages/planner/README.md +168 -0
  72. package/packages/planner/src/index.js +1166 -0
  73. package/packages/review/README.md +129 -0
  74. package/packages/review/src/index.js +1283 -0
  75. package/packages/risk/README.md +119 -0
  76. package/packages/risk/src/index.js +428 -0
  77. package/packages/session/README.md +165 -0
  78. package/packages/session/src/index.js +215 -0
  79. package/packages/test-utils/README.md +96 -0
  80. package/packages/test-utils/src/index.js +354 -0
  81. package/packages/tmux/README.md +261 -0
  82. package/packages/tmux/src/index.js +452 -0
  83. package/packages/workflow/README.md +313 -0
  84. package/packages/workflow/src/index.js +481 -0
  85. package/packages/workflow/src/proof-of-life.js +74 -0
  86. package/packages/workspace/README.md +143 -0
  87. package/packages/workspace/src/index.js +1076 -0
  88. package/index.js +0 -8
@@ -0,0 +1,1283 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { Workflow } from 'loreli/workflow';
4
+ import { output } from 'loreli/agent';
5
+ import { checkout, pathFor } from 'loreli/workspace';
6
+ import { side, capability } from 'loreli/identity';
7
+ import { logger } from 'loreli/log';
8
+ import { mark, has, parse, excise } from 'loreli/marker';
9
+ import { classify } from 'loreli/knowledge';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const log = logger('review');
13
+ const CLOSE_RE = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi;
14
+
15
+ /**
16
+ * Build workspace context used to regenerate MCP scaffolding after checkout.
17
+ *
18
+ * @param {object} orchestrator - Orchestrator instance.
19
+ * @param {string} repo - Repository in "owner/name" format.
20
+ * @param {string} agent - Agent identity name.
21
+ * @returns {object}
22
+ */
23
+ function workspaceContext(orchestrator, repo, agent) {
24
+ const context = {
25
+ session: orchestrator.sessionId,
26
+ agent,
27
+ repo,
28
+ home: orchestrator.storage?.home,
29
+ denied: orchestrator.cfg?.get?.('agents.disallowedTools') ?? []
30
+ };
31
+
32
+ if (process.env.GITHUB_TOKEN) context.token = process.env.GITHUB_TOKEN;
33
+
34
+ return context;
35
+ }
36
+
37
+ /**
38
+ * Build checkout options that preserve workspace scaffolding.
39
+ *
40
+ * @param {object} orchestrator - Orchestrator instance.
41
+ * @param {string} repo - Repository in "owner/name" format.
42
+ * @param {string} agent - Agent identity name.
43
+ * @returns {{context: object, descriptors: object[]}}
44
+ */
45
+ function workspaceOptions(orchestrator, repo, agent) {
46
+ const context = workspaceContext(orchestrator, repo, agent);
47
+ const descriptors = orchestrator.backendRegistry?.scaffoldAll?.(context) ?? [];
48
+ return { context, descriptors };
49
+ }
50
+
51
+ /**
52
+ * Path to the action prompt template. Used for cross-role rendering
53
+ * when forwarding review feedback to the action agent.
54
+ *
55
+ * @type {string}
56
+ */
57
+ const ACTION_TEMPLATE = join(__dirname, '..', '..', 'action', 'prompts', 'action.md');
58
+
59
+ /**
60
+ * Review workflow for Loreli's orchestration pipeline.
61
+ *
62
+ * Manages reviewer agents — PR scanning, review dispatch, feedback
63
+ * forwarding, merge landing, Human In The Loop (HITL), stalemate escalation, and
64
+ * the signoff protocol.
65
+ *
66
+ * @extends Workflow
67
+ */
68
+ export class ReviewWorkflow extends Workflow {
69
+ /** @type {string} Agent role this workflow manages. */
70
+ static role = 'reviewer';
71
+
72
+ /** @type {string} Mustache template path for reviewer prompts. */
73
+ static template = join(__dirname, '..', 'prompts', 'reviewer.md');
74
+
75
+ /**
76
+ * Tracked PRs undergoing review.
77
+ * Keys are PR numbers, values are { agent, reviewer, rounds }.
78
+ *
79
+ * @type {Map<number, {agent: string, reviewer: string, rounds: number}>}
80
+ */
81
+ _watched = new Map();
82
+
83
+ /**
84
+ * PRs that have been fully processed (merged, failed, or escalated).
85
+ * Prevents scan() from re-adding PRs after land() removes them.
86
+ *
87
+ * @type {Set<number>}
88
+ */
89
+ _completed = new Set();
90
+
91
+ /**
92
+ * Preserved round counts for evicted PRs. When a reviewer is evicted
93
+ * (stalled or killed) and scan() re-adds the PR with a new reviewer,
94
+ * the round count must carry over so the escalation threshold
95
+ * (maxRounds) is eventually reached. Without this, evict → scan
96
+ * cycles reset rounds to 0 indefinitely.
97
+ *
98
+ * @type {Map<number, number>}
99
+ */
100
+ _evictedRounds = new Map();
101
+
102
+ /**
103
+ * Extract linked issue numbers from a PR body using GitHub closing
104
+ * keywords (`Closes #N`, `Fixes #N`, `Resolves #N`).
105
+ *
106
+ * @param {string} body - Pull request body text.
107
+ * @returns {number[]} Unique linked issue numbers.
108
+ */
109
+ links(body) {
110
+ if (!body) return [];
111
+ const found = new Set();
112
+ CLOSE_RE.lastIndex = 0;
113
+
114
+ let match;
115
+ while ((match = CLOSE_RE.exec(body)) !== null) {
116
+ const number = Number.parseInt(match[1], 10);
117
+ if (Number.isInteger(number)) found.add(number);
118
+ }
119
+
120
+ return [...found];
121
+ }
122
+
123
+ /**
124
+ * Reconcile linked issue closure for non-default merge targets.
125
+ *
126
+ * GitHub only auto-closes linked issues for merges to the default
127
+ * branch. When Loreli targets a non-default merge base, merged PRs
128
+ * can leave linked issues open and trigger duplicate work cycles.
129
+ * This closes still-open linked issues after merge, but only when
130
+ * base != default branch.
131
+ *
132
+ * @param {string} repo - Repository in "owner/name" format.
133
+ * @param {number} prNum - Pull request number.
134
+ * @returns {Promise<void>}
135
+ */
136
+ async reconcile(repo, prNum) {
137
+ if (!this.hub?.pull || !this.hub?.repo || !this.hub?.issue || !this.hub?.update) return;
138
+
139
+ let pr;
140
+ try {
141
+ pr = await this.hub.pull(repo, prNum);
142
+ } catch (err) {
143
+ log.warn(`reconcile: failed to read PR #${prNum}: ${err.message}`);
144
+ return;
145
+ }
146
+
147
+ let info;
148
+ try {
149
+ info = await this.hub.repo(repo);
150
+ } catch (err) {
151
+ log.warn(`reconcile: failed to read repo metadata for ${repo}: ${err.message}`);
152
+ return;
153
+ }
154
+
155
+ const base = pr?.base;
156
+ const main = info?.default_branch ?? 'main';
157
+ if (!base || base === main) return;
158
+
159
+ const linked = this.links(pr?.body);
160
+ for (const number of linked) {
161
+ try {
162
+ const issue = await this.hub.issue(repo, number);
163
+ if (issue?.state !== 'open') continue;
164
+
165
+ await this.hub.update(repo, number, { state: 'closed' });
166
+ log.info(`reconcile: closed linked issue #${number} after merge of PR #${prNum} (base=${base}, default=${main})`);
167
+ } catch (err) {
168
+ log.warn(`reconcile: failed to close linked issue #${number} for PR #${prNum}: ${err.message}`);
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Evaluate a foreign reviewer assignment through the proof-of-life protocol.
175
+ *
176
+ * Same logic as ActionWorkflow._checkForeignClaim but operates on PRs.
177
+ *
178
+ * @param {string} repo - Repository in "owner/name" format.
179
+ * @param {number} prNum - PR number.
180
+ * @param {string} reviewer - Reviewer agent name being released.
181
+ * @param {string} reason - Human-readable eviction reason.
182
+ * @returns {Promise<void>}
183
+ */
184
+ async _releaseReviewer(repo, prNum, reviewer, reason) {
185
+ const identity = this.orchestrator.clientIdentity;
186
+ if (!identity || !this.hub) return;
187
+ try {
188
+ const body = mark('review-release', { agent: reviewer });
189
+ await this.hub.as(identity, 'orchestrator')
190
+ .comment(repo, prNum, `${reason}\n\n${body}`);
191
+ } catch (err) {
192
+ log.warn(`scan: release comment failed for PR #${prNum}: ${err.message}`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Check whether a foreign reviewer (not in this orchestrator's agent
198
+ * map) is still alive via the proof-of-life comment protocol.
199
+ *
200
+ * Delegates to the base Workflow `check()` method which implements
201
+ * the unified, status-aware PoL gate.
202
+ *
203
+ * @param {string} repo - Repository in "owner/name" format.
204
+ * @param {number} prNum - PR number.
205
+ * @param {object} tracking - Watched PR tracking object.
206
+ * @returns {Promise<string>} 'active'|'requested'|'pending'|'release'.
207
+ */
208
+ async _checkForeignReviewer(repo, prNum, tracking) {
209
+ return this.check(repo, prNum, tracking.reviewer);
210
+ }
211
+
212
+ /**
213
+ * Report reviewer demand: how many PRs need a reviewer vs how many
214
+ * reviewers are active. Uses hydrated state so no extra API calls.
215
+ *
216
+ * @param {string} repo - Repository in "owner/name" format.
217
+ * @returns {Promise<{workload: number, supply: number, deficit: number}>}
218
+ */
219
+ async demand(repo) {
220
+ const prs = await this.hub.pulls(repo, { state: 'open' });
221
+ const skipRisk = this.orchestrator.cfg?.get?.('review.skipRiskAssessment') ?? false;
222
+ let allowSameSide = false;
223
+
224
+ try {
225
+ await this.orchestrator.backendRegistry?.discover?.();
226
+ const providers = this.orchestrator.backendRegistry?.providers?.() ?? [];
227
+ allowSameSide = capability(providers).mode === 'single';
228
+ } catch { /* non-fatal: keep strict pairing */ }
229
+
230
+ let workload = 0;
231
+ const actionProviders = new Set();
232
+ for (const pr of prs) {
233
+ if (this._watched.has(pr.number)) continue;
234
+ if (this._completed.has(pr.number)) continue;
235
+
236
+ // Same filter as scan() — only count PRs that pass the label gate
237
+ if (!skipRisk) {
238
+ const hasRiskLabel = pr.labels?.some(function isRisk(l) {
239
+ const name = l.name ?? l;
240
+ return name.endsWith('-risk') && name.startsWith('loreli:');
241
+ });
242
+ if (!hasRiskLabel) continue;
243
+
244
+ const isCritical = pr.labels?.some(function isCrit(l) {
245
+ return (l.name ?? l) === 'loreli:critical-risk';
246
+ });
247
+ if (isCritical) continue;
248
+ }
249
+
250
+ // Collect action providers from PR labels so scale() can spawn
251
+ // the correct opposing-side reviewer even when no live action
252
+ // agent exists.
253
+ const providerLabel = pr.labels?.find(function isProvider(l) {
254
+ const name = l.name ?? l;
255
+ return name === 'loreli:anthropic' || name === 'loreli:openai' ||
256
+ name === 'loreli:cursor-anthropic' || name === 'loreli:cursor-openai';
257
+ });
258
+ if (providerLabel) actionProviders.add((providerLabel.name ?? providerLabel).replace('loreli:', ''));
259
+
260
+ workload++;
261
+ }
262
+
263
+ // Only count reviewers that can actually be paired with an action
264
+ // agent. A same-side reviewer (e.g. openai reviewer for an openai
265
+ // action agent) passes the role filter but pair() will reject it,
266
+ // creating a false "fully staffed" signal that suppresses scaling.
267
+ //
268
+ // When no live action agent exists (dead/foreign), fall back to
269
+ // PR label metadata to determine which side the action was on.
270
+ const actions = [...this.orchestrator.agents.values()]
271
+ .filter(function isAction(a) { return a.role === 'action'; });
272
+
273
+ const actionSides = actions.length
274
+ ? new Set(actions.map(function toSide(a) { return side(a.identity?.provider); }))
275
+ : new Set([...actionProviders].map(function toSide(p) { return side(p); }));
276
+
277
+ const assigned = new Set(
278
+ [...this._watched.values()].map(function name(t) { return t.reviewer; })
279
+ );
280
+
281
+ const supply = [...this.orchestrator.agents.values()]
282
+ .filter(function isPairable(a) {
283
+ if (a.role !== 'reviewer' || a.state === 'dormant') return false;
284
+ if (assigned.has(a.identity?.name)) return false;
285
+ if (allowSameSide) return true;
286
+ const reviewerSide = side(a.identity?.provider);
287
+ return actionSides.size === 0 || [...actionSides].some(function opposes(s) { return s !== reviewerSide; });
288
+ }).length;
289
+
290
+ return {
291
+ workload,
292
+ supply,
293
+ deficit: Math.max(0, workload - supply),
294
+ actionProviders: [...actionProviders]
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Register reactor handlers for the orchestrator's tick loop.
300
+ * Hydrate → Scan → Forward → Land runs sequentially on each tick.
301
+ *
302
+ * @returns {Record<string, function>} Handler map.
303
+ */
304
+ reactor() {
305
+ const self = this;
306
+ return {
307
+ async 'review-hydrate'(repo) { await self.hydrate(repo); },
308
+ async scan(repo) { await self.scan(repo); },
309
+ async forward(repo) { await self.forward(repo); },
310
+ async land(repo) { await self.land(repo); },
311
+ async 'review-reap'(repo) { await self.reap(repo); }
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Rehydrate in-memory review state from GitHub artifacts.
317
+ *
318
+ * Called at the start of each reactor tick so that PRs dispatched
319
+ * by other participants (or lost to a process restart) are visible
320
+ * to forward() and land(). Without this, only the process that
321
+ * originally called scan() would track the PR.
322
+ *
323
+ * Derives state from:
324
+ * - `review-claim` marker comments → PR is being reviewed
325
+ * - PR branch prefix → action agent name
326
+ * - CHANGES_REQUESTED reviews → round count
327
+ * - Terminal labels (needs-attention, escalated, merge-failed) → completed
328
+ * - Closed/merged PR state → completed
329
+ *
330
+ * @param {string} repo - Repository in "owner/name" format.
331
+ * @returns {Promise<void>}
332
+ */
333
+ async hydrate(repo) {
334
+ if (!this.hub) return;
335
+
336
+ const prs = await this.hub.pulls(repo, { state: 'open' });
337
+
338
+ for (const pr of prs) {
339
+ if (this._watched.has(pr.number) || this._completed.has(pr.number)) continue;
340
+
341
+ // Terminal labels → mark completed
342
+ const isTerminal = pr.labels?.some(function isEnd(l) {
343
+ const name = l.name ?? l;
344
+ return name === 'loreli:needs-attention' ||
345
+ name === 'loreli:escalated' ||
346
+ name === 'loreli:merge-failed';
347
+ });
348
+ if (isTerminal) {
349
+ this._completed.add(pr.number);
350
+ continue;
351
+ }
352
+
353
+ const comments = await this.hub.comments(repo, pr.number);
354
+ const claim = comments.find(function isClaim(c) { return has(c.body, 'review-claim'); });
355
+ if (!claim) continue;
356
+
357
+ const reviewerName = parse(claim.body, 'review-claim')?.agent;
358
+ if (!reviewerName) continue;
359
+
360
+ const released = comments.some(function isRelease(c) {
361
+ if (!has(c.body, 'review-release')) return false;
362
+ const data = parse(c.body, 'review-release');
363
+ return data?.agent === reviewerName;
364
+ });
365
+ if (released) continue;
366
+
367
+ // Agent name from branch prefix
368
+ const slash = pr.head?.indexOf('/');
369
+ const agentName = slash > 0 ? pr.head.slice(0, slash) : null;
370
+
371
+ // Round count from CHANGES_REQUESTED reviews
372
+ const reviews = await this.hub.reviews(repo, pr.number);
373
+ const rounds = reviews.filter(function isCR(r) {
374
+ return r.state === 'CHANGES_REQUESTED';
375
+ }).length;
376
+
377
+ this._watched.set(pr.number, {
378
+ agent: agentName,
379
+ reviewer: reviewerName,
380
+ rounds,
381
+ dispatchedAt: new Date(claim.created ?? claim.created_at).getTime()
382
+ });
383
+ }
384
+
385
+ // Move PRs that closed/merged between ticks from _watched to _completed.
386
+ // Only open PRs appear in the pulls() result above, so any _watched
387
+ // PR not present was resolved since the last tick.
388
+ for (const prNum of this._watched.keys()) {
389
+ if (!prs.some(function match(p) { return p.number === prNum; })) {
390
+ this._watched.delete(prNum);
391
+ this._completed.add(prNum);
392
+ }
393
+ }
394
+ }
395
+
396
+ // ── Signoff Protocol ──────────────────────────────────
397
+
398
+ /**
399
+ * Post an approval comment using the hub's scoped identity.
400
+ * Embeds a machine-readable signoff marker so downstream tools can
401
+ * detect approvals without parsing visible text.
402
+ *
403
+ * @param {string} repo - Repository in "owner/name" format.
404
+ * @param {number} number - Issue or PR number.
405
+ * @param {object} identity - Agent identity with name.
406
+ * @param {string} role - Agent role for hub scoping.
407
+ * @returns {Promise<void>}
408
+ */
409
+ async signoff(repo, number, identity, role) {
410
+ const scoped = this.hub.as(identity, role);
411
+ const marker = mark('signoff', { agent: identity.name });
412
+ const visible = identity.approval?.() ?? `Approved by **${identity.name}**`;
413
+ await scoped.comment(repo, number, `${marker}\n${visible}`);
414
+ }
415
+
416
+ // ── Scan ──────────────────────────────────────────────
417
+
418
+ /**
419
+ * Scan for new PRs created by action agents and dispatch
420
+ * opposing-provider reviewers.
421
+ *
422
+ * Uses branch naming convention (`{agentName}/issue-{number}`)
423
+ * to match PRs to agents. Auto-enlists opposing reviewer when
424
+ * none is available.
425
+ *
426
+ * @param {string} repo - Repository in "owner/name" format.
427
+ * @returns {Promise<Array<{pr: number, reviewer: string}>>}
428
+ */
429
+ async scan(repo) {
430
+ // Evict tracked PRs whose reviewer is no longer useful.
431
+ //
432
+ // Two cases:
433
+ // 1. Reviewer killed — stall detection (tier 3) removed it from the
434
+ // agents map. Without eviction the PR stays in _watched forever.
435
+ // 2. Reviewer stalled — the reviewer is alive but idle beyond the
436
+ // stall timeout. Waiting for tier 3 kill (3x) would exceed
437
+ // typical review timeouts. Proactive eviction lets scan()
438
+ // assign a replacement sooner.
439
+ //
440
+ // In both cases forward() dedup skips the stale review and land()
441
+ // finds no APPROVE — a deadlock unless we evict here.
442
+ const stallTimeout = this.orchestrator.stallTimeout;
443
+ const now = Date.now();
444
+ for (const [prNum, tracking] of this._watched) {
445
+ const reviewer = this.orchestrator.agents.get(tracking.reviewer);
446
+ if (!reviewer) {
447
+ // Locally removed agents (killed by our stall detection) can
448
+ // be evicted immediately — no proof-of-life needed.
449
+ if (this.orchestrator._removed?.has(tracking.reviewer)) {
450
+ log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} was locally removed`);
451
+ this._evictedRounds.set(prNum, tracking.rounds);
452
+ this._watched.delete(prNum);
453
+ await this._releaseReviewer(repo, prNum, tracking.reviewer,
454
+ `Review claim released — reviewer \`${tracking.reviewer}\` is no longer active.`);
455
+ continue;
456
+ }
457
+
458
+ // Foreign reviewer — use proof-of-life protocol before evicting
459
+ const verdict = await this._checkForeignReviewer(repo, prNum, tracking);
460
+ if (verdict !== 'release') {
461
+ log.debug(`scan: foreign reviewer ${tracking.reviewer} on PR #${prNum} — ${verdict}`);
462
+ continue;
463
+ }
464
+ log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} proof-of-life expired`);
465
+ this._evictedRounds.set(prNum, tracking.rounds);
466
+ this._watched.delete(prNum);
467
+ await this._releaseReviewer(repo, prNum, tracking.reviewer,
468
+ `Review claim released — no proof of life from \`${tracking.reviewer}\`.`);
469
+ continue;
470
+ }
471
+
472
+ // Fast eviction for dormant reviewers — crash recovery when
473
+ // the orchestrator's stall detection may not catch it for
474
+ // minutes. Checking state here on every tick gives sub-second
475
+ // recovery.
476
+ if (reviewer.state === 'dormant') {
477
+ log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} is dormant`);
478
+ this._evictedRounds.set(prNum, tracking.rounds);
479
+ this._watched.delete(prNum);
480
+ await this._releaseReviewer(repo, prNum, tracking.reviewer,
481
+ `Review claim released — reviewer \`${tracking.reviewer}\` is dormant.`);
482
+ continue;
483
+ }
484
+
485
+ // Evict reviewers whose dispatch exceeded the stall timeout
486
+ // without submitting any review. This catches reviewers that
487
+ // appear active (MCP calls) but are stuck (corrupted workspace,
488
+ // missing task file, confused agent loop).
489
+ if (tracking.dispatchedAt) {
490
+ const elapsed = now - tracking.dispatchedAt;
491
+ if (elapsed > stallTimeout) {
492
+ const reviews = await this.hub.reviews(repo, prNum);
493
+ // All agents share a single GitHub token — check for ANY
494
+ // review submitted after the dispatch timestamp, not by
495
+ // specific user/agent name.
496
+ const submitted = reviews.some(function afterDispatch(r) {
497
+ return new Date(r.submitted).getTime() > tracking.dispatchedAt;
498
+ });
499
+ if (!submitted) {
500
+ log.warn(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} dispatched ${Math.round(elapsed / 1000)}s ago with no review`);
501
+ this._evictedRounds.set(prNum, tracking.rounds);
502
+ this._watched.delete(prNum);
503
+ await this._releaseReviewer(repo, prNum, tracking.reviewer,
504
+ `Review claim released — reviewer \`${tracking.reviewer}\` stalled (${Math.round(elapsed / 1000)}s, no review submitted).`);
505
+ continue;
506
+ }
507
+ }
508
+ }
509
+
510
+ const last = this.orchestrator._lastActivity.get(tracking.reviewer);
511
+ const idle = last ? now - new Date(last).getTime() : 0;
512
+ if (idle > stallTimeout) {
513
+ const verdict = await this.check(repo, prNum, tracking.reviewer);
514
+ if (verdict !== 'release') {
515
+ log.debug(`scan: reviewer ${tracking.reviewer} on PR #${prNum} idle but PoL: ${verdict}`);
516
+ continue;
517
+ }
518
+ log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} confirmed stalled via PoL`);
519
+ this._evictedRounds.set(prNum, tracking.rounds);
520
+ this._watched.delete(prNum);
521
+ await this._releaseReviewer(repo, prNum, tracking.reviewer,
522
+ `Review claim released — reviewer \`${tracking.reviewer}\` confirmed stalled.`);
523
+ }
524
+ }
525
+
526
+ const dispatched = [];
527
+ const prs = await this.hub.pulls(repo, { state: 'open' });
528
+
529
+ // Piggyback: record agent names discovered from PR branch prefixes
530
+ // in the orchestrator's taken set. Zero API cost — pulls() was
531
+ // already needed for review scanning.
532
+ for (const pr of prs) {
533
+ const slash = pr.head?.indexOf('/');
534
+ if (slash > 0) this.orchestrator.takenNames?.add(pr.head.slice(0, slash));
535
+ }
536
+
537
+ const actions = [...this.orchestrator.agents.values()]
538
+ .filter(function isAction(a) { return a.role === 'action'; });
539
+
540
+ const skipRisk = this.orchestrator.cfg?.get?.('review.skipRiskAssessment') ?? false;
541
+ let allowSameSide = false;
542
+
543
+ try {
544
+ await this.orchestrator.backendRegistry?.discover?.();
545
+ const providers = this.orchestrator.backendRegistry?.providers?.() ?? [];
546
+ allowSameSide = capability(providers).mode === 'single';
547
+ } catch { /* non-fatal: keep strict pairing */ }
548
+
549
+ for (const pr of prs) {
550
+ if (this._watched.has(pr.number)) continue;
551
+ if (this._completed.has(pr.number)) continue;
552
+
553
+ // Derive author name from branch prefix convention: {agent}/issue-{N}
554
+ const slash = pr.head?.indexOf('/');
555
+ const authorName = slash > 0 ? pr.head.slice(0, slash) : null;
556
+ if (!authorName) continue;
557
+
558
+ // Check for existing review-claim marker — another participant
559
+ // may have already claimed this PR for review. Skip this check
560
+ // for PRs we just evicted (reviewer dead or stalled) — we need
561
+ // to allow re-claim so a replacement reviewer can be dispatched.
562
+ //
563
+ // Also allow re-claim when the claim has a matching review-release
564
+ // marker — the previous reviewer was evicted (possibly by a
565
+ // different orchestrator session) and the PR is available.
566
+ // `wasReleased` tracks this so claimFirst is also skipped — the
567
+ // old claim comment still exists and would win the race otherwise.
568
+ const wasEvicted = this._evictedRounds.has(pr.number);
569
+ let wasReleased = false;
570
+ if (!wasEvicted) {
571
+ const prComments = await this.hub.comments(repo, pr.number);
572
+ const claim = prComments.find(function isClaimed(c) { return has(c.body, 'review-claim'); });
573
+ if (claim) {
574
+ const claimedAgent = parse(claim.body, 'review-claim')?.agent;
575
+ const released = claimedAgent && prComments.some(function isRelease(c) {
576
+ if (!has(c.body, 'review-release')) return false;
577
+ return parse(c.body, 'review-release')?.agent === claimedAgent;
578
+ });
579
+ if (!released) {
580
+ log.debug(`scan: PR #${pr.number} already claimed by ${claimedAgent} — skipping`);
581
+ continue;
582
+ }
583
+ log.info(`scan: PR #${pr.number} claim by ${claimedAgent} was released — allowing re-claim`);
584
+ wasReleased = true;
585
+ }
586
+ }
587
+
588
+ // Prefer live action agent for full identity context; fall back
589
+ // to PR-derived metadata when the original agent was reaped,
590
+ // belongs to another orchestrator, or is from a previous session.
591
+ const agent = actions.find(function owns(a) {
592
+ return pr.head.startsWith(a.identity.name + '/');
593
+ });
594
+
595
+ // Derive provider from PR labels when no live agent exists.
596
+ // PRs from dead/reaped agents still carry loreli:anthropic or
597
+ // loreli:openai labels applied at claim time.
598
+ const providerLabel = pr.labels?.find(function isProvider(l) {
599
+ const name = l.name ?? l;
600
+ return name === 'loreli:anthropic' || name === 'loreli:openai' ||
601
+ name === 'loreli:cursor-anthropic' || name === 'loreli:cursor-openai';
602
+ });
603
+
604
+ // Require either a live agent match or recognizable loreli labels.
605
+ // Prevents dispatching reviewers for non-Loreli PRs.
606
+ if (!agent && !providerLabel) continue;
607
+
608
+ const authorProvider = agent?.identity?.provider
609
+ ?? (providerLabel?.name ?? providerLabel)?.replace('loreli:', '')
610
+ ?? 'unknown';
611
+
612
+ // Label gate: only dispatch reviewer for PRs that have been
613
+ // risk-assessed (carry a loreli:*-risk label), or when risk
614
+ // assessment is disabled. RiskWorkflow runs first in the
615
+ // reactor chain and applies labels before this handler fires.
616
+ if (!skipRisk) {
617
+ const hasRiskLabel = pr.labels?.some(function isRisk(l) {
618
+ const name = l.name ?? l;
619
+ return name.endsWith('-risk') && name.startsWith('loreli:');
620
+ });
621
+ if (!hasRiskLabel) continue;
622
+
623
+ // CRITICAL PRs must be escalated to HITL — never assign a reviewer.
624
+ // The label was applied by RiskWorkflow; scan() performs the escalation.
625
+ const isCritical = pr.labels?.some(function isCrit(l) {
626
+ return (l.name ?? l) === 'loreli:critical-risk';
627
+ });
628
+ if (isCritical) {
629
+ try {
630
+ await this.hitl(repo, pr.number);
631
+ } catch (err) {
632
+ log.warn(`scan: HITL failed for CRITICAL PR #${pr.number}: ${err.message}`);
633
+ }
634
+ this._completed.add(pr.number);
635
+ log.info(`scan: PR #${pr.number} escalated to HITL — risk verdict CRITICAL`);
636
+ continue;
637
+ }
638
+ }
639
+
640
+ // Attach risk context for MEDIUM PRs so the reviewer sees the warning
641
+ let riskContext = null;
642
+ const isMedium = pr.labels?.some(function isMed(l) {
643
+ return (l.name ?? l) === 'loreli:medium-risk';
644
+ });
645
+ if (isMedium) {
646
+ const comments = await this.hub.comments(repo, pr.number);
647
+ const riskComment = comments.find(function hasRisk(c) { return has(c.body, 'risk'); });
648
+ if (riskComment) riskContext = { assessment: riskComment.body };
649
+ }
650
+
651
+ const assigned = new Set(
652
+ [...this._watched.values()].map(function name(t) { return t.reviewer; })
653
+ );
654
+ const reviewers = [...this.orchestrator.agents.values()]
655
+ .filter(function isAvailable(a) {
656
+ if (a.role !== 'reviewer') return false;
657
+ return !assigned.has(a.identity?.name);
658
+ });
659
+
660
+ // Verify a reviewer is available BEFORE claiming. Claiming
661
+ // without an available reviewer creates a deadlock: the next tick
662
+ // sees the review-claim, skips the PR, but no reviewer was
663
+ // dispatched so the PR is stuck forever.
664
+ const pairIdentity = agent?.identity ?? { provider: authorProvider };
665
+ let reviewer = this.pair(pairIdentity, reviewers);
666
+ if (!reviewer && allowSameSide && reviewers.length) {
667
+ reviewer = reviewers[0];
668
+ }
669
+ if (!reviewer) {
670
+ log.debug(`scan: no reviewer available for PR #${pr.number} — waiting for scale()`);
671
+ continue;
672
+ }
673
+
674
+ // Optimistic claim: post a review-claim marker on the PR,
675
+ // then verify this agent was first. Prevents two reactors
676
+ // from both dispatching reviewers for the same PR.
677
+ // Skip for evicted PRs and released claims — the old claim
678
+ // comment still exists on GitHub and would win the first-
679
+ // comment race. We already verified the claim is dead above.
680
+ const claimIdentity = reviewer.identity ?? this.orchestrator.clientIdentity;
681
+ if (claimIdentity && !wasEvicted && !wasReleased) {
682
+ const visible = reviewer.identity?.claim?.() ?? `Claimed by **${claimIdentity.name}**`;
683
+ const won = await this.claimFirst(repo, pr.number, 'review-claim', claimIdentity, 'reviewer', visible);
684
+ if (!won) {
685
+ log.info(`scan: lost review-claim race for PR #${pr.number} — skipping`);
686
+ continue;
687
+ }
688
+ }
689
+
690
+ const home = this.orchestrator.storage?.home;
691
+ const wsRoot = home ? `${home}/workspaces` : undefined;
692
+ const reviewerCwd = reviewer.cwd ?? pathFor(reviewer.identity.name, wsRoot);
693
+ const base = this.orchestrator.cfg?.get?.('merge.base') ?? 'main';
694
+ const options = workspaceOptions(this.orchestrator, repo, reviewer.identity.name);
695
+ try {
696
+ await checkout(reviewerCwd, pr.head, base, options);
697
+ log.info(`scan: checked out ${pr.head} in ${reviewer.identity.name}'s workspace`);
698
+ } catch (err) {
699
+ log.warn(`scan: branch checkout failed for ${reviewer.identity.name}: ${err.message}`);
700
+ }
701
+
702
+ const files = await this.hub.files(repo, pr.number);
703
+
704
+ await this.saveTask(reviewer.identity.name, {
705
+ type: 'review_pr',
706
+ pr: pr.number
707
+ });
708
+
709
+ const templateVars = {
710
+ name: reviewer.identity.name,
711
+ repo,
712
+ faction: reviewer.identity.faction,
713
+ provider: reviewer.identity.provider,
714
+ model: reviewer.identity.model,
715
+ labels: reviewer.identity.labels?.('reviewer'),
716
+ pullRequest: {
717
+ number: pr.number,
718
+ title: pr.title,
719
+ head: pr.head,
720
+ base: pr.base,
721
+ issue: excise(pr.body, 'trace') ?? pr.body,
722
+ author: agent?.identity?.name ?? authorName,
723
+ authorProvider: agent?.identity?.provider ?? authorProvider,
724
+ files
725
+ }
726
+ };
727
+ if (riskContext) templateVars.riskContext = riskContext;
728
+
729
+ await this.dispatch(reviewer, templateVars);
730
+ this.orchestrator.activity(reviewer.identity.name);
731
+
732
+ const restored = this._evictedRounds.get(pr.number) ?? 0;
733
+ this._evictedRounds.delete(pr.number);
734
+
735
+ this._watched.set(pr.number, {
736
+ agent: agent?.identity?.name ?? authorName,
737
+ reviewer: reviewer.identity.name,
738
+ rounds: restored,
739
+ dispatchedAt: Date.now()
740
+ });
741
+
742
+ dispatched.push({ pr: pr.number, reviewer: reviewer.identity.name });
743
+ log.info(`scan: dispatched ${reviewer.identity.name} to review PR #${pr.number}`);
744
+ }
745
+
746
+ return dispatched;
747
+ }
748
+
749
+ // ── Restart ─────────────────────────────────────────
750
+
751
+ /**
752
+ * Close a stale PR and release its linked issue when the action
753
+ * agent is dead. Posts a themed comment on the PR, closes it via
754
+ * hub.closePull(), posts a `restart` marker on the linked issue
755
+ * (resets the circuit breaker counter), removes blocking labels,
756
+ * and kills the assigned reviewer.
757
+ *
758
+ * @param {string} repo - Repository in "owner/name" format.
759
+ * @param {number} prNum - Pull request number.
760
+ * @param {object} tracking - Tracked PR state from _watched.
761
+ * @returns {Promise<void>}
762
+ */
763
+ async _restartAction(repo, prNum, tracking) {
764
+ const identity = this.orchestrator.clientIdentity;
765
+
766
+ // Derive the linked issue number from the PR branch name.
767
+ // Convention: {agent}/issue-{N}
768
+ let issueNum = null;
769
+ try {
770
+ const pr = await this.hub.pull(repo, prNum);
771
+ const match = pr.head?.match(/\/issue-(\d+)$/);
772
+ if (match) issueNum = Number(match[1]);
773
+ } catch (err) {
774
+ log.warn(`_restartAction: failed to fetch PR #${prNum}: ${err.message}`);
775
+ }
776
+
777
+ // Post a themed comment on the PR before closing.
778
+ if (identity) {
779
+ try {
780
+ await this.hub.as(identity, 'orchestrator')
781
+ .comment(repo, prNum,
782
+ `**Restarting** — action agent \`${tracking.agent}\` is no longer available. ` +
783
+ 'Closing this PR and releasing the linked issue for re-dispatch.');
784
+ } catch (err) {
785
+ log.warn(`_restartAction: comment failed on PR #${prNum}: ${err.message}`);
786
+ }
787
+ }
788
+
789
+ try {
790
+ await this.hub.closePull(repo, prNum);
791
+ } catch (err) {
792
+ log.warn(`_restartAction: closePull failed for PR #${prNum}: ${err.message}`);
793
+ }
794
+
795
+ // Post restart marker on the linked issue — claimant() treats
796
+ // this as a release, and the circuit breaker uses it as a high-
797
+ // water mark (releases before this point are ignored).
798
+ if (issueNum && identity) {
799
+ try {
800
+ const restartMarker = mark('restart', { agent: tracking.agent });
801
+ await this.hub.as(identity, 'orchestrator')
802
+ .comment(repo, issueNum, `${restartMarker}\nAction restarted — previous agent \`${tracking.agent}\` was unavailable.`);
803
+ } catch (err) {
804
+ log.warn(`_restartAction: restart marker failed on issue #${issueNum}: ${err.message}`);
805
+ }
806
+
807
+ // Remove blocking labels so dispatch can pick up the issue.
808
+ try {
809
+ await this.hub.unlabel(repo, issueNum, 'loreli:blocked');
810
+ await this.hub.unlabel(repo, issueNum, 'loreli:needs-attention');
811
+ } catch (err) {
812
+ log.warn(`_restartAction: unlabel failed on issue #${issueNum}: ${err.message}`);
813
+ }
814
+ }
815
+
816
+ this._watched.delete(prNum);
817
+ this._completed.add(prNum);
818
+
819
+ // Kill the reviewer — the PR is closed, no review needed.
820
+ if (tracking.reviewer && this.orchestrator.agents.has(tracking.reviewer)) {
821
+ try {
822
+ await this.orchestrator.kill(tracking.reviewer);
823
+ } catch (err) {
824
+ log.warn(`_restartAction: kill reviewer ${tracking.reviewer} failed: ${err.message}`);
825
+ }
826
+ }
827
+
828
+ log.info(`forward: restarted PR #${prNum} — action agent ${tracking.agent} is dead` +
829
+ (issueNum ? `, issue #${issueNum} released for re-dispatch` : ''));
830
+ }
831
+
832
+ // ── Forward ───────────────────────────────────────────
833
+
834
+ /**
835
+ * Poll for new reviews on tracked PRs. If REQUEST_CHANGES, relay
836
+ * feedback to the action agent. Enforces maxRounds escalation.
837
+ *
838
+ * @param {string} repo - Repository in "owner/name" format.
839
+ * @returns {Promise<Array<{pr: number, action: string}>>}
840
+ */
841
+ async forward(repo) {
842
+ if (!this._watched.size) return [];
843
+
844
+ const maxRounds = this.orchestrator.cfg?.get?.('watch.maxRounds') ?? 7;
845
+ const forwarded = [];
846
+
847
+ for (const [prNum, tracking] of this._watched) {
848
+ const reviews = await this.hub.reviews(repo, prNum);
849
+
850
+ const latest = reviews
851
+ .filter(function fromReviewer(r) { return r.author === tracking.reviewer || r.state === 'CHANGES_REQUESTED'; })
852
+ .at(-1);
853
+
854
+ if (!latest || latest.state === 'APPROVED') continue;
855
+ if (latest.state !== 'CHANGES_REQUESTED') continue;
856
+
857
+ // Deduplicate: skip if this exact review was already relayed.
858
+ if (tracking.lastReviewId === latest.id) {
859
+ // Check if the action agent has pushed new commits since the
860
+ // review. If so, re-dispatch the reviewer.
861
+ const agent = this.orchestrator.agents.get(tracking.agent);
862
+ if (agent && latest.commitId) {
863
+ const pr = await this.hub.pull(repo, prNum);
864
+ const headSha = pr?.headSha ?? pr?.head;
865
+ if (headSha && headSha !== latest.commitId && !tracking.reviewerDispatched) {
866
+ await this._redispatch(repo, prNum, tracking, agent);
867
+ }
868
+ }
869
+ continue;
870
+ }
871
+
872
+ tracking.rounds++;
873
+ if (tracking.rounds >= maxRounds) {
874
+ log.warn(`forward: PR #${prNum} exceeded ${maxRounds} review rounds — escalating`);
875
+ await this._escalate(repo, prNum, tracking);
876
+ continue;
877
+ }
878
+
879
+ const agent = this.orchestrator.agents.get(tracking.agent);
880
+ if (!agent) {
881
+ await this._restartAction(repo, prNum, tracking);
882
+ continue;
883
+ }
884
+
885
+ const prompt = await this.renderFrom(ACTION_TEMPLATE, {
886
+ name: agent.identity.name,
887
+ repo,
888
+ faction: agent.identity.faction,
889
+ provider: agent.identity.provider,
890
+ model: agent.identity.model,
891
+ labels: agent.identity.labels?.('action'),
892
+ reviewFeedback: {
893
+ number: prNum,
894
+ reviewer: tracking.reviewer,
895
+ reviewerProvider: this.orchestrator.agents.get(tracking.reviewer)?.identity?.provider ?? 'unknown',
896
+ comments: [{ body: latest.body }]
897
+ }
898
+ }, { role: 'action' });
899
+
900
+ // Respawn the agent if it crashed or was evicted since the last tick.
901
+ if (agent.state === 'dormant') await agent.spawn();
902
+
903
+ await agent.send(prompt);
904
+ this.orchestrator.activity(agent.identity.name);
905
+ tracking.lastReviewId = latest.id;
906
+
907
+ const feedbackEnabled = this.orchestrator.cfg?.get?.('feedback.enabled') ?? true;
908
+ if (feedbackEnabled && latest.body) {
909
+ const categories = this.orchestrator.cfg?.get?.('feedback.categories');
910
+ const { category, confidence } = classify(latest.body, { categories });
911
+ if (confidence > 0) {
912
+ const identity = this.orchestrator.clientIdentity;
913
+ const provider = this.orchestrator.agents.get(tracking.reviewer)?.identity?.provider ?? 'unknown';
914
+ const marker = mark('feedback', { category, confidence: confidence.toFixed(2), provider });
915
+ const visible = `Feedback routing note: classified latest review as **${category}** (${confidence.toFixed(2)} confidence).`;
916
+ try {
917
+ if (identity) await this.hub.as(identity, 'orchestrator').comment(repo, prNum, `${marker}\n${visible}`);
918
+ log.debug(`forward: classified feedback on PR #${prNum} as ${category} (${confidence.toFixed(2)})`);
919
+ } catch (err) { log.debug(`forward: feedback marker failed: ${err.message}`); }
920
+ }
921
+ }
922
+
923
+ // Reset so _redispatch can fire again when the action agent
924
+ // pushes another round of changes.
925
+ tracking.reviewerDispatched = false;
926
+
927
+ forwarded.push({ pr: prNum, action: tracking.agent });
928
+
929
+ log.info(`forward: relayed feedback on PR #${prNum} to ${tracking.agent} (round ${tracking.rounds})`);
930
+ }
931
+
932
+ return forwarded;
933
+ }
934
+
935
+ // ── Land ──────────────────────────────────────────────
936
+
937
+ /**
938
+ * Detect approved reviews on tracked PRs. Requires cross-provider
939
+ * approval. Auto-merges or triggers Human In The Loop (HITL).
940
+ *
941
+ * @param {string} repo - Repository in "owner/name" format.
942
+ * @returns {Promise<Array<{pr: number, merged: boolean, gated: boolean}>>}
943
+ */
944
+ async land(repo) {
945
+ if (!this._watched.size) return [];
946
+
947
+ const method = this.orchestrator.cfg?.get?.('merge.method') ?? 'squash';
948
+ const hitlEnabled = this.orchestrator.cfg?.get?.('merge.hitl') ?? false;
949
+ const humanReviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
950
+ let dualSide = true;
951
+
952
+ try {
953
+ await this.orchestrator.backendRegistry?.discover?.();
954
+ const providers = this.orchestrator.backendRegistry?.providers?.() ?? [];
955
+ dualSide = capability(providers).hasDual;
956
+ } catch { /* non-fatal: keep strict gate */ }
957
+
958
+ const landed = [];
959
+
960
+ for (const [prNum, tracking] of this._watched) {
961
+ const reviews = await this.hub.reviews(repo, prNum);
962
+ const approval = reviews.find(function approved(r) {
963
+ return r.state === 'APPROVED';
964
+ });
965
+
966
+ if (!approval) continue;
967
+
968
+ // Verify cross-provider
969
+ const agent = this.orchestrator.agents.get(tracking.agent);
970
+ const reviewer = this.orchestrator.agents.get(tracking.reviewer);
971
+ if (tracking.agent === tracking.reviewer) {
972
+ log.warn(`land: PR #${prNum} approved by same identity — not sufficient`);
973
+ continue;
974
+ }
975
+
976
+ if (dualSide && agent && reviewer && side(agent.identity.provider) === side(reviewer.identity.provider)) {
977
+ log.warn(`land: PR #${prNum} approved by same-side reviewer in dual-side mode — not sufficient`);
978
+ continue;
979
+ }
980
+
981
+ // Post audit trail comment before merging or gating — mandatory
982
+ // so every merged PR has a Loreli-signed approval record.
983
+ if (reviewer) {
984
+ await this.signoff(repo, prNum, reviewer.identity, reviewer.role ?? 'reviewer');
985
+ }
986
+
987
+ if (hitlEnabled && humanReviewers.length) {
988
+ await this.hitl(repo, prNum);
989
+ this._watched.delete(prNum);
990
+ this._completed.add(prNum);
991
+ landed.push({ pr: prNum, merged: false, gated: true });
992
+ log.info(`land: PR #${prNum} escalated to HITL`);
993
+ } else {
994
+ try {
995
+ await this.hub.merge(repo, prNum, { method });
996
+ await this.reconcile(repo, prNum);
997
+ this._watched.delete(prNum);
998
+ this._completed.add(prNum);
999
+ landed.push({ pr: prNum, merged: true, gated: false });
1000
+ log.info(`land: PR #${prNum} merged via ${method}`);
1001
+ } catch (err) {
1002
+ // Merge failure escalation: alert humans instead of silently
1003
+ // abandoning. 405 = not mergeable, 409 = merge conflict.
1004
+ this._watched.delete(prNum);
1005
+ this._completed.add(prNum);
1006
+ landed.push({ pr: prNum, merged: false, gated: false });
1007
+
1008
+ const signer = this.agents()[0]?.identity ?? this.orchestrator.clientIdentity;
1009
+ if (signer) {
1010
+ try {
1011
+ const scoped = this.hub.as(signer, 'reviewer');
1012
+ await scoped.comment(repo, prNum,
1013
+ `${mark('hitl')}\n**Merge failed — needs human attention**\n\n` +
1014
+ `Auto-merge failed: \`${err.message}\`\n\n` +
1015
+ `Please resolve the issue and merge manually.`);
1016
+ await this.hub.label(repo, prNum, ['loreli:needs-attention', 'loreli:merge-failed']);
1017
+ } catch (labelErr) {
1018
+ log.warn(`land: failed to label merge-failure on PR #${prNum}: ${labelErr.message}`);
1019
+ }
1020
+ }
1021
+
1022
+ log.warn(`land: merge failed for PR #${prNum}: ${err.message}`);
1023
+ }
1024
+ }
1025
+
1026
+ // Kill the reviewer immediately — the PR is done (merged, gated,
1027
+ // or failed). Waiting for the reap sweep would leave interactive
1028
+ // reviewers stuck in 'working' state for up to a full tick cycle.
1029
+ if (tracking.reviewer && this.orchestrator.agents.has(tracking.reviewer)) {
1030
+ try {
1031
+ await this.orchestrator.kill(tracking.reviewer);
1032
+ log.info(`land: stopped reviewer ${tracking.reviewer} — PR #${prNum} finalized`);
1033
+ } catch (err) {
1034
+ log.warn(`land: failed to stop reviewer ${tracking.reviewer}: ${err.message}`);
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ // Kill action agents when all watched PRs have been finalized.
1040
+ // GitHub's issue auto-close ("Closes #N") is asynchronous — the
1041
+ // linked issue may still appear "open" milliseconds after merge.
1042
+ // Checking issue state here causes a race: land() sees open issues,
1043
+ // skips the kill, and the action agent stays "working" forever
1044
+ // (reap only touches dormant agents). Instead, unconditionally
1045
+ // kill action agents when no PRs remain in flight. If new issues
1046
+ // appear (human-created or from another planning cycle), the
1047
+ // reactor's next tick will re-enlist fresh agents via start_work.
1048
+ if (landed.length > 0 && this._watched.size === 0) {
1049
+ const actions = [...this.orchestrator.agents.values()]
1050
+ .filter(function isAction(a) { return a.role === 'action'; });
1051
+
1052
+ for (const a of actions) {
1053
+ try {
1054
+ await this.orchestrator.kill(a.identity.name);
1055
+ log.info(`land: stopped action agent ${a.identity.name} — all PRs finalized`);
1056
+ } catch (err) {
1057
+ log.warn(`land: failed to stop action agent ${a.identity.name}: ${err.message}`);
1058
+ }
1059
+ }
1060
+ }
1061
+
1062
+ return landed;
1063
+ }
1064
+
1065
+ // ── Reap (HITL Timeout) ───────────────────────────────
1066
+
1067
+ /**
1068
+ * Scan for stale HITL items that have timed out without human response.
1069
+ * Applies loreli:stale label and re-pings configured reviewers.
1070
+ * Idempotent — items already labeled loreli:stale are skipped.
1071
+ *
1072
+ * @param {string} repo - Repository in "owner/name" format.
1073
+ * @returns {Promise<void>}
1074
+ */
1075
+ async reap(repo) {
1076
+ const timeout = this.orchestrator.cfg?.get?.('hitl.timeout');
1077
+ if (timeout == null) return;
1078
+
1079
+ const items = await this.hub.pulls(repo, { state: 'open' });
1080
+ const needsAttention = [];
1081
+ for (const pr of items) {
1082
+ if (!pr.labels?.some(function isNeedsAttention(l) { return (l.name ?? l) === 'loreli:needs-attention'; })) continue;
1083
+ if (pr.labels?.some(function isStale(l) { return (l.name ?? l) === 'loreli:stale'; })) continue;
1084
+ needsAttention.push(pr);
1085
+ }
1086
+
1087
+ if (!needsAttention.length) return;
1088
+
1089
+ const now = Date.now();
1090
+ const reviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
1091
+
1092
+ for (const pr of needsAttention) {
1093
+ const comments = await this.hub.comments(repo, pr.number);
1094
+ const hitlComment = comments.find(function isHitl(c) { return has(c.body, 'hitl'); });
1095
+ if (!hitlComment) continue;
1096
+
1097
+ const elapsed = now - new Date(hitlComment.created ?? hitlComment.created_at).getTime();
1098
+ if (elapsed < timeout) continue;
1099
+
1100
+ const mentions = reviewers.length
1101
+ ? reviewers.map(function mention(r) { return `@${r}`; }).join(', ')
1102
+ : '';
1103
+
1104
+ const signer = this.agents()[0]?.identity ?? this.orchestrator.clientIdentity;
1105
+ if (signer) {
1106
+ try {
1107
+ const scoped = this.hub.as(signer, 'reviewer');
1108
+ const msg = mentions
1109
+ ? `**HITL reminder** — ${mentions}\n\nThis PR has been awaiting human attention for over ${Math.round(elapsed / 3600000)} hours.`
1110
+ : `**HITL reminder**\n\nThis PR has been awaiting human attention for over ${Math.round(elapsed / 3600000)} hours.`;
1111
+ await scoped.comment(repo, pr.number, msg);
1112
+ } catch (err) {
1113
+ log.warn(`reap: failed to post reminder on PR #${pr.number}: ${err.message}`);
1114
+ }
1115
+ }
1116
+
1117
+ try {
1118
+ await this.hub.label(repo, pr.number, ['loreli:stale']);
1119
+ } catch (err) {
1120
+ log.warn(`reap: failed to label PR #${pr.number} as stale: ${err.message}`);
1121
+ }
1122
+
1123
+ log.warn(`reap: PR #${pr.number} marked stale — HITL timeout exceeded (${Math.round(elapsed / 3600000)}h)`);
1124
+ }
1125
+ }
1126
+
1127
+ // ── HITL ──────────────────────────────────────────────
1128
+
1129
+ /**
1130
+ * Activate Human In The Loop (HITL) for a PR.
1131
+ *
1132
+ * Requests review from configured human reviewers, posts a summary
1133
+ * comment, and kills active agents to conserve resources.
1134
+ *
1135
+ * @param {string} repo - Repository in "owner/name" format.
1136
+ * @param {number} pr - Pull request number.
1137
+ * @returns {Promise<{hitlAt: string, reviewers: string[]}>}
1138
+ * @throws {Error} When no human reviewers are configured.
1139
+ */
1140
+ async hitl(repo, pr) {
1141
+ log.info(`hitl PR #${pr} on ${repo}`);
1142
+ const reviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
1143
+ if (!reviewers.length) throw new Error('No reviewers configured for HITL');
1144
+
1145
+ await this.hub.request(repo, pr, reviewers);
1146
+ await this.hub.assign(repo, pr, reviewers);
1147
+
1148
+ // Post summary comment tagging reviewers with themed HITL message
1149
+ const mentions = reviewers.map(function mention(r) { return `@${r}`; }).join(', ');
1150
+
1151
+ // Use first reviewer agent's identity for the signature,
1152
+ // fall back to orchestrator's client identity
1153
+ const reviewerAgent = this.agents()[0];
1154
+ const signer = reviewerAgent?.identity ?? this.orchestrator.clientIdentity;
1155
+ if (!signer) throw new Error('hitl() requires at least one reviewer agent or a client identity');
1156
+
1157
+ const hitlMarker = mark('hitl');
1158
+ const visible = signer.hitl?.(mentions) ?? `**Human review required**\n\nAgent review is complete. ${mentions} — please review and merge when satisfied.`;
1159
+ const body = `${hitlMarker}\n${visible}`;
1160
+
1161
+ const scoped = this.hub.as(signer, reviewerAgent?.role ?? 'reviewer');
1162
+ await scoped.comment(repo, pr, body);
1163
+
1164
+ // Kill all active agents for this session
1165
+ const names = [...this.orchestrator.agents.keys()];
1166
+ for (const name of names) {
1167
+ await this.orchestrator.kill(name);
1168
+ }
1169
+
1170
+ const hitlAt = new Date().toISOString();
1171
+ return { hitlAt, reviewers };
1172
+ }
1173
+
1174
+ // ── Re-dispatch ─────────────────────────────────────────
1175
+
1176
+ /**
1177
+ * Re-dispatch the reviewer for re-review after an action agent has
1178
+ * pushed new commits.
1179
+ *
1180
+ * Also removes the stale `loreli:changes-requested` label — the
1181
+ * action agent addressed the feedback, so the label no longer
1182
+ * reflects the current PR state.
1183
+ *
1184
+ * @param {string} repo - Repository in "owner/name" format.
1185
+ * @param {number} prNum - Pull request number.
1186
+ * @param {object} tracking - Tracked PR state from _watched.
1187
+ * @param {object} agent - The action agent.
1188
+ * @returns {Promise<void>}
1189
+ */
1190
+ async _redispatch(repo, prNum, tracking, agent) {
1191
+ // Remove stale changes-requested label — the action agent has
1192
+ // pushed new commits addressing the feedback.
1193
+ try {
1194
+ await this.hub.unlabel(repo, prNum, 'loreli:changes-requested');
1195
+ } catch (err) { log.debug(`_redispatch: unlabel failed: ${err.message}`); }
1196
+
1197
+ const reviewer = this.orchestrator.agents.get(tracking.reviewer);
1198
+ if (!reviewer) {
1199
+ log.warn(`_redispatch: reviewer ${tracking.reviewer} not found — cannot re-dispatch`);
1200
+ return;
1201
+ }
1202
+
1203
+ try {
1204
+ const [files, prDetails] = await Promise.all([
1205
+ this.hub.files(repo, prNum),
1206
+ this.hub.pull(repo, prNum)
1207
+ ]);
1208
+ const prompt = await this.render({
1209
+ name: reviewer.identity.name,
1210
+ repo,
1211
+ faction: reviewer.identity.faction,
1212
+ provider: reviewer.identity.provider,
1213
+ model: reviewer.identity.model,
1214
+ labels: reviewer.identity.labels?.('reviewer'),
1215
+ pullRequest: {
1216
+ number: prNum,
1217
+ title: prDetails?.title ?? `Re-review PR #${prNum}`,
1218
+ head: prDetails?.head ?? `${agent.identity.name}/work`,
1219
+ base: prDetails?.base ?? this.orchestrator.cfg?.get?.('merge.base') ?? 'main',
1220
+ issue: 'Re-review after feedback was addressed.',
1221
+ author: agent.identity.name,
1222
+ authorProvider: agent.identity.provider,
1223
+ files
1224
+ }
1225
+ });
1226
+
1227
+ await reviewer.send(prompt);
1228
+ this.orchestrator.activity(reviewer.identity.name);
1229
+ tracking.reviewerDispatched = true;
1230
+ log.info(`_redispatch: re-dispatched ${reviewer.identity.name} for re-review of PR #${prNum}`);
1231
+ } catch (err) {
1232
+ log.warn(`_redispatch: re-dispatch failed for PR #${prNum}: ${err.message}`);
1233
+ }
1234
+ }
1235
+
1236
+ // ── Escalation ────────────────────────────────────────
1237
+
1238
+ /**
1239
+ * Escalate a stalemate when maxRounds is exceeded.
1240
+ *
1241
+ * If human reviewers are configured, triggers HITL.
1242
+ * Otherwise, creates a discussion in the Loreli category describing
1243
+ * the stalemate and labels the PR for attention.
1244
+ *
1245
+ * @param {string} repo - Repository in "owner/name" format.
1246
+ * @param {number} prNum - Pull request number.
1247
+ * @param {object} tracking - Tracked PR state.
1248
+ * @returns {Promise<void>}
1249
+ */
1250
+ async _escalate(repo, prNum, tracking) {
1251
+ const humanReviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
1252
+
1253
+ if (humanReviewers.length) {
1254
+ await this.hitl(repo, prNum);
1255
+ this._watched.delete(prNum);
1256
+ this._completed.add(prNum);
1257
+ log.info(`escalate: PR #${prNum} handed to human reviewers after ${tracking.rounds} rounds`);
1258
+ return;
1259
+ }
1260
+
1261
+ // No human reviewers — escalation is terminal and blocking.
1262
+ // The PR stays open with loreli:needs-attention + loreli:escalated
1263
+ // until a human intervenes. Auto-approval is never performed.
1264
+ const signer = this.orchestrator.agents.get(tracking.reviewer)?.identity
1265
+ ?? this.orchestrator.clientIdentity;
1266
+
1267
+ if (signer) {
1268
+ try {
1269
+ const scoped = this.hub.as(signer, 'reviewer');
1270
+ await scoped.comment(repo, prNum,
1271
+ `${mark('hitl')}\n**Escalation — needs human attention**\n\n` +
1272
+ `This PR exceeded ${tracking.rounds} review rounds without convergence. ` +
1273
+ 'A human must review and decide whether to merge, close, or provide direction.');
1274
+ } catch (err) {
1275
+ log.warn(`escalate: comment failed for PR #${prNum}: ${err.message}`);
1276
+ }
1277
+ }
1278
+
1279
+ try {
1280
+ await this.hub.label(repo, prNum, ['loreli:needs-attention', 'loreli:escalated']);
1281
+ } catch (err) { log.warn(`escalate: labeling failed for PR #${prNum}: ${err.message}`); }
1282
+ }
1283
+ }