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.
- package/README.md +66 -26
- package/package.json +17 -14
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +33 -5
- package/packages/agent/README.md +107 -18
- package/packages/agent/src/backends/claude.js +111 -11
- package/packages/agent/src/backends/codex.js +78 -5
- package/packages/agent/src/backends/cursor.js +104 -27
- package/packages/agent/src/backends/index.js +162 -5
- package/packages/agent/src/cli.js +80 -3
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +39 -34
- package/packages/agent/src/models.js +24 -6
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +156 -91
- package/packages/config/src/defaults.js +32 -21
- package/packages/config/src/index.js +33 -2
- package/packages/config/src/schema.js +57 -39
- package/packages/hub/src/github.js +59 -20
- package/packages/identity/README.md +1 -1
- package/packages/identity/src/index.js +2 -2
- package/packages/knowledge/README.md +86 -106
- package/packages/knowledge/src/index.js +56 -225
- package/packages/mcp/README.md +51 -7
- package/packages/mcp/instructions.md +6 -1
- package/packages/mcp/scaffolding/loreli.yml +115 -77
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +1 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +4 -1
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +4 -1
- package/packages/mcp/src/index.js +45 -16
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +34 -13
- package/packages/mcp/src/tools/context.js +3 -2
- package/packages/mcp/src/tools/github.js +11 -47
- package/packages/mcp/src/tools/hitl.js +19 -6
- package/packages/mcp/src/tools/index.js +2 -1
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +159 -90
- package/packages/mcp/src/tools/status.js +5 -2
- package/packages/mcp/src/tools/work.js +18 -8
- package/packages/orchestrator/src/index.js +345 -79
- package/packages/planner/README.md +84 -1
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +326 -111
- package/packages/review/README.md +2 -2
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +196 -76
- package/packages/risk/README.md +81 -22
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +44 -33
- package/packages/tmux/src/index.js +61 -12
- package/packages/workflow/README.md +18 -14
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +191 -12
- package/packages/workspace/README.md +2 -2
- 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
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
|
626
|
-
return a.state === 'spawned' || a.state === '
|
|
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 === '
|
|
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
|
-
|
|
856
|
-
|
|
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
|
|
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
|
|
935
|
-
* 2. Deduplicate racing parents (keep lowest issue number)
|
|
936
|
-
* 3. Discover
|
|
937
|
-
* 4. Create parent
|
|
938
|
-
* 5. Link unlinked children
|
|
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
|
-
|
|
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(
|
|
959
|
-
return has(
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1251
|
+
parent = created;
|
|
1252
|
+
log.info(`link: created parent issue #${parent.number} for objective: ${objective}`);
|
|
1253
|
+
}
|
|
1040
1254
|
|
|
1041
|
-
|
|
1255
|
+
if (!parent) continue;
|
|
1042
1256
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1267
|
+
const linkedIds = new Set(linked.map(function id(subIssue) { return subIssue.id; }));
|
|
1054
1268
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1269
|
+
// Link unlinked children
|
|
1270
|
+
for (const child of scopedChildren) {
|
|
1271
|
+
if (linkedIds.has(child.id)) continue;
|
|
1058
1272
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|