loreli 1.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 (63) hide show
  1. package/README.md +66 -26
  2. package/package.json +17 -14
  3. package/packages/action/prompts/action.md +172 -0
  4. package/packages/action/src/index.js +33 -5
  5. package/packages/agent/README.md +107 -18
  6. package/packages/agent/src/backends/claude.js +111 -11
  7. package/packages/agent/src/backends/codex.js +78 -5
  8. package/packages/agent/src/backends/cursor.js +104 -27
  9. package/packages/agent/src/backends/index.js +162 -5
  10. package/packages/agent/src/cli.js +80 -3
  11. package/packages/agent/src/discover.js +396 -0
  12. package/packages/agent/src/factory.js +39 -34
  13. package/packages/agent/src/models.js +24 -6
  14. package/packages/classify/README.md +136 -0
  15. package/packages/classify/prompts/blocker.md +12 -0
  16. package/packages/classify/prompts/feedback.md +14 -0
  17. package/packages/classify/prompts/pane-state.md +20 -0
  18. package/packages/classify/src/index.js +81 -0
  19. package/packages/config/README.md +156 -91
  20. package/packages/config/src/defaults.js +32 -21
  21. package/packages/config/src/index.js +33 -2
  22. package/packages/config/src/schema.js +57 -39
  23. package/packages/hub/src/github.js +59 -20
  24. package/packages/identity/README.md +1 -1
  25. package/packages/identity/src/index.js +2 -2
  26. package/packages/knowledge/README.md +86 -106
  27. package/packages/knowledge/src/index.js +56 -225
  28. package/packages/mcp/README.md +51 -7
  29. package/packages/mcp/instructions.md +6 -1
  30. package/packages/mcp/scaffolding/loreli.yml +115 -77
  31. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +1 -0
  32. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +4 -1
  33. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +4 -1
  34. package/packages/mcp/src/index.js +45 -16
  35. package/packages/mcp/src/tools/agent-context.js +44 -0
  36. package/packages/mcp/src/tools/agents.js +34 -13
  37. package/packages/mcp/src/tools/context.js +3 -2
  38. package/packages/mcp/src/tools/github.js +11 -47
  39. package/packages/mcp/src/tools/hitl.js +19 -6
  40. package/packages/mcp/src/tools/index.js +2 -1
  41. package/packages/mcp/src/tools/refactor.js +227 -0
  42. package/packages/mcp/src/tools/repo.js +44 -0
  43. package/packages/mcp/src/tools/start.js +159 -90
  44. package/packages/mcp/src/tools/status.js +5 -2
  45. package/packages/mcp/src/tools/work.js +18 -8
  46. package/packages/orchestrator/src/index.js +345 -79
  47. package/packages/planner/README.md +84 -1
  48. package/packages/planner/prompts/plan-reviewer.md +109 -0
  49. package/packages/planner/prompts/planner.md +191 -0
  50. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  51. package/packages/planner/src/index.js +326 -111
  52. package/packages/review/README.md +2 -2
  53. package/packages/review/prompts/reviewer.md +158 -0
  54. package/packages/review/src/index.js +196 -76
  55. package/packages/risk/README.md +81 -22
  56. package/packages/risk/prompts/risk.md +272 -0
  57. package/packages/risk/src/index.js +44 -33
  58. package/packages/tmux/src/index.js +61 -12
  59. package/packages/workflow/README.md +18 -14
  60. package/packages/workflow/prompts/preamble.md +14 -0
  61. package/packages/workflow/src/index.js +191 -12
  62. package/packages/workspace/README.md +2 -2
  63. package/packages/workspace/src/index.js +69 -18
@@ -8,6 +8,12 @@ import { logger } from 'loreli/log';
8
8
 
9
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
10
  const log = logger('planner');
11
+ /**
12
+ * Default objective label used when no scoped objective metadata is available.
13
+ *
14
+ * @type {string}
15
+ */
16
+ const FALLBACK_OBJECTIVE = 'Planning objective';
11
17
 
12
18
  /**
13
19
  * Path to the plan-reviewer prompt template. Used for rendering the
@@ -25,6 +31,20 @@ const PLAN_REVIEWER_TEMPLATE = join(__dirname, '..', 'prompts', 'plan-reviewer.m
25
31
  */
26
32
  const TIEBREAKER_TEMPLATE = join(__dirname, '..', 'prompts', 'tiebreaker-reviewer.md');
27
33
 
34
+ /**
35
+ * Maximum time a dispatched planner can run without creating any
36
+ * planning discussion before it is considered stalled.
37
+ *
38
+ * The planning bootstrap path has a strict E2E timeout. A planner
39
+ * process that stays "working" without producing its first discussion
40
+ * can block scaling indefinitely because demand() sees non-zero supply.
41
+ * This timeout lets recover()/demand() treat that planner as stale so
42
+ * a replacement can be spawned before bootstrap times out.
43
+ *
44
+ * @type {number}
45
+ */
46
+ const PENDING_PLANNER_TIMEOUT = 120000;
47
+
28
48
  /**
29
49
  * Planning workflow for Loreli's orchestration pipeline.
30
50
  *
@@ -161,6 +181,14 @@ export class PlannerWorkflow extends Workflow {
161
181
  */
162
182
  _dispatched = new Set();
163
183
 
184
+ /**
185
+ * Timestamp (ms) when each planner last received the objective prompt.
186
+ * Used to detect stale bootstrap attempts that never created discussions.
187
+ *
188
+ * @type {Map<string, number>}
189
+ */
190
+ _dispatchedAt = new Map();
191
+
164
192
  /**
165
193
  * Report planner demand: how many open discussions need a planner
166
194
  * agent. Planner demand is typically low — one planner handles the
@@ -178,11 +206,29 @@ export class PlannerWorkflow extends Workflow {
178
206
  // Objective dispatched but planner died before creating any
179
207
  // discussion → treat as pending workload so scale() spawns
180
208
  // a replacement planner.
181
- const pending = this.objective && all.length === 0;
209
+ const pending = Boolean(this.objective) && all.length === 0;
182
210
  const workload = open.length > 0 || pending ? 1 : 0;
183
- const supply = this.agents().filter(function active(a) {
184
- return a.state !== 'dormant';
185
- }).length;
211
+ const now = Date.now();
212
+ let supply = 0;
213
+
214
+ for (const agent of this.agents()) {
215
+ if (agent.state === 'dormant') continue;
216
+ const name = agent.identity?.name;
217
+ if (!pending || !name || !this._dispatched.has(name)) {
218
+ supply++;
219
+ continue;
220
+ }
221
+
222
+ const sentAt = this._dispatchedAt.get(name);
223
+ if (typeof sentAt !== 'number' || now - sentAt < PENDING_PLANNER_TIMEOUT) {
224
+ supply++;
225
+ continue;
226
+ }
227
+
228
+ log.warn(
229
+ `demand: stale planner bootstrap ${name} (${Math.round((now - sentAt) / 1000)}s without discussions)`
230
+ );
231
+ }
186
232
 
187
233
  return { workload, supply, deficit: Math.max(0, workload - supply) };
188
234
  }
@@ -231,7 +277,19 @@ export class PlannerWorkflow extends Workflow {
231
277
  * @returns {Promise<void>}
232
278
  */
233
279
  async hydrate(repo) {
234
- if (!this.hub || !this.categoryId) return;
280
+ if (!this.hub) return;
281
+
282
+ if (!this.categoryId) {
283
+ try {
284
+ const cat = await this.hub.category(repo, 'Loreli');
285
+ this.categoryId = cat.id;
286
+ this.repositoryId = cat.repositoryId;
287
+ log.info('hydrate: lazily resolved Loreli category');
288
+ } catch (err) {
289
+ log.debug(`hydrate: category resolution failed: ${err.message}`);
290
+ return;
291
+ }
292
+ }
235
293
 
236
294
  const all = await this.hub.discussions(repo, this.categoryId);
237
295
 
@@ -293,6 +351,66 @@ export class PlannerWorkflow extends Workflow {
293
351
  }
294
352
  }
295
353
 
354
+ /**
355
+ * Archive orphaned open planning discussions before a new objective run.
356
+ *
357
+ * A prior interrupted session can leave planner-created discussions open
358
+ * without any review state. New callers that poll for "any open discussion"
359
+ * can accidentally latch these stale drafts instead of the fresh objective.
360
+ * To keep the planning pipeline deterministic, archive only discussions that
361
+ * look orphaned: open, planner-authored, no terminal review labels, and no
362
+ * comments.
363
+ *
364
+ * @param {string} repo - Repository in "owner/name" format.
365
+ * @returns {Promise<number>} Number of discussions archived.
366
+ */
367
+ async archive(repo) {
368
+ if (!this.categoryId) return 0;
369
+ if (typeof this.hub?.discussions !== 'function') return 0;
370
+ if (typeof this.hub?.discussion !== 'function') return 0;
371
+ if (typeof this.hub?.closeDiscussion !== 'function') return 0;
372
+
373
+ const all = await this.hub.discussions(repo, this.categoryId);
374
+ const stale = all.filter(function orphaned(d) {
375
+ return !d.closed &&
376
+ d.labels.includes('loreli:planner') &&
377
+ !d.labels.includes('loreli:approved') &&
378
+ !d.labels.includes('loreli:changes-requested') &&
379
+ !d.labels.includes('loreli:blocked') &&
380
+ !d.labels.includes('loreli:needs-attention');
381
+ });
382
+
383
+ let archived = 0;
384
+ for (const disc of stale) {
385
+ let full;
386
+ try {
387
+ full = await this.hub.discussion(repo, disc.number);
388
+ } catch (err) {
389
+ log.debug(`archive: skipping #${disc.number} — read failed: ${err.message}`);
390
+ continue;
391
+ }
392
+ if ((full.comments?.length ?? 0) > 0) continue;
393
+
394
+ if (typeof this.hub.applyDiscussionLabels === 'function') {
395
+ try {
396
+ await this.hub.applyDiscussionLabels(repo, disc.id, ['loreli:abandoned']);
397
+ } catch (err) {
398
+ log.warn(`archive: failed to label #${disc.number} as abandoned: ${err.message}`);
399
+ }
400
+ }
401
+
402
+ try {
403
+ await this.hub.closeDiscussion(disc.id);
404
+ archived++;
405
+ log.info(`archive: closed stale discussion #${disc.number} before planning`);
406
+ } catch (err) {
407
+ log.warn(`archive: failed to close stale discussion #${disc.number}: ${err.message}`);
408
+ }
409
+ }
410
+
411
+ return archived;
412
+ }
413
+
296
414
  /**
297
415
  * Dispatch the planning workflow to planner agents.
298
416
  *
@@ -302,20 +420,25 @@ export class PlannerWorkflow extends Workflow {
302
420
  *
303
421
  * @param {string} repo - Repository in "owner/name" format.
304
422
  * @param {string} objective - What should be planned.
423
+ * @param {object} [opts] - Options.
424
+ * @param {string} [opts.feedbackCategory] - Feedback category that triggered this plan.
305
425
  * @returns {Promise<{planners: string[], categoryId: string}>}
306
426
  * @throws {Error} When no planner agents are available.
307
427
  */
308
- async plan(repo, objective) {
428
+ async plan(repo, objective, opts = {}) {
309
429
  log.info(`planning: ${repo} — objective: ${objective}`);
310
430
  const planners = this.agents();
311
431
 
312
432
  if (!planners.length) throw new Error('No planner agents available');
313
433
 
314
434
  this.objective = objective;
435
+ this._dispatched.clear();
436
+ this._dispatchedAt.clear();
315
437
 
316
438
  const cat = await this.hub.category(repo, 'Loreli');
317
439
  this.categoryId = cat.id;
318
440
  this.repositoryId = cat.repositoryId;
441
+ await this.archive(repo);
319
442
  const dispatched = [];
320
443
 
321
444
  for (const agent of planners) {
@@ -327,6 +450,7 @@ export class PlannerWorkflow extends Workflow {
327
450
  model: agent.identity.model,
328
451
  objective,
329
452
  labels: agent.identity.labels?.('planner'),
453
+ feedbackCategory: opts.feedbackCategory ?? null,
330
454
  plan: true,
331
455
  categoryId: cat.id,
332
456
  repositoryId: cat.repositoryId
@@ -334,6 +458,7 @@ export class PlannerWorkflow extends Workflow {
334
458
 
335
459
  this.orchestrator.activity(agent.identity.name);
336
460
  this._dispatched.add(agent.identity.name);
461
+ this._dispatchedAt.set(agent.identity.name, Date.now());
337
462
  dispatched.push(agent.identity.name);
338
463
  }
339
464
 
@@ -363,11 +488,38 @@ export class PlannerWorkflow extends Workflow {
363
488
  const all = await this.hub.discussions(repo, this.categoryId);
364
489
  if (all.length > 0) return;
365
490
 
491
+ const now = Date.now();
492
+ const retired = new Set();
366
493
  const planners = this.agents().filter(function eligible(a) {
367
494
  return a.state !== 'dormant';
368
495
  });
369
496
 
370
497
  for (const agent of planners) {
498
+ const name = agent.identity.name;
499
+ if (!this._dispatched.has(name)) continue;
500
+
501
+ const sentAt = this._dispatchedAt.get(name);
502
+ if (typeof sentAt !== 'number') continue;
503
+
504
+ const elapsed = now - sentAt;
505
+ if (elapsed < PENDING_PLANNER_TIMEOUT) continue;
506
+
507
+ if (typeof this.orchestrator?.kill === 'function') {
508
+ try {
509
+ await this.orchestrator.kill(name);
510
+ log.warn(`recover: killed stale planner ${name} after ${Math.round(elapsed / 1000)}s without discussions`);
511
+ } catch (err) {
512
+ log.warn(`recover: failed to kill stale planner ${name}: ${err.message}`);
513
+ }
514
+ }
515
+
516
+ retired.add(name);
517
+ this._dispatched.delete(name);
518
+ this._dispatchedAt.delete(name);
519
+ }
520
+
521
+ for (const agent of planners) {
522
+ if (retired.has(agent.identity.name)) continue;
371
523
  if (this._dispatched.has(agent.identity.name)) continue;
372
524
 
373
525
  log.info(`recover: re-dispatching objective to ${agent.identity.name}`);
@@ -386,6 +538,7 @@ export class PlannerWorkflow extends Workflow {
386
538
 
387
539
  this.orchestrator.activity(agent.identity.name);
388
540
  this._dispatched.add(agent.identity.name);
541
+ this._dispatchedAt.set(agent.identity.name, Date.now());
389
542
  }
390
543
  }
391
544
 
@@ -495,12 +648,19 @@ export class PlannerWorkflow extends Workflow {
495
648
  for (const disc of needsRevision) {
496
649
  const full = await this.hub.discussion(repo, disc.number);
497
650
 
498
- // ── Blocker detection via heuristic classification ──
499
651
  const refs = extractRefs(full.comments);
500
652
  if (refs.length) {
501
- const classified = classifyRefs(full.comments, refs);
653
+ let classified;
654
+ try {
655
+ classified = await classifyRefs(full.comments, refs, {
656
+ backends: this.orchestrator.backendRegistry,
657
+ config: this.orchestrator.cfg
658
+ });
659
+ } catch (err) {
660
+ log.debug(`revise: blocker classification unavailable for #${disc.number}, skipping: ${err.message}`);
661
+ }
502
662
 
503
- if (classified.blockers.length) {
663
+ if (classified?.blockers?.length) {
504
664
  // Verify classified blockers are actually open issues
505
665
  const open = [];
506
666
  for (const ref of classified.blockers) {
@@ -617,13 +777,13 @@ export class PlannerWorkflow extends Workflow {
617
777
  if (!this.categoryId) return { reviewer: null, discussions: 0 };
618
778
 
619
779
  // Clear stale in-flight entries when no productive reviewer exists.
620
- // An active reviewer must be in a working state (spawned/running) —
780
+ // An active reviewer must be in a working state (spawned/working) —
621
781
  // dormant agents finished their work, dead agents crashed, and
622
782
  // stalled agents may never produce output.
623
783
  const reviewers = [...this.orchestrator.agents.values()]
624
784
  .filter(function isReviewer(a) { return a.role === 'reviewer'; });
625
- const productive = reviewers.filter(function working(a) {
626
- return a.state === 'spawned' || a.state === 'running';
785
+ const productive = reviewers.filter(function active(a) {
786
+ return a.state === 'spawned' || a.state === 'working';
627
787
  });
628
788
 
629
789
  if (!productive.length && this._inflight.size) {
@@ -661,7 +821,7 @@ export class PlannerWorkflow extends Workflow {
661
821
  const assignee = this._reviewerName
662
822
  ? this.orchestrator.agents.get(this._reviewerName)
663
823
  : null;
664
- const alive = assignee && (assignee.state === 'spawned' || assignee.state === 'running' || assignee.state === 'working');
824
+ const alive = assignee && (assignee.state === 'spawned' || assignee.state === 'working');
665
825
  if (alive) {
666
826
  log.debug(`review: reviewer ${this._reviewerName} still working on #${this._reviewing} — waiting`);
667
827
  return { reviewer: null, discussions: 0 };
@@ -817,6 +977,8 @@ export class PlannerWorkflow extends Workflow {
817
977
  * Runs LAST in the tick pipeline. For each discussion with the
818
978
  * `loreli:approved` label, creates a real issue, posts a closing
819
979
  * comment linking to the issue, and closes/locks the discussion.
980
+ * Promoted issues are stamped with a `loreli:plan` marker that carries
981
+ * the planning objective so parent-child linking can scope correctly.
820
982
  *
821
983
  * @param {string} repo - Repository in "owner/name" format.
822
984
  * @returns {Promise<Array<{number: number, title: string}>>}
@@ -852,11 +1014,19 @@ export class PlannerWorkflow extends Workflow {
852
1014
 
853
1015
  for (const disc of approved) {
854
1016
  try {
855
- // Strip the discussion's existing signature so open() applies
856
- // exactly one fresh stamp — prevents double-signature on promoted issues.
1017
+ const objective = this.objective ?? FALLBACK_OBJECTIVE;
1018
+ const carry = disc.labels.filter(function passthrough(l) {
1019
+ return l === 'loreli:refactor' || l.startsWith('loreli:feedback:');
1020
+ });
1021
+ const body = [
1022
+ Identity.strip(disc.body),
1023
+ mark('plan', { objective, discussion: String(disc.number) })
1024
+ ].join('\n\n');
1025
+
857
1026
  const issue = await scoped.open(repo, {
858
1027
  title: disc.title,
859
- body: Identity.strip(disc.body)
1028
+ body,
1029
+ ...(carry.length && { labels: carry })
860
1030
  });
861
1031
 
862
1032
  // Post themed closing comment linking to the new issue.
@@ -894,6 +1064,8 @@ export class PlannerWorkflow extends Workflow {
894
1064
  this._reviewerName = null;
895
1065
  this._inflight.clear();
896
1066
  this._revising.clear();
1067
+ this._dispatched.clear();
1068
+ this._dispatchedAt.clear();
897
1069
 
898
1070
  const planners = this.agents();
899
1071
  for (const p of planners) {
@@ -931,11 +1103,11 @@ export class PlannerWorkflow extends Workflow {
931
1103
  * is fully idempotent and distributed-safe across multiple machines.
932
1104
  *
933
1105
  * 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
1106
+ * 1. Scan for existing parent issues (label + marker) and scope by objective
1107
+ * 2. Deduplicate racing parents per objective (keep lowest issue number)
1108
+ * 3. Discover child issues and resolve objective from `loreli:plan` marker
1109
+ * 4. Create a parent for objective groups with >= 2 children and no parent
1110
+ * 5. Link unlinked children to the objective's canonical parent
939
1111
  *
940
1112
  * @param {string} repo - Repository in "owner/name" format.
941
1113
  * @returns {Promise<void>}
@@ -949,118 +1121,161 @@ export class PlannerWorkflow extends Workflow {
949
1121
  return;
950
1122
  }
951
1123
 
952
- // Find existing parent issues via label scan
953
- const candidates = await this.hub.issues(repo, {
1124
+ // Find existing parent issues via label scan. If the label index is
1125
+ // stale, fall back to an unfiltered scan and filter labels locally.
1126
+ let candidates = await this.hub.issues(repo, {
954
1127
  state: 'open', labels: ['loreli:parent']
955
1128
  });
1129
+ if (!candidates.length) {
1130
+ const open = await this.hub.issues(repo, { state: 'open' });
1131
+ candidates = open.filter(function withParentLabel(issue) {
1132
+ return issue.labels.includes('loreli:parent');
1133
+ });
1134
+ }
956
1135
 
957
1136
  // Confirm via marker — guards against accidental manual labeling
958
- const parents = candidates.filter(function confirmed(i) {
959
- return has(i.body, 'parent');
1137
+ const parents = candidates.filter(function confirmed(issue) {
1138
+ return has(issue.body, 'parent');
1139
+ }).map(function withObjective(issue) {
1140
+ const objective = parse(issue.body, 'parent')?.objective ?? FALLBACK_OBJECTIVE;
1141
+ return { issue, objective };
960
1142
  });
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
- }
1143
+ const parentObjectives = [...new Set(parents.map(function objective(entry) {
1144
+ return entry.objective;
1145
+ }))];
1146
+ const parentHint = parentObjectives.length === 1 ? parentObjectives[0] : null;
990
1147
 
991
1148
  // Discover child issues: agent-stamped loreli issues that are NOT the parent.
992
1149
  // The agent marker guards against human-created issues being linked as
993
1150
  // 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;
1151
+ let all = await this.hub.issues(repo, { state: 'open', labels: ['loreli'] });
1152
+ if (!all.length) {
1153
+ const open = await this.hub.issues(repo, { state: 'open' });
1154
+ all = open.filter(function withLoreliLabel(issue) {
1155
+ return issue.labels.includes('loreli');
1156
+ });
1157
+ }
1158
+
1159
+ const activeObjective = this.objective;
1160
+ const children = all.filter(function isChild(issue) {
1161
+ return has(issue.body, 'agent') && !has(issue.body, 'parent');
1162
+ }).map(function withObjective(issue) {
1163
+ const derived = parse(issue.body, 'plan')?.objective ?? activeObjective ?? parentHint ?? FALLBACK_OBJECTIVE;
1164
+ return { issue, objective: derived };
997
1165
  });
998
1166
 
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`);
1167
+ const objectives = new Set();
1168
+ for (const entry of parents) objectives.add(entry.objective);
1169
+ for (const entry of children) objectives.add(entry.objective);
1170
+
1171
+ for (const objective of objectives) {
1172
+ const scopedParents = parents
1173
+ .filter(function inScope(entry) { return entry.objective === objective; })
1174
+ .map(function issue(entry) { return entry.issue; });
1175
+ const scopedChildren = children
1176
+ .filter(function inScope(entry) { return entry.objective === objective; })
1177
+ .map(function issue(entry) { return entry.issue; });
1178
+
1179
+ let parent = null;
1180
+
1181
+ // Deduplicate racing parents per objective: keep lowest number, close others.
1182
+ if (scopedParents.length > 1) {
1183
+ scopedParents.sort(function byNumber(a, b) { return a.number - b.number; });
1184
+ parent = scopedParents[0];
1185
+
1186
+ for (let i = 1; i < scopedParents.length; i++) {
1187
+ try {
1188
+ const signer = this.agents()[0]?.identity ?? this.orchestrator?.clientIdentity;
1189
+ if (signer) {
1190
+ const scoped = this.hub.as(signer, 'planner');
1191
+ await scoped.comment(repo, scopedParents[i].number,
1192
+ `Duplicate parent detected. Keeping #${parent.number} as canonical parent.`);
1193
+ }
1194
+ // Close via direct Octokit — hub doesn't expose issue update yet
1195
+ const [owner, name] = this.hub.parse(repo);
1196
+ await this.hub.client.issues.update({
1197
+ owner, repo: name, issue_number: scopedParents[i].number, state: 'closed'
1198
+ });
1199
+ log.info(
1200
+ `link: closed duplicate parent #${scopedParents[i].number} ` +
1201
+ `for objective "${objective}" — keeping #${parent.number}`
1202
+ );
1203
+ } catch (err) {
1204
+ log.warn(`link: failed to close duplicate parent #${scopedParents[i].number}: ${err.message}`);
1205
+ }
1206
+ }
1207
+ } else if (scopedParents.length === 1) {
1208
+ parent = scopedParents[0];
1003
1209
  }
1004
- return;
1005
- }
1006
1210
 
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;
1211
+ if (scopedChildren.length < 2 && !parent) {
1212
+ // Not enough children for a parent hierarchy yet
1213
+ if (scopedChildren.length > 0) {
1214
+ log.debug(
1215
+ `link: ${scopedChildren.length} child issue(s) for objective "${objective}" ` +
1216
+ '— waiting for more before creating parent'
1217
+ );
1218
+ }
1219
+ continue;
1013
1220
  }
1014
1221
 
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');
1222
+ // Create parent if needed
1223
+ if (!parent && scopedChildren.length >= 2) {
1224
+ const signer = this.agents()[0]?.identity ?? this.orchestrator?.clientIdentity;
1225
+ if (!signer) {
1226
+ log.warn('link: no identity available for creating parent issue');
1227
+ return;
1228
+ }
1020
1229
 
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
- });
1230
+ const title = objective.split('\n')[0].slice(0, 128);
1231
+ const taskList = scopedChildren
1232
+ .map(function line(child) { return `- [ ] #${child.number} — ${child.title}`; })
1233
+ .join('\n');
1234
+
1235
+ const body = [
1236
+ '## Objective\n',
1237
+ objective,
1238
+ '\n### Work Items\n',
1239
+ taskList,
1240
+ '\n',
1241
+ mark('parent', { objective })
1242
+ ].join('\n');
1243
+
1244
+ const scoped = this.hub.as(signer, 'planner');
1245
+ const created = await scoped.open(repo, {
1246
+ title,
1247
+ body,
1248
+ labels: ['loreli:parent']
1249
+ });
1036
1250
 
1037
- parent = created;
1038
- log.info(`link: created parent issue #${parent.number} for objective: ${objective}`);
1039
- }
1251
+ parent = created;
1252
+ log.info(`link: created parent issue #${parent.number} for objective: ${objective}`);
1253
+ }
1040
1254
 
1041
- if (!parent) return;
1255
+ if (!parent) continue;
1042
1256
 
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
- }
1257
+ // Get already-linked sub-issues for idempotency
1258
+ let linked;
1259
+ try {
1260
+ linked = await this.hub.subs(repo, parent.number);
1261
+ } catch {
1262
+ // Sub-issues API may not be available — log and bail
1263
+ log.warn('link: sub-issues API unavailable — skipping linking');
1264
+ return;
1265
+ }
1052
1266
 
1053
- const linkedIds = new Set(linked.map(function id(s) { return s.id; }));
1267
+ const linkedIds = new Set(linked.map(function id(subIssue) { return subIssue.id; }));
1054
1268
 
1055
- // Link unlinked children
1056
- for (const child of children) {
1057
- if (linkedIds.has(child.id)) continue;
1269
+ // Link unlinked children
1270
+ for (const child of scopedChildren) {
1271
+ if (linkedIds.has(child.id)) continue;
1058
1272
 
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}`);
1273
+ try {
1274
+ await this.hub.sub(repo, parent.number, child.id);
1275
+ log.info(`link: linked #${child.number} as sub-issue of #${parent.number}`);
1276
+ } catch (err) {
1277
+ log.warn(`link: failed to link #${child.number} to #${parent.number}: ${err.message}`);
1278
+ }
1064
1279
  }
1065
1280
  }
1066
1281
  }
@@ -15,7 +15,7 @@ ReviewWorkflow's `scan()` uses a **label gate** to wait for risk assessment befo
15
15
  - **`loreli:medium-risk`** — Reviewer dispatched with risk context attached to the prompt.
16
16
  - **`loreli:critical-risk`** — PR added to `_completed` immediately; HITL escalation is handled by `loreli/risk`.
17
17
 
18
- Setting `review.skipRiskAssessment: true` in `loreli.yml` disables the label gate entirely.
18
+ Setting `workflows.risk.skip: true` in `loreli.yml` disables the label gate entirely.
19
19
 
20
20
  ## API Reference
21
21
 
@@ -38,7 +38,7 @@ const review = new ReviewWorkflow(orchestrator, hub);
38
38
 
39
39
  #### `review.scan(repo)` → Promise\<Array\<{pr, reviewer}\>\>
40
40
 
41
- Scan for new PRs created by action agents and dispatch reviewers. Uses branch naming convention (`{agentName}/issue-{number}`) to match PRs to agents. Defaults to opposing-provider reviewers when available, and only falls back to same-side reviewers in single-side environments. Requires a risk label on the PR unless `skipRiskAssessment` is true.
41
+ Scan for new PRs created by action agents and dispatch reviewers. Uses branch naming convention (`{agentName}/issue-{number}`) to match PRs to agents. Defaults to opposing-provider reviewers when available, and only falls back to same-side reviewers in single-side environments. Requires a risk label on the PR unless `workflows.risk.skip` is true.
42
42
 
43
43
  The `demand()` signal is topology-aware. In dual-side environments, it only counts opposing-side reviewers as supply. In single-side environments, same-side reviewers count as valid supply.
44
44