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
@@ -0,0 +1,158 @@
1
+ You are **{{name}}**, a code review agent for the repository **{{{repo}}}**.
2
+
3
+ <instructions>
4
+
5
+ ## Objective
6
+
7
+ Review pull requests and approve only when the work meets all acceptance criteria. Your review is the last automated quality gate before merge — missed issues ship to production.
8
+
9
+ ### Evaluation Process
10
+
11
+ Evaluate the PR against each criterion below in order. Only after assessing all criteria, synthesize your findings into your review:
12
+
13
+ 1. **Correctness**: Does the code do what the issue asks? Are edge cases handled?
14
+ 2. **Testing**: Are there tests? Do they cover the acceptance criteria and edge cases? Are tests written first (TDD)?
15
+ 3. **Documentation**: Are new APIs documented? Are comments meaningful and non-obvious?
16
+ 4. **Security**: Any hardcoded secrets, injection vectors, or unsafe input handling?
17
+ 5. **Performance**: Are there obvious performance concerns (N+1 queries, unbounded loops, memory leaks)?
18
+ 6. **DX**: Is the code readable and maintainable? Could another developer understand it without context?
19
+ 7. **Risk Assessment**: Does this PR introduce destructive changes? Large-scale deletions, removal of critical infrastructure files, or scope beyond the issue should be escalated using the **plan** tool (action: `escalate`).
20
+ 8. **Documentation completeness**: If the PR introduces architectural changes, verify architecture docs are created or updated. If new error handling is added, verify error resolution documentation exists. If new APIs are added, verify JSDoc and README updates. Missing documentation for structural changes is grounds for REQUEST_CHANGES.
21
+
22
+ ### Hard Blocking Criteria (Mandatory REQUEST_CHANGES)
23
+
24
+ These are non-negotiable blockers for functional code changes:
25
+
26
+ 1. **Bug fix without a regression test for corrected behavior** -> `REQUEST_CHANGES`
27
+ 2. **Feature change without comprehensive tests for expected behavior** -> `REQUEST_CHANGES`
28
+ 3. **AI-slop patterns without local-file precedent** -> `REQUEST_CHANGES`
29
+
30
+ Docs-only or non-functional changes are exempt from the strict functional test gate, but any functional behavior change must be covered by tests.
31
+
32
+ ### AI-Slop Blockers
33
+
34
+ Treat these as blocking when they are inconsistent with local file conventions:
35
+
36
+ - Gratuitous or inconsistent comments a human contributor would not normally add in that area.
37
+ - Abnormal defensive checks or `try/catch` wrappers on trusted or validated code paths.
38
+ - `any` casts or equivalent type escapes used to bypass type-safety issues.
39
+ - Style that is inconsistent with surrounding file patterns without explicit justification.
40
+
41
+ ### Feedback Style
42
+
43
+ - Be specific: reference exact files and lines, and provide concrete suggestions.
44
+ - Be constructive: explain *why* something should change, not just *that* it should.
45
+ - Be adversarial: challenge assumptions and push for quality.
46
+ - Acknowledge good work when you see it — positive signals help calibrate future agents.
47
+
48
+ ### Tools
49
+
50
+ Use these Loreli MCP tools for all GitHub operations:
51
+
52
+ - **pr** (action: `review`) — Submit your review. Provide `event` ("APPROVE" or "REQUEST_CHANGES"), `body` with your assessment, and `reasoning` documenting your evaluation. Your agent stamp is applied automatically.
53
+ - **read** — Read any issue, PR, or discussion by number. Use this to look up acceptance criteria in linked issues or understand PR context.
54
+ - **comment** — Post a comment on your current work item for follow-up or clarification.
55
+ - **plan** (action: `escalate`) — Flag a concern or discovery as a new discussion. Provide `title` and `body`.
56
+
57
+ ### Sign-off
58
+
59
+ Approve only when ALL acceptance criteria from the linked issue are met. If the PR introduces scope beyond the issue, request it be split — unscoped work bypasses the planning process and creates conflicts with parallel agents.
60
+
61
+ ### Rules
62
+
63
+ - Do not approve out of convenience — a rubber-stamp approval means bugs ship unchallenged.
64
+ - If the PR introduces scope beyond the issue, request it be split.
65
+ - If you discover new concerns, use the **plan** tool (action: `escalate`) to flag them.
66
+
67
+ </instructions>
68
+
69
+ <output_format>
70
+
71
+ Structure your review `body` as:
72
+
73
+ 1. **Summary**: One sentence stating your decision and the primary reason.
74
+ 2. **Criterion-level findings**: For each criterion that influenced your decision, reference specific files/lines and explain what is strong or needs change.
75
+ 3. **Decision**: APPROVE or REQUEST_CHANGES.
76
+
77
+ Structure your `reasoning` parameter as:
78
+
79
+ - How you evaluated the code against acceptance criteria.
80
+ - Key concerns or positive observations.
81
+ - Why you chose APPROVE or REQUEST_CHANGES.
82
+
83
+ </output_format>
84
+
85
+ <examples>
86
+
87
+ <example title="Review approving a well-implemented PR">
88
+ **Summary**: Approving — all five acceptance criteria are met, tests cover edge cases, and the retry logic is cleanly isolated.
89
+
90
+ **Correctness**: The retry wrapper correctly classifies 429 and 5xx as retryable. The `isRetryable` guard in `client.js:42` handles the boundary correctly — 399 falls through, 429 retries, 430 falls through.
91
+
92
+ **Testing**: Five test scenarios match the issue's TDD strategy. The jitter test at `test/retry.test.js:78` uses a statistical assertion (100 runs, checks variance) rather than exact matching — good approach for non-deterministic behavior.
93
+
94
+ **Security**: No secrets, no user input in retry paths.
95
+
96
+ **Decision**: APPROVE
97
+ </example>
98
+
99
+ <example title="Review requesting changes">
100
+ **Summary**: Requesting changes — the retry logic is correct but the error handling silently swallows non-retryable failures.
101
+
102
+ **Correctness**: `client.js:55` catches all errors and returns `null` instead of rethrowing non-retryable errors. This means 401 (auth expired) and 404 (not found) return `null` instead of surfacing the error to callers. The acceptance criteria require non-retryable errors to "propagate immediately."
103
+
104
+ **Testing**: The test at `test/retry.test.js:45` asserts `result === null` for 401, but the acceptance criteria say 401 should throw. The test is passing but testing the wrong behavior.
105
+
106
+ **DX**: The `sleep` utility in `utils.js:12` duplicates `setTimeout` promisification already available via `node:timers/promises`. Use `import { setTimeout } from 'node:timers/promises'` instead.
107
+
108
+ **Decision**: REQUEST_CHANGES
109
+ </example>
110
+
111
+ </examples>
112
+
113
+ {{#pullRequest}}
114
+ <context>
115
+
116
+ ## Pull Request to Review
117
+
118
+ **PR #{{number}}**: {{title}} (by **{{author}}**, {{authorProvider}})
119
+ **Branch**: `{{{head}}}` -> `{{{base}}}`
120
+ **Issue**: {{issue}}
121
+
122
+ ### Files Changed
123
+
124
+ {{#files}}
125
+
126
+ #### `{{filename}}` ({{status}}: +{{additions}} -{{deletions}})
127
+
128
+ {{#patch}}
129
+
130
+ ```diff
131
+ {{{patch}}}
132
+ ```
133
+
134
+ {{/patch}}
135
+ {{/files}}
136
+
137
+ Review this PR against the acceptance criteria in the linked issue, then submit your review using the **pr** tool (action: `review`).
138
+
139
+ </context>
140
+ {{/pullRequest}}
141
+
142
+ {{#riskContext}}
143
+ <context>
144
+
145
+ ## Risk Warning
146
+
147
+ The risk agent flagged concerns with this PR:
148
+
149
+ {{{assessment}}}
150
+
151
+ Pay special attention to these signals during your review.
152
+
153
+ </context>
154
+ {{/riskContext}}
155
+
156
+ <agent_metadata>
157
+ Faction: {{faction}} | Provider: {{provider}}
158
+ </agent_metadata>
@@ -83,10 +83,11 @@ export class ReviewWorkflow extends Workflow {
83
83
  /**
84
84
  * PRs that have been fully processed (merged, failed, or escalated).
85
85
  * Prevents scan() from re-adding PRs after land() removes them.
86
+ * Entries are `{ addedAt: number }` for TTL-based pruning.
86
87
  *
87
- * @type {Set<number>}
88
+ * @type {Map<number, number>}
88
89
  */
89
- _completed = new Set();
90
+ _completed = new Map();
90
91
 
91
92
  /**
92
93
  * Preserved round counts for evicted PRs. When a reviewer is evicted
@@ -94,11 +95,19 @@ export class ReviewWorkflow extends Workflow {
94
95
  * the round count must carry over so the escalation threshold
95
96
  * (maxRounds) is eventually reached. Without this, evict → scan
96
97
  * cycles reset rounds to 0 indefinitely.
98
+ * Entries are `{ rounds, addedAt }` for TTL-based pruning.
97
99
  *
98
- * @type {Map<number, number>}
100
+ * @type {Map<number, {rounds: number, addedAt: number}>}
99
101
  */
100
102
  _evictedRounds = new Map();
101
103
 
104
+ /**
105
+ * TTL for completed/evicted entries (1 hour). Entries older than
106
+ * this are pruned during hydrate() to prevent unbounded growth.
107
+ * @type {number}
108
+ */
109
+ static PRUNE_TTL = 3_600_000;
110
+
102
111
  /**
103
112
  * Extract linked issue numbers from a PR body using GitHub closing
104
113
  * keywords (`Closes #N`, `Fixes #N`, `Resolves #N`).
@@ -218,7 +227,7 @@ export class ReviewWorkflow extends Workflow {
218
227
  */
219
228
  async demand(repo) {
220
229
  const prs = await this.hub.pulls(repo, { state: 'open' });
221
- const skipRisk = this.orchestrator.cfg?.get?.('review.skipRiskAssessment') ?? false;
230
+ const skipRisk = this.orchestrator.cfg?.get?.('workflows.risk.skip') ?? false;
222
231
  let allowSameSide = false;
223
232
 
224
233
  try {
@@ -237,7 +246,7 @@ export class ReviewWorkflow extends Workflow {
237
246
  if (!skipRisk) {
238
247
  const hasRiskLabel = pr.labels?.some(function isRisk(l) {
239
248
  const name = l.name ?? l;
240
- return name.endsWith('-risk') && name.startsWith('loreli:');
249
+ return (name.endsWith('-risk') || name === 'loreli:risk-unassessed') && name.startsWith('loreli:');
241
250
  });
242
251
  if (!hasRiskLabel) continue;
243
252
 
@@ -245,6 +254,11 @@ export class ReviewWorkflow extends Workflow {
245
254
  return (l.name ?? l) === 'loreli:critical-risk';
246
255
  });
247
256
  if (isCritical) continue;
257
+
258
+ const isUnassessed = pr.labels?.some(function isUn(l) {
259
+ return (l.name ?? l) === 'loreli:risk-unassessed';
260
+ });
261
+ if (isUnassessed) continue;
248
262
  }
249
263
 
250
264
  // Collect action providers from PR labels so scale() can spawn
@@ -346,7 +360,7 @@ export class ReviewWorkflow extends Workflow {
346
360
  name === 'loreli:merge-failed';
347
361
  });
348
362
  if (isTerminal) {
349
- this._completed.add(pr.number);
363
+ this._completed.set(pr.number, Date.now());
350
364
  continue;
351
365
  }
352
366
 
@@ -370,14 +384,30 @@ export class ReviewWorkflow extends Workflow {
370
384
 
371
385
  // Round count from CHANGES_REQUESTED reviews
372
386
  const reviews = await this.hub.reviews(repo, pr.number);
373
- const rounds = reviews.filter(function isCR(r) {
387
+ const crReviews = reviews.filter(function isCR(r) {
374
388
  return r.state === 'CHANGES_REQUESTED';
375
- }).length;
389
+ });
390
+ const rounds = crReviews.length;
391
+
392
+ // Restore lastReviewId by finding the latest feedback marker
393
+ // and matching it to the CHANGES_REQUESTED review that triggered it.
394
+ let lastReviewId = null;
395
+ const feedbackMarker = [...comments].reverse().find(function isFeedback(c) {
396
+ return has(c.body, 'feedback');
397
+ });
398
+ if (feedbackMarker && crReviews.length) {
399
+ const markerTime = new Date(feedbackMarker.created).getTime();
400
+ const processed = crReviews.filter(function before(r) {
401
+ return new Date(r.submitted).getTime() <= markerTime;
402
+ }).at(-1);
403
+ lastReviewId = processed?.id ?? null;
404
+ }
376
405
 
377
406
  this._watched.set(pr.number, {
378
407
  agent: agentName,
379
408
  reviewer: reviewerName,
380
409
  rounds,
410
+ lastReviewId,
381
411
  dispatchedAt: new Date(claim.created ?? claim.created_at).getTime()
382
412
  });
383
413
  }
@@ -388,9 +418,19 @@ export class ReviewWorkflow extends Workflow {
388
418
  for (const prNum of this._watched.keys()) {
389
419
  if (!prs.some(function match(p) { return p.number === prNum; })) {
390
420
  this._watched.delete(prNum);
391
- this._completed.add(prNum);
421
+ this._completed.set(prNum, Date.now());
392
422
  }
393
423
  }
424
+
425
+ // Prune stale entries to prevent unbounded growth in long-running sessions
426
+ const ttl = ReviewWorkflow.PRUNE_TTL;
427
+ const now = Date.now();
428
+ for (const [prNum, addedAt] of this._completed) {
429
+ if (now - addedAt > ttl) this._completed.delete(prNum);
430
+ }
431
+ for (const [prNum, entry] of this._evictedRounds) {
432
+ if (now - entry.addedAt > ttl) this._evictedRounds.delete(prNum);
433
+ }
394
434
  }
395
435
 
396
436
  // ── Signoff Protocol ──────────────────────────────────
@@ -448,7 +488,7 @@ export class ReviewWorkflow extends Workflow {
448
488
  // be evicted immediately — no proof-of-life needed.
449
489
  if (this.orchestrator._removed?.has(tracking.reviewer)) {
450
490
  log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} was locally removed`);
451
- this._evictedRounds.set(prNum, tracking.rounds);
491
+ this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
452
492
  this._watched.delete(prNum);
453
493
  await this._releaseReviewer(repo, prNum, tracking.reviewer,
454
494
  `Review claim released — reviewer \`${tracking.reviewer}\` is no longer active.`);
@@ -462,7 +502,7 @@ export class ReviewWorkflow extends Workflow {
462
502
  continue;
463
503
  }
464
504
  log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} proof-of-life expired`);
465
- this._evictedRounds.set(prNum, tracking.rounds);
505
+ this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
466
506
  this._watched.delete(prNum);
467
507
  await this._releaseReviewer(repo, prNum, tracking.reviewer,
468
508
  `Review claim released — no proof of life from \`${tracking.reviewer}\`.`);
@@ -475,7 +515,7 @@ export class ReviewWorkflow extends Workflow {
475
515
  // recovery.
476
516
  if (reviewer.state === 'dormant') {
477
517
  log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} is dormant`);
478
- this._evictedRounds.set(prNum, tracking.rounds);
518
+ this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
479
519
  this._watched.delete(prNum);
480
520
  await this._releaseReviewer(repo, prNum, tracking.reviewer,
481
521
  `Review claim released — reviewer \`${tracking.reviewer}\` is dormant.`);
@@ -498,7 +538,7 @@ export class ReviewWorkflow extends Workflow {
498
538
  });
499
539
  if (!submitted) {
500
540
  log.warn(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} dispatched ${Math.round(elapsed / 1000)}s ago with no review`);
501
- this._evictedRounds.set(prNum, tracking.rounds);
541
+ this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
502
542
  this._watched.delete(prNum);
503
543
  await this._releaseReviewer(repo, prNum, tracking.reviewer,
504
544
  `Review claim released — reviewer \`${tracking.reviewer}\` stalled (${Math.round(elapsed / 1000)}s, no review submitted).`);
@@ -516,7 +556,7 @@ export class ReviewWorkflow extends Workflow {
516
556
  continue;
517
557
  }
518
558
  log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} confirmed stalled via PoL`);
519
- this._evictedRounds.set(prNum, tracking.rounds);
559
+ this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
520
560
  this._watched.delete(prNum);
521
561
  await this._releaseReviewer(repo, prNum, tracking.reviewer,
522
562
  `Review claim released — reviewer \`${tracking.reviewer}\` confirmed stalled.`);
@@ -537,7 +577,7 @@ export class ReviewWorkflow extends Workflow {
537
577
  const actions = [...this.orchestrator.agents.values()]
538
578
  .filter(function isAction(a) { return a.role === 'action'; });
539
579
 
540
- const skipRisk = this.orchestrator.cfg?.get?.('review.skipRiskAssessment') ?? false;
580
+ const skipRisk = this.orchestrator.cfg?.get?.('workflows.risk.skip') ?? false;
541
581
  let allowSameSide = false;
542
582
 
543
583
  try {
@@ -610,13 +650,14 @@ export class ReviewWorkflow extends Workflow {
610
650
  ?? 'unknown';
611
651
 
612
652
  // Label gate: only dispatch reviewer for PRs that have been
613
- // risk-assessed (carry a loreli:*-risk label), or when risk
614
- // assessment is disabled. RiskWorkflow runs first in the
615
- // reactor chain and applies labels before this handler fires.
653
+ // risk-assessed (carry a loreli:*-risk label) or flagged as
654
+ // unassessed, or when risk assessment is disabled. RiskWorkflow
655
+ // runs first in the reactor chain and applies labels before
656
+ // this handler fires.
616
657
  if (!skipRisk) {
617
658
  const hasRiskLabel = pr.labels?.some(function isRisk(l) {
618
659
  const name = l.name ?? l;
619
- return name.endsWith('-risk') && name.startsWith('loreli:');
660
+ return (name.endsWith('-risk') || name === 'loreli:risk-unassessed') && name.startsWith('loreli:');
620
661
  });
621
662
  if (!hasRiskLabel) continue;
622
663
 
@@ -631,10 +672,27 @@ export class ReviewWorkflow extends Workflow {
631
672
  } catch (err) {
632
673
  log.warn(`scan: HITL failed for CRITICAL PR #${pr.number}: ${err.message}`);
633
674
  }
634
- this._completed.add(pr.number);
675
+ this._completed.set(pr.number, Date.now());
635
676
  log.info(`scan: PR #${pr.number} escalated to HITL — risk verdict CRITICAL`);
636
677
  continue;
637
678
  }
679
+
680
+ // Unassessed PRs (risk agent crashed, timed out, or failed to
681
+ // dispatch) carry risk-unassessed and must escalate to HITL.
682
+ // Fail-safe: never let unassessed changes through to automated review.
683
+ const isUnassessed = pr.labels?.some(function isUn(l) {
684
+ return (l.name ?? l) === 'loreli:risk-unassessed';
685
+ });
686
+ if (isUnassessed) {
687
+ try {
688
+ await this.hitl(repo, pr.number);
689
+ } catch (err) {
690
+ log.warn(`scan: HITL failed for unassessed PR #${pr.number}: ${err.message}`);
691
+ }
692
+ this._completed.set(pr.number, Date.now());
693
+ log.info(`scan: PR #${pr.number} escalated to HITL — risk assessment failed`);
694
+ continue;
695
+ }
638
696
  }
639
697
 
640
698
  // Attach risk context for MEDIUM PRs so the reviewer sees the warning
@@ -696,7 +754,8 @@ export class ReviewWorkflow extends Workflow {
696
754
  await checkout(reviewerCwd, pr.head, base, options);
697
755
  log.info(`scan: checked out ${pr.head} in ${reviewer.identity.name}'s workspace`);
698
756
  } catch (err) {
699
- log.warn(`scan: branch checkout failed for ${reviewer.identity.name}: ${err.message}`);
757
+ log.warn(`scan: branch checkout failed for ${reviewer.identity.name}: ${err.message} — skipping PR`);
758
+ continue;
700
759
  }
701
760
 
702
761
  const files = await this.hub.files(repo, pr.number);
@@ -729,7 +788,7 @@ export class ReviewWorkflow extends Workflow {
729
788
  await this.dispatch(reviewer, templateVars);
730
789
  this.orchestrator.activity(reviewer.identity.name);
731
790
 
732
- const restored = this._evictedRounds.get(pr.number) ?? 0;
791
+ const restored = this._evictedRounds.get(pr.number)?.rounds ?? 0;
733
792
  this._evictedRounds.delete(pr.number);
734
793
 
735
794
  this._watched.set(pr.number, {
@@ -814,7 +873,7 @@ export class ReviewWorkflow extends Workflow {
814
873
  }
815
874
 
816
875
  this._watched.delete(prNum);
817
- this._completed.add(prNum);
876
+ this._completed.set(prNum, Date.now());
818
877
 
819
878
  // Kill the reviewer — the PR is closed, no review needed.
820
879
  if (tracking.reviewer && this.orchestrator.agents.has(tracking.reviewer)) {
@@ -906,18 +965,22 @@ export class ReviewWorkflow extends Workflow {
906
965
 
907
966
  const feedbackEnabled = this.orchestrator.cfg?.get?.('feedback.enabled') ?? true;
908
967
  if (feedbackEnabled && latest.body) {
909
- const categories = this.orchestrator.cfg?.get?.('feedback.categories');
910
- const { category, confidence } = classify(latest.body, { categories });
911
- if (confidence > 0) {
912
- const identity = this.orchestrator.clientIdentity;
913
- const provider = this.orchestrator.agents.get(tracking.reviewer)?.identity?.provider ?? 'unknown';
914
- const marker = mark('feedback', { category, confidence: confidence.toFixed(2), provider });
915
- const visible = `Feedback routing note: classified latest review as **${category}** (${confidence.toFixed(2)} confidence).`;
916
- try {
917
- if (identity) await this.hub.as(identity, 'orchestrator').comment(repo, prNum, `${marker}\n${visible}`);
918
- log.debug(`forward: classified feedback on PR #${prNum} as ${category} (${confidence.toFixed(2)})`);
919
- } catch (err) { log.debug(`forward: feedback marker failed: ${err.message}`); }
920
- }
968
+ try {
969
+ const { category, confidence } = await classify(latest.body, {
970
+ backends: this.orchestrator.backendRegistry,
971
+ config: this.orchestrator.cfg
972
+ });
973
+ if (confidence > 0) {
974
+ const identity = this.orchestrator.clientIdentity;
975
+ const provider = this.orchestrator.agents.get(tracking.reviewer)?.identity?.provider ?? 'unknown';
976
+ const marker = mark('feedback', { category, confidence: confidence.toFixed(2), provider });
977
+ const visible = `Feedback routing note: classified latest review as **${category}** (${confidence.toFixed(2)} confidence).`;
978
+ try {
979
+ if (identity) await this.hub.as(identity, 'orchestrator').comment(repo, prNum, `${marker}\n${visible}`);
980
+ log.debug(`forward: classified feedback on PR #${prNum} as ${category} (${confidence.toFixed(2)})`);
981
+ } catch (err) { log.debug(`forward: feedback marker failed: ${err.message}`); }
982
+ }
983
+ } catch (err) { log.debug(`forward: feedback classification unavailable, skipping marker: ${err.message}`); }
921
984
  }
922
985
 
923
986
  // Reset so _redispatch can fire again when the action agent
@@ -929,6 +992,47 @@ export class ReviewWorkflow extends Workflow {
929
992
  log.info(`forward: relayed feedback on PR #${prNum} to ${tracking.agent} (round ${tracking.rounds})`);
930
993
  }
931
994
 
995
+ // ── Conflict nudge ──────────────────────────────────
996
+ // Check watched PRs for merge conflicts and nudge the action agent
997
+ // to rebase. GitHub computes mergeable asynchronously on single-PR
998
+ // fetches — null means pending, skip until next tick.
999
+ for (const [prNum, tracking] of this._watched) {
1000
+ const agent = this.orchestrator.agents.get(tracking.agent);
1001
+ if (!agent) continue;
1002
+
1003
+ let pr;
1004
+ try {
1005
+ pr = await this.hub.pull(repo, prNum);
1006
+ } catch (err) {
1007
+ log.debug(`forward: conflict check skipped for PR #${prNum}: ${err.message}`);
1008
+ continue;
1009
+ }
1010
+ if (pr.mergeable == null) continue;
1011
+
1012
+ if (pr.mergeable) {
1013
+ if (tracking.conflictNotifiedSha) {
1014
+ log.info(`forward: PR #${prNum} conflict resolved`);
1015
+ tracking.conflictNotifiedSha = null;
1016
+ }
1017
+ continue;
1018
+ }
1019
+
1020
+ if (tracking.conflictNotifiedSha === pr.headSha) continue;
1021
+
1022
+ if (agent.state === 'dormant') await agent.spawn();
1023
+
1024
+ const base = pr.base || 'main';
1025
+ await agent.send(
1026
+ `Your pull request #${prNum} has merge conflicts with \`${base}\`. ` +
1027
+ `Rebase your branch onto \`origin/${base}\`, resolve the conflicts, ` +
1028
+ `and force-push to update the PR.`
1029
+ );
1030
+ this.orchestrator.activity(agent.identity.name);
1031
+ tracking.conflictNotifiedSha = pr.headSha;
1032
+
1033
+ log.info(`forward: nudged ${tracking.agent} to resolve conflict on PR #${prNum}`);
1034
+ }
1035
+
932
1036
  return forwarded;
933
1037
  }
934
1038
 
@@ -939,13 +1043,14 @@ export class ReviewWorkflow extends Workflow {
939
1043
  * approval. Auto-merges or triggers Human In The Loop (HITL).
940
1044
  *
941
1045
  * @param {string} repo - Repository in "owner/name" format.
942
- * @returns {Promise<Array<{pr: number, merged: boolean, gated: boolean}>>}
1046
+ * @returns {Promise<Array<{pr: number, merged: boolean, gated: boolean, agent: string}>>}
943
1047
  */
944
1048
  async land(repo) {
945
1049
  if (!this._watched.size) return [];
946
1050
 
947
1051
  const method = this.orchestrator.cfg?.get?.('merge.method') ?? 'squash';
948
- const hitlEnabled = this.orchestrator.cfg?.get?.('merge.hitl') ?? false;
1052
+ const globalHitl = this.orchestrator.cfg?.get?.('merge.hitl') ?? false;
1053
+ const feedbackHitl = this.orchestrator.cfg?.get?.('feedback.hitl') ?? false;
949
1054
  const humanReviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
950
1055
  let dualSide = true;
951
1056
 
@@ -984,39 +1089,63 @@ export class ReviewWorkflow extends Workflow {
984
1089
  await this.signoff(repo, prNum, reviewer.identity, reviewer.role ?? 'reviewer');
985
1090
  }
986
1091
 
987
- if (hitlEnabled && humanReviewers.length) {
1092
+ let needsHitl = globalHitl;
1093
+
1094
+ if (!needsHitl && feedbackHitl !== false) {
1095
+ const prData = await this.hub.read(repo, prNum);
1096
+ const closesMatch = prData.body?.match(/Closes #(\d+)/);
1097
+ if (closesMatch) {
1098
+ try {
1099
+ const issueData = await this.hub.read(repo, Number(closesMatch[1]));
1100
+ const fbLabel = (issueData.labels ?? []).find(function isFeedback(l) {
1101
+ const name = typeof l === 'string' ? l : l.name;
1102
+ return name?.startsWith('loreli:feedback:');
1103
+ });
1104
+ if (fbLabel) {
1105
+ const category = (typeof fbLabel === 'string' ? fbLabel : fbLabel.name)
1106
+ .replace('loreli:feedback:', '');
1107
+ needsHitl = feedbackHitl === true || feedbackHitl.includes(category);
1108
+ }
1109
+ } catch (err) {
1110
+ log.warn(`land: feedback HITL check failed for PR #${prNum}: ${err.message}`);
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ if (needsHitl && humanReviewers.length) {
988
1116
  await this.hitl(repo, prNum);
989
1117
  this._watched.delete(prNum);
990
- this._completed.add(prNum);
991
- landed.push({ pr: prNum, merged: false, gated: true });
1118
+ this._completed.set(prNum, Date.now());
1119
+ landed.push({ pr: prNum, merged: false, gated: true, agent: tracking.agent });
992
1120
  log.info(`land: PR #${prNum} escalated to HITL`);
993
1121
  } else {
994
1122
  try {
995
1123
  await this.hub.merge(repo, prNum, { method });
996
1124
  await this.reconcile(repo, prNum);
997
1125
  this._watched.delete(prNum);
998
- this._completed.add(prNum);
999
- landed.push({ pr: prNum, merged: true, gated: false });
1126
+ this._completed.set(prNum, Date.now());
1127
+ landed.push({ pr: prNum, merged: true, gated: false, agent: tracking.agent });
1000
1128
  log.info(`land: PR #${prNum} merged via ${method}`);
1001
1129
  } catch (err) {
1002
1130
  // Merge failure escalation: alert humans instead of silently
1003
1131
  // abandoning. 405 = not mergeable, 409 = merge conflict.
1004
1132
  this._watched.delete(prNum);
1005
- this._completed.add(prNum);
1006
- landed.push({ pr: prNum, merged: false, gated: false });
1133
+ this._completed.set(prNum, Date.now());
1134
+ landed.push({ pr: prNum, merged: false, gated: false, agent: tracking.agent });
1007
1135
 
1008
- const signer = this.agents()[0]?.identity ?? this.orchestrator.clientIdentity;
1009
- if (signer) {
1010
- try {
1011
- const scoped = this.hub.as(signer, 'reviewer');
1012
- await scoped.comment(repo, prNum,
1013
- `${mark('hitl')}\n**Merge failed — needs human attention**\n\n` +
1014
- `Auto-merge failed: \`${err.message}\`\n\n` +
1015
- `Please resolve the issue and merge manually.`);
1016
- await this.hub.label(repo, prNum, ['loreli:needs-attention', 'loreli:merge-failed']);
1017
- } catch (labelErr) {
1018
- log.warn(`land: failed to label merge-failure on PR #${prNum}: ${labelErr.message}`);
1136
+ try {
1137
+ const signer = this.agents()[0]?.identity ?? this.orchestrator.clientIdentity;
1138
+ const body = `${mark('hitl')}\n**Merge failed — needs human attention**\n\n` +
1139
+ `Auto-merge failed: \`${err.message}\`\n\n` +
1140
+ `Please resolve the issue and merge manually.`;
1141
+ if (signer) {
1142
+ await this.hub.as(signer, 'reviewer').comment(repo, prNum, body);
1143
+ } else {
1144
+ await this.hub.comment(repo, prNum, body);
1019
1145
  }
1146
+ await this.hub.label(repo, prNum, ['loreli:needs-attention', 'loreli:merge-failed']);
1147
+ } catch (labelErr) {
1148
+ log.warn(`land: failed to label merge-failure on PR #${prNum}: ${labelErr.message}`);
1020
1149
  }
1021
1150
 
1022
1151
  log.warn(`land: merge failed for PR #${prNum}: ${err.message}`);
@@ -1036,26 +1165,17 @@ export class ReviewWorkflow extends Workflow {
1036
1165
  }
1037
1166
  }
1038
1167
 
1039
- // Kill action agents when all watched PRs have been finalized.
1040
- // GitHub's issue auto-close ("Closes #N") is asynchronousthe
1041
- // linked issue may still appear "open" milliseconds after merge.
1042
- // Checking issue state here causes a race: land() sees open issues,
1043
- // skips the kill, and the action agent stays "working" forever
1044
- // (reap only touches dormant agents). Instead, unconditionally
1045
- // kill action agents when no PRs remain in flight. If new issues
1046
- // appear (human-created or from another planning cycle), the
1047
- // reactor's next tick will re-enlist fresh agents via start_work.
1048
- if (landed.length > 0 && this._watched.size === 0) {
1049
- const actions = [...this.orchestrator.agents.values()]
1050
- .filter(function isAction(a) { return a.role === 'action'; });
1051
-
1052
- for (const a of actions) {
1053
- try {
1054
- await this.orchestrator.kill(a.identity.name);
1055
- log.info(`land: stopped action agent ${a.identity.name} — all PRs finalized`);
1056
- } catch (err) {
1057
- log.warn(`land: failed to stop action agent ${a.identity.name}: ${err.message}`);
1058
- }
1168
+ // Kill action agents whose PRs were finalized (merged, gated, or
1169
+ // failed). Only targets agents that owned a landed PR unrelated
1170
+ // action agents working on other issues are left untouched.
1171
+ const landedAgents = new Set(landed.map(function agent(l) { return l.agent; }));
1172
+ for (const name of landedAgents) {
1173
+ if (!name || !this.orchestrator.agents.has(name)) continue;
1174
+ try {
1175
+ await this.orchestrator.kill(name);
1176
+ log.info(`land: stopped action agent ${name} PR finalized`);
1177
+ } catch (err) {
1178
+ log.warn(`land: failed to stop action agent ${name}: ${err.message}`);
1059
1179
  }
1060
1180
  }
1061
1181
 
@@ -1253,7 +1373,7 @@ export class ReviewWorkflow extends Workflow {
1253
1373
  if (humanReviewers.length) {
1254
1374
  await this.hitl(repo, prNum);
1255
1375
  this._watched.delete(prNum);
1256
- this._completed.add(prNum);
1376
+ this._completed.set(prNum, Date.now());
1257
1377
  log.info(`escalate: PR #${prNum} handed to human reviewers after ${tracking.rounds} rounds`);
1258
1378
  return;
1259
1379
  }