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,1166 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { Workflow } from 'loreli/workflow';
4
+ import { Identity, capability } from 'loreli/identity';
5
+ import { mark, has, parse } from 'loreli/marker';
6
+ import { classifyRefs, extractRefs } from 'loreli/knowledge';
7
+ import { logger } from 'loreli/log';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const log = logger('planner');
11
+
12
+ /**
13
+ * Path to the plan-reviewer prompt template. Used for rendering the
14
+ * adversarial plan review prompt via cross-role rendering.
15
+ *
16
+ * @type {string}
17
+ */
18
+ const PLAN_REVIEWER_TEMPLATE = join(__dirname, '..', 'prompts', 'plan-reviewer.md');
19
+
20
+ /**
21
+ * Path to the tiebreaker-reviewer prompt template. Used when a discussion
22
+ * has exhausted maxRounds of revision and needs pragmatic evaluation.
23
+ *
24
+ * @type {string}
25
+ */
26
+ const TIEBREAKER_TEMPLATE = join(__dirname, '..', 'prompts', 'tiebreaker-reviewer.md');
27
+
28
+ /**
29
+ * Planning workflow for Loreli's orchestration pipeline.
30
+ *
31
+ * Uses GitHub Discussions as the planning primitive. The planner creates
32
+ * discussions in a "Loreli" category, an adversarial reviewer applies
33
+ * labels to approve or request changes, and approved discussions are
34
+ * promoted to real issues for action agents.
35
+ *
36
+ * Labels act as a state machine:
37
+ * - No review label → needs review (dispatched to reviewer)
38
+ * - `loreli:changes-requested` → needs revision (dispatched to planner)
39
+ * - `loreli:blocked` → parked until dependency resolves
40
+ * - `loreli:approved` → ready for promotion (creates issue, closes discussion)
41
+ *
42
+ * @extends Workflow
43
+ */
44
+ export class PlannerWorkflow extends Workflow {
45
+ /** @type {string} Agent role this workflow manages. */
46
+ static role = 'planner';
47
+
48
+ /** @type {string} Mustache template path for planner prompts. */
49
+ static template = join(__dirname, '..', 'prompts', 'planner.md');
50
+
51
+ /**
52
+ * Discussion category ID for the current planning session.
53
+ * Set by plan() and used across tick cycles.
54
+ *
55
+ * @type {string|null}
56
+ */
57
+ categoryId = null;
58
+
59
+ /**
60
+ * Set of discussion numbers currently in-flight for review.
61
+ * Prevents the reactor from re-dispatching the same discussion
62
+ * every tick while a reviewer is still working on it.
63
+ *
64
+ * @type {Set<number>}
65
+ */
66
+ _inflight = new Set();
67
+
68
+ /**
69
+ * Discussion number the reviewer is locked to for the current
70
+ * review/revision cycle. Keeps the reviewer focused on one
71
+ * discussion through its full review cycle (review → revision →
72
+ * re-review → approved) before moving to the next.
73
+ *
74
+ * @type {number|null}
75
+ */
76
+ _reviewing = null;
77
+
78
+ /**
79
+ * Name of the reviewer agent assigned to `_reviewing`. Used to
80
+ * detect when the committed reviewer has been killed or gone dormant,
81
+ * which means the in-flight entry is stale and should be cleared.
82
+ *
83
+ * @type {string|null}
84
+ */
85
+ _reviewerName = null;
86
+
87
+ /**
88
+ * Set of discussion numbers currently in-flight for revision.
89
+ * Prevents the reactor from re-dispatching revise prompts every
90
+ * tick while the planner is still processing a revision. Keeps
91
+ * the planner focused on one discussion through its full revision
92
+ * cycle before moving to the next.
93
+ *
94
+ * Entries self-clear when the discussion label changes (the planner
95
+ * calls `plan revise` which removes `loreli:changes-requested`).
96
+ *
97
+ * @type {Set<number>}
98
+ */
99
+ _revising = new Set();
100
+
101
+ /**
102
+ * Name of the reviewer agent dispatched for discussion review.
103
+ * Tracked so promote() only kills this agent — not PR reviewers
104
+ * managed by ReviewWorkflow sharing the same role.
105
+ *
106
+ * @type {string|null}
107
+ */
108
+ _discussionReviewer = null;
109
+
110
+ /**
111
+ * Revision round counter per discussion. Incremented each time
112
+ * revise() processes a non-blocked `loreli:changes-requested` discussion.
113
+ * At maxRounds, a tiebreaker revision is dispatched. Beyond maxRounds,
114
+ * HITL escalation blocks until a human intervenes.
115
+ *
116
+ * @type {Map<number, number>}
117
+ */
118
+ _revisionRounds = new Map();
119
+
120
+ /**
121
+ * Discussion numbers whose reviewer died during revision. Persists
122
+ * across ticks so the claim check can be skipped when the discussion
123
+ * finally exits the `changes-requested` state. Cleared after a
124
+ * successful re-dispatch.
125
+ *
126
+ * @type {Set<number>}
127
+ */
128
+ _cleared = new Set();
129
+
130
+ /**
131
+ * Blocked discussions awaiting dependency resolution.
132
+ * Keys are discussion numbers, values contain the discussion node ID
133
+ * and the open issue numbers that are blocking progress.
134
+ *
135
+ * @type {Map<number, { id: string, blockers: number[] }>}
136
+ */
137
+ _blocked = new Map();
138
+
139
+ /**
140
+ * Repository node ID needed for createDiscussion mutation.
141
+ * Stored alongside categoryId during plan().
142
+ *
143
+ * @type {string|null}
144
+ */
145
+ repositoryId = null;
146
+
147
+ /**
148
+ * Planning objective text. Stored by plan() so the link() reactor
149
+ * handler can use it as the parent tracking issue title.
150
+ *
151
+ * @type {string|null}
152
+ */
153
+ objective = null;
154
+
155
+ /**
156
+ * Names of planner agents that have already received the objective.
157
+ * Used by recover() to avoid re-dispatching to planners that were
158
+ * already sent the prompt (whether they completed it or not).
159
+ *
160
+ * @type {Set<string>}
161
+ */
162
+ _dispatched = new Set();
163
+
164
+ /**
165
+ * Report planner demand: how many open discussions need a planner
166
+ * agent. Planner demand is typically low — one planner handles the
167
+ * entire discussion lifecycle. Uses hydrated state.
168
+ *
169
+ * @param {string} repo - Repository in "owner/name" format.
170
+ * @returns {Promise<{workload: number, supply: number, deficit: number}>}
171
+ */
172
+ async demand(repo) {
173
+ if (!this.categoryId) return { workload: 0, supply: 0, deficit: 0 };
174
+
175
+ const all = await this.hub.discussions(repo, this.categoryId);
176
+ const open = all.filter(function isOpen(d) { return !d.closed; });
177
+
178
+ // Objective dispatched but planner died before creating any
179
+ // discussion → treat as pending workload so scale() spawns
180
+ // a replacement planner.
181
+ const pending = this.objective && all.length === 0;
182
+ const workload = open.length > 0 || pending ? 1 : 0;
183
+ const supply = this.agents().filter(function active(a) {
184
+ return a.state !== 'dormant';
185
+ }).length;
186
+
187
+ return { workload, supply, deficit: Math.max(0, workload - supply) };
188
+ }
189
+
190
+ /**
191
+ * Register reactor handlers for the orchestrator's tick loop.
192
+ *
193
+ * Tick order: hydrate → unblock → revise → review → promote → link
194
+ * Hydrate runs first to recover distributed state from GitHub.
195
+ * unblock runs next so freshly unblocked discussions can enter
196
+ * review on the same tick. revise runs before review so a just-revised
197
+ * discussion can be reviewed immediately. link runs last to create
198
+ * parent tracking issues and wire sub-issue relationships after
199
+ * promote has created the child issues.
200
+ *
201
+ * @returns {Record<string, function>} Handler map.
202
+ */
203
+ reactor() {
204
+ const self = this;
205
+ return {
206
+ async 'planner-hydrate'(repo) { await self.hydrate(repo); },
207
+ async 'planner-recover'(repo) { await self.recover(repo); },
208
+ async unblock(repo) { await self.unblock(repo); },
209
+ async revise(repo) { await self.revise(repo); },
210
+ async review(repo) { await self.review(repo); },
211
+ async promote(repo) { await self.promote(repo); },
212
+ async link(repo) { await self.link(repo); },
213
+ async 'planner-reap'(repo) { await self.reap(repo); }
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Rehydrate in-memory planner state from GitHub artifacts.
219
+ *
220
+ * Called at the start of each reactor tick so that discussions
221
+ * tracked by other participants (or lost to a process restart)
222
+ * are visible to revise(), review(), and promote().
223
+ *
224
+ * Derives state from:
225
+ * - `loreli:blocked` label → blocked discussions
226
+ * - `review-claim` marker comments → discussions in-flight for review
227
+ * - `loreli:changes-requested` label → discussions needing revision
228
+ * - Comment history → revision round counts
229
+ *
230
+ * @param {string} repo - Repository in "owner/name" format.
231
+ * @returns {Promise<void>}
232
+ */
233
+ async hydrate(repo) {
234
+ if (!this.hub || !this.categoryId) return;
235
+
236
+ const all = await this.hub.discussions(repo, this.categoryId);
237
+
238
+ for (const d of all) {
239
+ if (d.closed) continue;
240
+
241
+ // Blocked: derive from loreli:blocked label
242
+ if (d.labels.includes('loreli:blocked') && !this._blocked.has(d.number)) {
243
+ const full = await this.hub.discussion(repo, d.number);
244
+ const blockComment = full.comments?.find(function isBlock(c) {
245
+ return c.body.includes('Blocked by open items');
246
+ });
247
+ const blockers = [];
248
+ if (blockComment) {
249
+ for (const m of blockComment.body.matchAll(/#(\d+)/g)) {
250
+ blockers.push(Number(m[1]));
251
+ }
252
+ }
253
+ this._blocked.set(d.number, { id: d.id, blockers });
254
+ continue;
255
+ }
256
+
257
+ // Already tracked locally
258
+ if (this._inflight.has(d.number)) continue;
259
+
260
+ // In-flight for review: has review-claim marker, no terminal label.
261
+ // Skip the committed discussion — review() handles re-dispatch
262
+ // via its commitment check, and re-adding it here after revise()
263
+ // clears it would create a deadlock (review-claim marker persists
264
+ // across revision cycles).
265
+ if (d.number === this._reviewing) continue;
266
+
267
+ if (!d.labels.includes('loreli:approved') &&
268
+ !d.labels.includes('loreli:changes-requested') &&
269
+ !d.labels.includes('loreli:blocked')) {
270
+ const full = await this.hub.discussion(repo, d.number);
271
+ const claimComment = full.comments?.find(function isClaim(c) {
272
+ return has(c.body, 'review-claim');
273
+ });
274
+ if (claimComment) {
275
+ this._inflight.add(d.number);
276
+ const parsed = parse(claimComment.body, 'review-claim');
277
+ if (parsed?.agent) this._reviewerName = parsed.agent;
278
+ }
279
+ }
280
+
281
+ // Revision rounds: count loreli:changes-requested → revision cycles.
282
+ // Each cycle is a reviewer requesting changes + planner revising.
283
+ // The count is derived from the number of review-claim markers
284
+ // (each review cycle starts with a new claim or re-claim).
285
+ if (d.labels.includes('loreli:changes-requested') &&
286
+ !this._revisionRounds.has(d.number)) {
287
+ const full = await this.hub.discussion(repo, d.number);
288
+ const claimCount = full.comments?.filter(function isClaim(c) {
289
+ return has(c.body, 'review-claim');
290
+ }).length ?? 0;
291
+ if (claimCount > 0) this._revisionRounds.set(d.number, claimCount);
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Dispatch the planning workflow to planner agents.
298
+ *
299
+ * Finds the "Loreli" discussion category, renders the planner
300
+ * prompt with the given objective, and sends it to every planner
301
+ * agent. Returns the dispatched agent names and category ID.
302
+ *
303
+ * @param {string} repo - Repository in "owner/name" format.
304
+ * @param {string} objective - What should be planned.
305
+ * @returns {Promise<{planners: string[], categoryId: string}>}
306
+ * @throws {Error} When no planner agents are available.
307
+ */
308
+ async plan(repo, objective) {
309
+ log.info(`planning: ${repo} — objective: ${objective}`);
310
+ const planners = this.agents();
311
+
312
+ if (!planners.length) throw new Error('No planner agents available');
313
+
314
+ this.objective = objective;
315
+
316
+ const cat = await this.hub.category(repo, 'Loreli');
317
+ this.categoryId = cat.id;
318
+ this.repositoryId = cat.repositoryId;
319
+ const dispatched = [];
320
+
321
+ for (const agent of planners) {
322
+ await this.dispatch(agent, {
323
+ name: agent.identity.name,
324
+ repo,
325
+ faction: agent.identity.faction,
326
+ provider: agent.identity.provider,
327
+ model: agent.identity.model,
328
+ objective,
329
+ labels: agent.identity.labels?.('planner'),
330
+ plan: true,
331
+ categoryId: cat.id,
332
+ repositoryId: cat.repositoryId
333
+ });
334
+
335
+ this.orchestrator.activity(agent.identity.name);
336
+ this._dispatched.add(agent.identity.name);
337
+ dispatched.push(agent.identity.name);
338
+ }
339
+
340
+ log.info(`planning dispatched to ${dispatched.length} planners`);
341
+ return { planners: dispatched, categoryId: cat.id };
342
+ }
343
+
344
+ /**
345
+ * Re-dispatch the planning objective to newly spawned planners.
346
+ *
347
+ * When a planner dies before creating any discussions (e.g. budget
348
+ * exhaustion), scale() spawns a replacement. This handler detects
349
+ * the new planner and sends it the original objective so planning
350
+ * can resume without external intervention.
351
+ *
352
+ * Runs on every reactor tick. No-ops when:
353
+ * - No objective is pending
354
+ * - Discussions already exist (original planner succeeded)
355
+ * - No new (undispatched, non-dormant) planners are available
356
+ *
357
+ * @param {string} repo - Repository in "owner/name" format.
358
+ * @returns {Promise<void>}
359
+ */
360
+ async recover(repo) {
361
+ if (!this.objective || !this.categoryId) return;
362
+
363
+ const all = await this.hub.discussions(repo, this.categoryId);
364
+ if (all.length > 0) return;
365
+
366
+ const planners = this.agents().filter(function eligible(a) {
367
+ return a.state !== 'dormant';
368
+ });
369
+
370
+ for (const agent of planners) {
371
+ if (this._dispatched.has(agent.identity.name)) continue;
372
+
373
+ log.info(`recover: re-dispatching objective to ${agent.identity.name}`);
374
+ await this.dispatch(agent, {
375
+ name: agent.identity.name,
376
+ repo,
377
+ faction: agent.identity.faction,
378
+ provider: agent.identity.provider,
379
+ model: agent.identity.model,
380
+ objective: this.objective,
381
+ labels: agent.identity.labels?.('planner'),
382
+ plan: true,
383
+ categoryId: this.categoryId,
384
+ repositoryId: this.repositoryId
385
+ });
386
+
387
+ this.orchestrator.activity(agent.identity.name);
388
+ this._dispatched.add(agent.identity.name);
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Check blocked discussions for resolved dependencies.
394
+ *
395
+ * Runs FIRST in the tick pipeline (before revise). For each discussion
396
+ * in the `_blocked` map, verifies whether all blocking issues/PRs have
397
+ * been closed or merged. When resolved, removes both `loreli:blocked`
398
+ * and `loreli:changes-requested` labels so the discussion re-enters
399
+ * the review pipeline fresh.
400
+ *
401
+ * @param {string} repo - Repository in "owner/name" format.
402
+ * @returns {Promise<void>}
403
+ */
404
+ async unblock(repo) {
405
+ if (!this._blocked.size) return;
406
+
407
+ for (const [num, entry] of this._blocked) {
408
+ const open = [];
409
+ for (const ref of entry.blockers) {
410
+ try {
411
+ const item = await this.hub.issue(repo, ref);
412
+ if (item.state === 'open') open.push(ref);
413
+ } catch {
414
+ // Reference may not exist as an issue — skip
415
+ }
416
+ }
417
+
418
+ if (!open.length) {
419
+ try {
420
+ await this.hub.removeDiscussionLabels(repo, entry.id,
421
+ ['loreli:blocked', 'loreli:changes-requested']);
422
+ } catch (err) { log.warn(`unblock: label removal failed for #${num}: ${err.message}`); }
423
+
424
+ // discussionComment requires scoping — use planner identity or client fallback
425
+ const signer = this.agents()[0]?.identity ?? this.orchestrator?.clientIdentity;
426
+ if (signer) {
427
+ try {
428
+ const scoped = this.hub.as(signer, 'planner');
429
+ await scoped.discussionComment(entry.id,
430
+ `Blockers resolved (${entry.blockers.map(function hash(n) { return '#' + n; }).join(', ')}). Restarting review.`);
431
+ } catch (err) { log.warn(`unblock: comment failed for #${num}: ${err.message}`); }
432
+ }
433
+
434
+ this._blocked.delete(num);
435
+ log.info(`unblock: discussion #${num} unblocked — blockers resolved`);
436
+ }
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Handle discussions with `loreli:changes-requested` label.
442
+ *
443
+ * Runs SECOND in the tick pipeline (after unblock). For each discussion
444
+ * with changes requested:
445
+ *
446
+ * 1. Blocker detection: parse comments for issue refs, classify via LLM,
447
+ * park if open blockers found
448
+ * 2. Round tracking: increment `_revisionRounds`
449
+ * 3. HITL escalation: if rounds > maxRounds, escalate to human (no auto-approve)
450
+ * 4. Tiebreaker: if rounds === maxRounds, dispatch with tiebreaker prompt
451
+ * 5. Normal: dispatch planner for standard revision
452
+ *
453
+ * @param {string} repo - Repository in "owner/name" format.
454
+ * @returns {Promise<Array<{number: number, planner: string}>>}
455
+ */
456
+ async revise(repo) {
457
+ if (!this.categoryId) return [];
458
+
459
+ const all = await this.hub.discussions(repo, this.categoryId);
460
+ const self = this;
461
+
462
+ // Self-clear _revising for discussions whose label changed (planner
463
+ // successfully revised and the `loreli:changes-requested` was removed).
464
+ const currentCR = new Set(
465
+ all.filter(function hasCR(d) { return d.labels.includes('loreli:changes-requested'); })
466
+ .map(function num(d) { return d.number; })
467
+ );
468
+ for (const num of this._revising) {
469
+ if (!currentCR.has(num)) this._revising.delete(num);
470
+ }
471
+
472
+ const needsRevision = all.filter(function changesRequested(d) {
473
+ return !d.closed &&
474
+ d.labels.includes('loreli:changes-requested') &&
475
+ !d.labels.includes('loreli:rejected') &&
476
+ !self._blocked.has(d.number) &&
477
+ !self._revising.has(d.number);
478
+ });
479
+
480
+ // Clear in-flight tracking for discussions that now have labels —
481
+ // the reviewer completed their work.
482
+ for (const d of needsRevision) this._inflight.delete(d.number);
483
+
484
+ if (!needsRevision.length) return [];
485
+
486
+ const planner = this.agents()[0];
487
+ if (!planner) {
488
+ log.info('revise: no planner agent available — skipping');
489
+ return [];
490
+ }
491
+
492
+ const maxRounds = this.orchestrator.cfg?.get?.('watch.maxRounds') ?? 7;
493
+ const revised = [];
494
+
495
+ for (const disc of needsRevision) {
496
+ const full = await this.hub.discussion(repo, disc.number);
497
+
498
+ // ── Blocker detection via heuristic classification ──
499
+ const refs = extractRefs(full.comments);
500
+ if (refs.length) {
501
+ const classified = classifyRefs(full.comments, refs);
502
+
503
+ if (classified.blockers.length) {
504
+ // Verify classified blockers are actually open issues
505
+ const open = [];
506
+ for (const ref of classified.blockers) {
507
+ try {
508
+ const item = await this.hub.issue(repo, ref);
509
+ if (item.state === 'open') open.push(ref);
510
+ } catch {
511
+ // Not an issue — skip
512
+ }
513
+ }
514
+
515
+ if (open.length) {
516
+ try {
517
+ await this.hub.applyDiscussionLabels(repo, disc.id, ['loreli:blocked']);
518
+ } catch (err) { log.warn(`revise: label failed for #${disc.number}: ${err.message}`); }
519
+
520
+ try {
521
+ await this.hub.discussionComment(disc.id,
522
+ `Blocked by open items: ${open.map(function hash(n) { return '#' + n; }).join(', ')}. Will resume review when resolved.`);
523
+ } catch (err) { log.warn(`revise: comment failed for #${disc.number}: ${err.message}`); }
524
+
525
+ this._blocked.set(disc.number, { id: disc.id, blockers: open });
526
+ log.info(`revise: discussion #${disc.number} blocked by ${open.join(', ')}`);
527
+ continue;
528
+ }
529
+ }
530
+ }
531
+
532
+ // ── Round tracking and tiebreaker/safety-net ──
533
+ const rounds = (this._revisionRounds.get(disc.number) ?? 0) + 1;
534
+ this._revisionRounds.set(disc.number, rounds);
535
+
536
+ if (rounds > maxRounds) {
537
+ // HITL escalation: do NOT auto-approve. Tag humans and block.
538
+ try {
539
+ await this.hub.applyDiscussionLabels(repo, disc.id, ['loreli:needs-attention']);
540
+
541
+ const reviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
542
+ const mentions = reviewers.length
543
+ ? reviewers.map(function mention(r) { return `@${r}`; }).join(', ')
544
+ : '';
545
+
546
+ const signer = planner?.identity ?? this.orchestrator?.clientIdentity;
547
+ if (signer) {
548
+ const scoped = this.hub.as(signer, planner?.role ?? 'planner');
549
+ const msg = mentions
550
+ ? `**Needs human attention** — ${mentions}\n\n` +
551
+ `This discussion exceeded ${rounds} revision rounds without convergence. ` +
552
+ 'A human must review and decide how to proceed.'
553
+ : `**Needs human attention**\n\n` +
554
+ `This discussion exceeded ${rounds} revision rounds without convergence. ` +
555
+ 'A human must review and decide how to proceed.';
556
+ await scoped.discussionComment(disc.id, `${mark('hitl')}\n${msg}`);
557
+ }
558
+ } catch (err) { log.warn(`revise: HITL escalation failed for #${disc.number}: ${err.message}`); }
559
+
560
+ this._inflight.delete(disc.number);
561
+ log.warn(`revise: HITL escalation for discussion #${disc.number} after ${rounds} rounds`);
562
+ continue;
563
+ }
564
+
565
+ const isTiebreaker = rounds === maxRounds;
566
+
567
+ // Write task context for the agent's MCP tools
568
+ await this.saveTask(planner.identity.name, {
569
+ type: 'revise_discussion',
570
+ discussion: full.number,
571
+ discussionId: full.id
572
+ });
573
+
574
+ await this.dispatch(planner, {
575
+ name: planner.identity.name,
576
+ repo,
577
+ faction: planner.identity.faction,
578
+ provider: planner.identity.provider,
579
+ model: planner.identity.model,
580
+ labels: planner.identity.labels?.('planner'),
581
+ revise: {
582
+ discussionId: full.id,
583
+ number: full.number,
584
+ title: full.title,
585
+ body: full.body,
586
+ comments: full.comments,
587
+ tiebreaker: isTiebreaker,
588
+ rounds,
589
+ maxRounds
590
+ },
591
+ categoryId: this.categoryId,
592
+ repositoryId: this.repositoryId
593
+ });
594
+
595
+ this._revising.add(disc.number);
596
+ this.orchestrator.activity(planner.identity.name);
597
+ revised.push({ number: disc.number, planner: planner.identity.name });
598
+ log.info(`revise: dispatched ${planner.identity.name} for discussion #${disc.number}${isTiebreaker ? ' (TIEBREAKER)' : ''}`);
599
+ }
600
+
601
+ return revised;
602
+ }
603
+
604
+ /**
605
+ * Dispatch discussions without review labels to a reviewer agent.
606
+ *
607
+ * Runs THIRD in the tick pipeline (after unblock and revise). Selects
608
+ * the appropriate reviewer template based on the discussion's revision
609
+ * round count:
610
+ * - Normal: `plan-reviewer.md` for standard adversarial review
611
+ * - Tiebreaker: `tiebreaker-reviewer.md` when rounds >= maxRounds
612
+ *
613
+ * @param {string} repo - Repository in "owner/name" format.
614
+ * @returns {Promise<{reviewer: string|null, discussions: number}>}
615
+ */
616
+ async review(repo) {
617
+ if (!this.categoryId) return { reviewer: null, discussions: 0 };
618
+
619
+ // Clear stale in-flight entries when no productive reviewer exists.
620
+ // An active reviewer must be in a working state (spawned/running) —
621
+ // dormant agents finished their work, dead agents crashed, and
622
+ // stalled agents may never produce output.
623
+ const reviewers = [...this.orchestrator.agents.values()]
624
+ .filter(function isReviewer(a) { return a.role === 'reviewer'; });
625
+ const productive = reviewers.filter(function working(a) {
626
+ return a.state === 'spawned' || a.state === 'running';
627
+ });
628
+
629
+ if (!productive.length && this._inflight.size) {
630
+ for (const n of this._inflight) this._cleared.add(n);
631
+ log.info(`review: clearing ${this._inflight.size} stale in-flight entries — no productive reviewer (${reviewers.length} registered, 0 working)`);
632
+ this._inflight.clear();
633
+ this._reviewing = null;
634
+ this._reviewerName = null;
635
+ }
636
+
637
+ const all = await this.hub.discussions(repo, this.categoryId);
638
+ const self = this;
639
+
640
+ // ── Commitment check ────────────────────────────────────
641
+ // Lock the reviewer to a discussion's full review/revision cycle.
642
+ // Keeps the reviewer focused on one discussion through its full
643
+ // review cycle before moving to the next.
644
+ if (this._reviewing !== null) {
645
+ const committed = all.find(function locked(d) { return d.number === self._reviewing; });
646
+
647
+ if (!committed || committed.closed || committed.labels.includes('loreli:approved')) {
648
+ // Discussion resolved or disappeared — unlock
649
+ this._reviewing = null;
650
+ this._reviewerName = null;
651
+ } else if (committed.labels.includes('loreli:changes-requested') ||
652
+ committed.labels.includes('loreli:blocked')) {
653
+ // In revision or blocked — wait for the cycle to complete
654
+ log.debug(`review: discussion #${this._reviewing} in revision — reviewer waiting`);
655
+ return { reviewer: null, discussions: 0 };
656
+ } else if (this._inflight.has(this._reviewing)) {
657
+ // Verify the committed reviewer agent is still registered and
658
+ // productive. A reviewer can become dormant (completed other work)
659
+ // or dead (killed by stall detection) while the in-flight entry
660
+ // lingers — without this check, the system deadlocks.
661
+ const assignee = this._reviewerName
662
+ ? this.orchestrator.agents.get(this._reviewerName)
663
+ : null;
664
+ const alive = assignee && (assignee.state === 'spawned' || assignee.state === 'running' || assignee.state === 'working');
665
+ if (alive) {
666
+ log.debug(`review: reviewer ${this._reviewerName} still working on #${this._reviewing} — waiting`);
667
+ return { reviewer: null, discussions: 0 };
668
+ }
669
+ // Committed reviewer is gone or dormant — clear stale state
670
+ log.info(`review: clearing stale commitment to #${this._reviewing} — reviewer ${this._reviewerName ?? 'unknown'} no longer active`);
671
+ this._cleared.add(this._reviewing);
672
+ this._inflight.delete(this._reviewing);
673
+ this._reviewing = null;
674
+ this._reviewerName = null;
675
+ }
676
+ // else: no label, not in _inflight → revised and needs re-review
677
+ }
678
+
679
+ // Pre-check: fetch discussion details for review-claim markers
680
+ // to avoid dispatching reviewers for discussions already claimed
681
+ // by another participant. Only fetch for discussions that pass
682
+ // the basic label filter to minimize API calls.
683
+ const candidates = all.filter(function unlabeled(d) {
684
+ if (self._reviewing !== null && d.number !== self._reviewing) return false;
685
+ return !d.closed &&
686
+ !d.labels.includes('loreli:approved') &&
687
+ !d.labels.includes('loreli:changes-requested') &&
688
+ !d.labels.includes('loreli:blocked') &&
689
+ !self._inflight.has(d.number);
690
+ });
691
+
692
+ const needsReview = [];
693
+ for (const d of candidates) {
694
+ const full = await this.hub.discussion(repo, d.number);
695
+ const claimed = full.comments?.some(function isClaimed(c) { return has(c.body, 'review-claim'); });
696
+ // Re-review: when committed to this discussion, our prior claim is from us — allow re-dispatch
697
+ // Stale clear: discussions we just cleared from _inflight (reviewer died) need re-dispatch
698
+ if (!claimed || self._reviewing === d.number || self._cleared.has(d.number)) needsReview.push(d);
699
+ }
700
+
701
+ log.debug(`review: ${all.length} total, ${needsReview.length} need review, ${this._inflight.size} in-flight`);
702
+
703
+ if (!needsReview.length) return { reviewer: null, discussions: 0 };
704
+
705
+ // Find the first planner to determine opposing provider
706
+ const planner = this.agents()[0];
707
+ let providers = [];
708
+ let mode = 'none';
709
+
710
+ try {
711
+ await this.orchestrator.backendRegistry?.discover?.();
712
+ providers = this.orchestrator.backendRegistry?.providers?.() ?? [];
713
+ mode = capability(providers).mode;
714
+ } catch { /* non-fatal: enlist fallback uses planner provider */ }
715
+
716
+ // Cross-provider pairing: find a reviewer from the opposing provider.
717
+ // Reuses the `reviewers` list computed above for stale in-flight detection.
718
+ let reviewer;
719
+ if (planner) {
720
+ reviewer = this.pair(planner.identity, reviewers);
721
+
722
+ // Prefer cross-provider review in dual-side environments.
723
+ // In single-side environments, fall back to any available provider.
724
+ if (!reviewer) {
725
+ if (mode === 'dual') {
726
+ const opposing = this.orchestrator.identityRegistry.opposite(planner.identity);
727
+ reviewer = await this.enlist(opposing.provider, 'reviewer');
728
+ } else {
729
+ const fallbackProvider = providers[0] ?? planner.identity.provider;
730
+ reviewer = await this.enlist(fallbackProvider, 'reviewer');
731
+ }
732
+ }
733
+ } else {
734
+ // Planner died (budget, crash) — infer the planner's side from
735
+ // the discussion body's agent stamp to maintain adversarial pairing.
736
+ const prefetch = await this.hub.discussion(repo, needsReview[0].number);
737
+ const stamp = parse(prefetch?.body, 'agent');
738
+ if (stamp?.provider) {
739
+ reviewer = this.pair({ provider: stamp.provider }, reviewers);
740
+ }
741
+ if (!reviewer) reviewer = reviewers[0] ?? null;
742
+ }
743
+
744
+ if (!reviewer) {
745
+ log.info('review: no reviewer available — skipping');
746
+ return { reviewer: null, discussions: 0 };
747
+ }
748
+
749
+ // Dispatch ONE discussion per tick. Sequential dispatch ensures each
750
+ // discussion gets full attention. Remaining discussions are picked
751
+ // up on the next tick when this method runs again.
752
+ const disc = needsReview[0];
753
+
754
+ const full = await this.hub.discussion(repo, disc.number);
755
+
756
+ // Optimistic claim: post a review-claim marker on the discussion,
757
+ // then verify this agent was first. Prevents two reactors from
758
+ // both dispatching reviewers for the same discussion.
759
+ // Skip claim when re-dispatching for a cleared discussion — the prior
760
+ // claim was from a dead reviewer; we are forcibly re-assigning.
761
+ const claimIdentity = reviewer?.identity ?? planner?.identity ?? this.orchestrator.clientIdentity;
762
+ if (claimIdentity && !this._cleared.has(disc.number)) {
763
+ const visible = reviewer?.identity?.claim?.() ?? claimIdentity.claim?.() ?? `Claimed by **${claimIdentity.name}**`;
764
+ const won = await this.claimFirstDiscussion(repo, disc.number, full.id, 'review-claim', claimIdentity, 'reviewer', visible);
765
+ if (!won) {
766
+ log.info(`review: lost review-claim race for discussion #${disc.number} — skipping`);
767
+ return { reviewer: null, discussions: 0 };
768
+ }
769
+ }
770
+
771
+ this._inflight.add(disc.number);
772
+ this._cleared.delete(disc.number);
773
+ this._reviewing = disc.number;
774
+ this._reviewerName = reviewer.identity.name;
775
+
776
+ await this.saveTask(reviewer.identity.name, {
777
+ type: 'review_discussion',
778
+ discussion: full.number,
779
+ discussionId: full.id
780
+ });
781
+
782
+ // Select reviewer template based on revision history
783
+ const maxRounds = this.orchestrator.cfg?.get?.('watch.maxRounds') ?? 7;
784
+ const rounds = this._revisionRounds.get(disc.number) ?? 0;
785
+ const template = rounds >= maxRounds ? TIEBREAKER_TEMPLATE : PLAN_REVIEWER_TEMPLATE;
786
+
787
+ const prompt = await this.renderFrom(template, {
788
+ name: reviewer.identity.name,
789
+ repo,
790
+ faction: reviewer.identity.faction,
791
+ provider: reviewer.identity.provider,
792
+ model: reviewer.identity.model,
793
+ labels: reviewer.identity.labels?.('reviewer'),
794
+ planReview: {
795
+ discussionId: full.id,
796
+ number: full.number,
797
+ title: full.title,
798
+ body: full.body,
799
+ comments: full.comments,
800
+ rounds,
801
+ maxRounds
802
+ }
803
+ }, { role: 'reviewer' });
804
+
805
+ await reviewer.send(prompt);
806
+ this.orchestrator.activity(reviewer.identity.name);
807
+ this._discussionReviewer = reviewer.identity.name;
808
+
809
+ const reviewMode = rounds >= maxRounds ? 'TIEBREAKER' : 'normal';
810
+ log.info(`review: dispatched ${reviewer.identity.name} for 1 discussion (#${disc.number}, ${reviewMode})`);
811
+ return { reviewer: reviewer.identity.name, discussions: 1 };
812
+ }
813
+
814
+ /**
815
+ * Promote approved discussions to real issues.
816
+ *
817
+ * Runs LAST in the tick pipeline. For each discussion with the
818
+ * `loreli:approved` label, creates a real issue, posts a closing
819
+ * comment linking to the issue, and closes/locks the discussion.
820
+ *
821
+ * @param {string} repo - Repository in "owner/name" format.
822
+ * @returns {Promise<Array<{number: number, title: string}>>}
823
+ */
824
+ async promote(repo) {
825
+ if (!this.categoryId) return [];
826
+
827
+ const all = await this.hub.discussions(repo, this.categoryId);
828
+ const approved = all.filter(function isApproved(d) {
829
+ return !d.closed && d.labels.includes('loreli:approved');
830
+ });
831
+
832
+ // Clear tracking state for approved discussions
833
+ for (const d of approved) {
834
+ this._inflight.delete(d.number);
835
+ this._revisionRounds.delete(d.number);
836
+ this._blocked.delete(d.number);
837
+ this._revising.delete(d.number);
838
+ }
839
+
840
+ if (!approved.length) return [];
841
+
842
+ // Get a signer for the promotion — prefer planner, fall back to any agent
843
+ const planner = this.agents()[0];
844
+ const signer = planner?.identity ?? this.orchestrator.clientIdentity;
845
+ if (!signer) {
846
+ log.warn('promote: no identity available for signing — skipping');
847
+ return [];
848
+ }
849
+
850
+ const scoped = this.hub.as(signer, planner?.role ?? 'planner');
851
+ const promoted = [];
852
+
853
+ for (const disc of approved) {
854
+ try {
855
+ // Strip the discussion's existing signature so open() applies
856
+ // exactly one fresh stamp — prevents double-signature on promoted issues.
857
+ const issue = await scoped.open(repo, {
858
+ title: disc.title,
859
+ body: Identity.strip(disc.body)
860
+ });
861
+
862
+ // Post themed closing comment linking to the new issue.
863
+ // GitHub auto-links #N references, so only the number is needed.
864
+ const visible = signer.promote?.(issue.number)
865
+ ?? `Promoted to issue #${issue.number}`;
866
+ await scoped.discussionComment(disc.id, visible);
867
+
868
+ // Close and lock the discussion
869
+ await this.hub.closeDiscussion(disc.id);
870
+
871
+ promoted.push({ number: issue.number, title: issue.title });
872
+ log.info(`promote: discussion #${disc.number} → issue #${issue.number}`);
873
+ } catch (err) {
874
+ log.warn(`promote: failed for discussion #${disc.number}: ${err.message}`);
875
+ }
876
+ }
877
+
878
+ // Signal link() to defer parent creation by one tick. Creating
879
+ // the parent on the same tick as promotion causes a race: the
880
+ // parent appears in hub.issues() queries alongside the children,
881
+ // inflating issue counts for consumers that poll open issues.
882
+ if (promoted.length) this._justPromoted = true;
883
+
884
+ // Only shut down agents when ALL discussions are resolved (approved+promoted
885
+ // or closed). If any open discussion still needs revision or review, the
886
+ // planner and reviewer must stay alive to handle it.
887
+ const remaining = all.filter(function unresolved(d) {
888
+ return !d.closed && !d.labels.includes('loreli:approved');
889
+ });
890
+
891
+ if (remaining.length === 0) {
892
+ // Clear dispatch state — planning phase is complete
893
+ this._reviewing = null;
894
+ this._reviewerName = null;
895
+ this._inflight.clear();
896
+ this._revising.clear();
897
+
898
+ const planners = this.agents();
899
+ for (const p of planners) {
900
+ try {
901
+ await this.orchestrator.kill(p.identity.name);
902
+ log.info(`promote: killed planner ${p.identity.name} — all discussions resolved`);
903
+ } catch (err) {
904
+ log.warn(`promote: failed to kill ${p.identity.name}: ${err.message}`);
905
+ }
906
+ }
907
+
908
+ // Kill only the discussion reviewer — PR reviewers share the same
909
+ // role but are managed by ReviewWorkflow and must not be disrupted.
910
+ if (this._discussionReviewer) {
911
+ try {
912
+ await this.orchestrator.kill(this._discussionReviewer);
913
+ log.info(`promote: killed reviewer ${this._discussionReviewer} — all discussions resolved`);
914
+ } catch (err) {
915
+ log.warn(`promote: failed to kill ${this._discussionReviewer}: ${err.message}`);
916
+ }
917
+ this._discussionReviewer = null;
918
+ }
919
+ } else {
920
+ log.info(`promote: ${remaining.length} discussion(s) still open — planners stay alive`);
921
+ }
922
+
923
+ return promoted;
924
+ }
925
+
926
+ /**
927
+ * Link promoted child issues to a parent tracking issue using GitHub
928
+ * sub-issues. Runs on every reactor tick after promote().
929
+ *
930
+ * All state is derived from GitHub (labels + markers), so this handler
931
+ * is fully idempotent and distributed-safe across multiple machines.
932
+ *
933
+ * Flow:
934
+ * 1. Scan for existing parent issue (loreli:parent label + marker)
935
+ * 2. Deduplicate racing parents (keep lowest issue number)
936
+ * 3. Discover unlinked child issues (loreli-labeled, agent-stamped, not parent)
937
+ * 4. Create parent if >= 2 children and no parent exists
938
+ * 5. Link unlinked children via sub-issues API
939
+ *
940
+ * @param {string} repo - Repository in "owner/name" format.
941
+ * @returns {Promise<void>}
942
+ */
943
+ async link(repo) {
944
+ // Defer parent creation by one tick after any promotion. This
945
+ // prevents the parent from appearing in the same hub.issues()
946
+ // result set as the newly promoted children.
947
+ if (this._justPromoted) {
948
+ this._justPromoted = false;
949
+ return;
950
+ }
951
+
952
+ // Find existing parent issues via label scan
953
+ const candidates = await this.hub.issues(repo, {
954
+ state: 'open', labels: ['loreli:parent']
955
+ });
956
+
957
+ // Confirm via marker — guards against accidental manual labeling
958
+ const parents = candidates.filter(function confirmed(i) {
959
+ return has(i.body, 'parent');
960
+ });
961
+
962
+ let parent = null;
963
+
964
+ // Deduplicate racing parents: keep lowest number, close others
965
+ if (parents.length > 1) {
966
+ parents.sort(function byNumber(a, b) { return a.number - b.number; });
967
+ parent = parents[0];
968
+
969
+ for (let i = 1; i < parents.length; i++) {
970
+ try {
971
+ const signer = this.agents()[0]?.identity ?? this.orchestrator?.clientIdentity;
972
+ if (signer) {
973
+ const scoped = this.hub.as(signer, 'planner');
974
+ await scoped.comment(repo, parents[i].number,
975
+ `Duplicate parent detected. Keeping #${parent.number} as canonical parent.`);
976
+ }
977
+ // Close via direct Octokit — hub doesn't expose issue update yet
978
+ const [owner, name] = this.hub.parse(repo);
979
+ await this.hub.client.issues.update({
980
+ owner, repo: name, issue_number: parents[i].number, state: 'closed'
981
+ });
982
+ log.info(`link: closed duplicate parent #${parents[i].number} — keeping #${parent.number}`);
983
+ } catch (err) {
984
+ log.warn(`link: failed to close duplicate parent #${parents[i].number}: ${err.message}`);
985
+ }
986
+ }
987
+ } else if (parents.length === 1) {
988
+ parent = parents[0];
989
+ }
990
+
991
+ // Discover child issues: agent-stamped loreli issues that are NOT the parent.
992
+ // The agent marker guards against human-created issues being linked as
993
+ // sub-issues — only issues promoted from plans carry the stamp.
994
+ const all = await this.hub.issues(repo, { state: 'open', labels: ['loreli'] });
995
+ const children = all.filter(function isChild(i) {
996
+ return has(i.body, 'agent') && !has(i.body, 'parent') && i.number !== parent?.number;
997
+ });
998
+
999
+ if (children.length < 2 && !parent) {
1000
+ // Not enough children for a parent hierarchy yet
1001
+ if (children.length > 0) {
1002
+ log.debug(`link: ${children.length} child issue(s) — waiting for more before creating parent`);
1003
+ }
1004
+ return;
1005
+ }
1006
+
1007
+ // Create parent if needed
1008
+ if (!parent && children.length >= 2) {
1009
+ const signer = this.agents()[0]?.identity ?? this.orchestrator?.clientIdentity;
1010
+ if (!signer) {
1011
+ log.warn('link: no identity available for creating parent issue');
1012
+ return;
1013
+ }
1014
+
1015
+ const objective = this.objective ?? 'Planning objective';
1016
+ const title = objective.split('\n')[0].slice(0, 128);
1017
+ const taskList = children
1018
+ .map(function line(c) { return `- [ ] #${c.number} — ${c.title}`; })
1019
+ .join('\n');
1020
+
1021
+ const body = [
1022
+ '## Objective\n',
1023
+ objective,
1024
+ '\n### Work Items\n',
1025
+ taskList,
1026
+ '\n',
1027
+ mark('parent', { objective })
1028
+ ].join('\n');
1029
+
1030
+ const scoped = this.hub.as(signer, 'planner');
1031
+ const created = await scoped.open(repo, {
1032
+ title,
1033
+ body,
1034
+ labels: ['loreli:parent']
1035
+ });
1036
+
1037
+ parent = created;
1038
+ log.info(`link: created parent issue #${parent.number} for objective: ${objective}`);
1039
+ }
1040
+
1041
+ if (!parent) return;
1042
+
1043
+ // Get already-linked sub-issues for idempotency
1044
+ let linked;
1045
+ try {
1046
+ linked = await this.hub.subs(repo, parent.number);
1047
+ } catch {
1048
+ // Sub-issues API may not be available — log and bail
1049
+ log.warn('link: sub-issues API unavailable — skipping linking');
1050
+ return;
1051
+ }
1052
+
1053
+ const linkedIds = new Set(linked.map(function id(s) { return s.id; }));
1054
+
1055
+ // Link unlinked children
1056
+ for (const child of children) {
1057
+ if (linkedIds.has(child.id)) continue;
1058
+
1059
+ try {
1060
+ await this.hub.sub(repo, parent.number, child.id);
1061
+ log.info(`link: linked #${child.number} as sub-issue of #${parent.number}`);
1062
+ } catch (err) {
1063
+ log.warn(`link: failed to link #${child.number} to #${parent.number}: ${err.message}`);
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ /**
1069
+ * Scan for stale HITL discussions that have timed out without human response.
1070
+ * Applies loreli:stale label and re-pings configured reviewers.
1071
+ * Idempotent — discussions already labeled loreli:stale are skipped.
1072
+ *
1073
+ * @param {string} repo - Repository in "owner/name" format.
1074
+ * @returns {Promise<void>}
1075
+ */
1076
+ async reap(repo) {
1077
+ const timeout = this.orchestrator.cfg?.get?.('hitl.timeout');
1078
+ if (timeout == null) return;
1079
+
1080
+ if (!this.categoryId) return;
1081
+
1082
+ const all = await this.hub.discussions(repo, this.categoryId);
1083
+ const needsAttention = all.filter(function hasNeedsAttention(d) {
1084
+ return !d.closed &&
1085
+ d.labels.includes('loreli:needs-attention') &&
1086
+ !d.labels.includes('loreli:stale');
1087
+ });
1088
+
1089
+ if (!needsAttention.length) return;
1090
+
1091
+ const now = Date.now();
1092
+ const reviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
1093
+
1094
+ for (const disc of needsAttention) {
1095
+ const full = await this.hub.discussion(repo, disc.number);
1096
+ const hitlComment = full.comments?.find(function isHitl(c) { return has(c.body, 'hitl'); });
1097
+ if (!hitlComment) continue;
1098
+
1099
+ const elapsed = now - new Date(hitlComment.created ?? hitlComment.created_at).getTime();
1100
+ if (elapsed < timeout) continue;
1101
+
1102
+ const mentions = reviewers.length
1103
+ ? reviewers.map(function mention(r) { return `@${r}`; }).join(', ')
1104
+ : '';
1105
+
1106
+ const signer = this.agents()[0]?.identity ?? this.orchestrator.clientIdentity;
1107
+ if (signer) {
1108
+ try {
1109
+ const scoped = this.hub.as(signer, 'planner');
1110
+ const msg = mentions
1111
+ ? `**HITL reminder** — ${mentions}\n\nThis discussion has been awaiting human attention for over ${Math.round(elapsed / 3600000)} hours.`
1112
+ : `**HITL reminder**\n\nThis discussion has been awaiting human attention for over ${Math.round(elapsed / 3600000)} hours.`;
1113
+ await scoped.discussionComment(disc.id, `${mark('hitl')}\n${msg}`);
1114
+ } catch (err) {
1115
+ log.warn(`reap: failed to post reminder on discussion #${disc.number}: ${err.message}`);
1116
+ }
1117
+ }
1118
+
1119
+ try {
1120
+ await this.hub.applyDiscussionLabels(repo, disc.id, ['loreli:stale']);
1121
+ } catch (err) {
1122
+ log.warn(`reap: failed to label discussion #${disc.number} as stale: ${err.message}`);
1123
+ }
1124
+
1125
+ log.warn(`reap: discussion #${disc.number} marked stale — HITL timeout exceeded (${Math.round(elapsed / 3600000)}h)`);
1126
+ }
1127
+ }
1128
+
1129
+ /**
1130
+ * Signal a concern or feature idea to the Loreli discussion category.
1131
+ *
1132
+ * Creates a discussion for adversarial review, rather than directly
1133
+ * creating an issue. The discussion goes through the standard
1134
+ * review → revise → approve → promote pipeline.
1135
+ *
1136
+ * @param {string} repo - Repository in "owner/name" format.
1137
+ * @param {string} title - Brief title for the escalation.
1138
+ * @param {string} body - Detailed description of the concern.
1139
+ * @param {object} [fallbackIdentity] - Identity to use when no planner agents exist.
1140
+ * @returns {Promise<{discussionId: string, categoryId: string}>}
1141
+ * @throws {Error} When no identity is available for signing.
1142
+ */
1143
+ async escalate(repo, title, body, fallbackIdentity) {
1144
+ // Ensure category is resolved
1145
+ if (!this.categoryId) {
1146
+ const cat = await this.hub.category(repo, 'Loreli');
1147
+ this.categoryId = cat.id;
1148
+ this.repositoryId = cat.repositoryId;
1149
+ }
1150
+
1151
+ const planner = this.agents()[0];
1152
+ const signer = planner?.identity ?? fallbackIdentity;
1153
+ if (!signer) throw new Error('Cannot escalate without an identity');
1154
+
1155
+ const scoped = this.hub.as(signer, planner?.role ?? 'planner');
1156
+ const disc = await scoped.discuss(repo, {
1157
+ title,
1158
+ body,
1159
+ categoryId: this.categoryId,
1160
+ repositoryId: this.repositoryId
1161
+ });
1162
+
1163
+ log.info(`escalated: "${title}" → discussion #${disc.number}`);
1164
+ return { discussionId: disc.id, categoryId: this.categoryId };
1165
+ }
1166
+ }