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
|
@@ -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 {
|
|
88
|
+
* @type {Map<number, number>}
|
|
88
89
|
*/
|
|
89
|
-
_completed = new
|
|
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?.('
|
|
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.
|
|
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
|
|
387
|
+
const crReviews = reviews.filter(function isCR(r) {
|
|
374
388
|
return r.state === 'CHANGES_REQUESTED';
|
|
375
|
-
})
|
|
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.
|
|
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?.('
|
|
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)
|
|
614
|
-
// assessment is disabled. RiskWorkflow
|
|
615
|
-
// reactor chain and applies labels before
|
|
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.
|
|
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.
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
await this.hub.
|
|
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
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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.
|
|
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
|
}
|