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