loreli 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +670 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +74 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/src/index.js +656 -0
  8. package/packages/agent/README.md +517 -0
  9. package/packages/agent/src/backends/claude.js +287 -0
  10. package/packages/agent/src/backends/codex.js +278 -0
  11. package/packages/agent/src/backends/cursor.js +294 -0
  12. package/packages/agent/src/backends/index.js +329 -0
  13. package/packages/agent/src/base.js +138 -0
  14. package/packages/agent/src/cli.js +198 -0
  15. package/packages/agent/src/factory.js +119 -0
  16. package/packages/agent/src/index.js +12 -0
  17. package/packages/agent/src/models.js +141 -0
  18. package/packages/agent/src/output.js +62 -0
  19. package/packages/agent/src/session.js +162 -0
  20. package/packages/agent/src/trace.js +186 -0
  21. package/packages/config/README.md +833 -0
  22. package/packages/config/src/defaults.js +134 -0
  23. package/packages/config/src/index.js +192 -0
  24. package/packages/config/src/schema.js +273 -0
  25. package/packages/config/src/validate.js +160 -0
  26. package/packages/context/README.md +165 -0
  27. package/packages/context/src/index.js +198 -0
  28. package/packages/hub/README.md +338 -0
  29. package/packages/hub/src/base.js +154 -0
  30. package/packages/hub/src/github.js +1558 -0
  31. package/packages/hub/src/index.js +79 -0
  32. package/packages/hub/src/labels.js +48 -0
  33. package/packages/identity/README.md +288 -0
  34. package/packages/identity/src/index.js +620 -0
  35. package/packages/identity/src/themes/avatar.js +217 -0
  36. package/packages/identity/src/themes/digimon.js +217 -0
  37. package/packages/identity/src/themes/dragonball.js +217 -0
  38. package/packages/identity/src/themes/lotr.js +217 -0
  39. package/packages/identity/src/themes/marvel.js +217 -0
  40. package/packages/identity/src/themes/pokemon.js +217 -0
  41. package/packages/identity/src/themes/starwars.js +217 -0
  42. package/packages/identity/src/themes/transformers.js +217 -0
  43. package/packages/identity/src/themes/zelda.js +217 -0
  44. package/packages/knowledge/README.md +237 -0
  45. package/packages/knowledge/src/index.js +412 -0
  46. package/packages/log/README.md +93 -0
  47. package/packages/log/src/index.js +252 -0
  48. package/packages/marker/README.md +200 -0
  49. package/packages/marker/src/index.js +184 -0
  50. package/packages/mcp/README.md +279 -0
  51. package/packages/mcp/instructions.md +121 -0
  52. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  53. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  54. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  55. package/packages/mcp/scaffolding/loreli.yml +453 -0
  56. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
  57. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
  58. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
  59. package/packages/mcp/scaffolding/pull-request.md +23 -0
  60. package/packages/mcp/src/index.js +571 -0
  61. package/packages/mcp/src/tools/agents.js +429 -0
  62. package/packages/mcp/src/tools/context.js +199 -0
  63. package/packages/mcp/src/tools/github.js +1199 -0
  64. package/packages/mcp/src/tools/hitl.js +149 -0
  65. package/packages/mcp/src/tools/index.js +17 -0
  66. package/packages/mcp/src/tools/start.js +835 -0
  67. package/packages/mcp/src/tools/status.js +146 -0
  68. package/packages/mcp/src/tools/work.js +124 -0
  69. package/packages/orchestrator/README.md +192 -0
  70. package/packages/orchestrator/src/index.js +1226 -0
  71. package/packages/planner/README.md +168 -0
  72. package/packages/planner/src/index.js +1166 -0
  73. package/packages/review/README.md +129 -0
  74. package/packages/review/src/index.js +1283 -0
  75. package/packages/risk/README.md +119 -0
  76. package/packages/risk/src/index.js +428 -0
  77. package/packages/session/README.md +165 -0
  78. package/packages/session/src/index.js +215 -0
  79. package/packages/test-utils/README.md +96 -0
  80. package/packages/test-utils/src/index.js +354 -0
  81. package/packages/tmux/README.md +261 -0
  82. package/packages/tmux/src/index.js +452 -0
  83. package/packages/workflow/README.md +313 -0
  84. package/packages/workflow/src/index.js +481 -0
  85. package/packages/workflow/src/proof-of-life.js +74 -0
  86. package/packages/workspace/README.md +143 -0
  87. package/packages/workspace/src/index.js +1076 -0
  88. package/index.js +0 -8
@@ -0,0 +1,119 @@
1
+ # loreli/risk
2
+
3
+ Risk assessment workflow for Loreli's orchestration pipeline. Extends the `Workflow` base class to manage risk agents — dispatching them for new PRs, reading their verdicts, and applying risk labels that gate reviewer dispatch in `loreli/review`.
4
+
5
+ ## Research Findings
6
+
7
+ No existing npm packages perform LLM-driven PR risk assessment with label-based routing. This is domain-specific to Loreli's adversarial review model.
8
+
9
+ ## How It Works
10
+
11
+ RiskWorkflow runs **before** ReviewWorkflow in the reactor chain. When a new PR appears from an action agent, the risk workflow:
12
+
13
+ 1. **Assess** — Dispatches an opposing-provider risk agent with the PR's diff, file stats, linked issue body, and planning objective.
14
+ 2. **Verdict** — Reads the risk agent's verdict comment (posted via the `comment` MCP tool with `risk: true`). The comment tool applies the appropriate GitHub label as a side effect.
15
+ 3. **Route** — Based on the verdict label:
16
+ - `loreli:low-risk` — PR passes through to `ReviewWorkflow.scan()` for normal reviewer dispatch.
17
+ - `loreli:medium-risk` — PR passes through with risk context attached to the reviewer prompt.
18
+ - `loreli:critical-risk` — PR is escalated to HITL. No reviewer is dispatched.
19
+
20
+ The contract between risk and review is **GitHub labels** — visible, auditable, and filterable. No shared in-memory state is needed.
21
+
22
+ ## API Reference
23
+
24
+ ### `RiskWorkflow` (extends Workflow)
25
+
26
+ ```js
27
+ import { RiskWorkflow } from 'loreli/risk';
28
+
29
+ const risk = new RiskWorkflow(orchestrator, hub);
30
+ ```
31
+
32
+ #### Static Properties
33
+
34
+ | Property | Value | Description |
35
+ |----------|-------|-------------|
36
+ | `role` | `'risk'` | Agent role this workflow manages |
37
+ | `template` | `prompts/risk.md` | Mustache template for risk assessment prompts |
38
+
39
+ ### Methods
40
+
41
+ #### `risk.assess(repo)` → Promise\<Array\<{pr, agent}\>\>
42
+
43
+ Scan for new PRs from action agents and dispatch risk agents. Skips PRs that:
44
+ - Already have a risk label (previously assessed)
45
+ - Are currently being assessed (`_assessing` map)
46
+ - Were already assessed (`_assessed` set)
47
+
48
+ Returns early with an empty array when `review.skipRiskAssessment` is `true`.
49
+
50
+ When enlisting a risk agent fails, the PR is marked as assessed so `ReviewWorkflow.scan()` can proceed without the risk gate blocking it.
51
+
52
+ #### `risk.verdict(repo)` → Promise\<Array\<{pr, level}\>\>
53
+
54
+ Check for risk verdicts on PRs being assessed. For each PR in `_assessing`:
55
+ - Looks for a comment with the `risk` marker
56
+ - Parses the `level` field (`LOW`, `MEDIUM`, `CRITICAL`)
57
+ - Kills the risk agent
58
+ - Moves the PR from `_assessing` to `_assessed`
59
+
60
+ If the stall timeout is exceeded without a verdict, the PR is marked as assessed with level `TIMEOUT`.
61
+
62
+ #### `risk.reap(repo)` → Promise\<void\>
63
+
64
+ Clean up stale risk assessments. Risk agents that die without posting a verdict leave PRs stuck in `_assessing`. This handler checks for dead agents (no longer in the orchestrator's agents map), marks their PRs as assessed, and applies a fallback `loreli:low-risk` label so `ReviewWorkflow.scan()` can proceed. Without the fallback label, the PR would be assessed in memory but unlabeled on GitHub — permanently skipped by the review label gate.
65
+
66
+ #### `risk.closesIssue(body)` → number|null
67
+
68
+ Extract the first "Closes #N" or "Fixes #N" issue number from a PR body.
69
+
70
+ #### `risk.objective(repo, childNumber)` → Promise\<string\>
71
+
72
+ Resolve the planning objective from the parent issue by finding the parent via `loreli:parent` label and sub-issue linkage.
73
+
74
+ ### Reactor Handlers
75
+
76
+ ```js
77
+ risk.reactor()
78
+ // → { 'risk-hydrate': fn, assess: fn, verdict: fn, 'risk-reap': fn }
79
+ ```
80
+
81
+ These must be registered **before** review handlers in the orchestrator so labels are applied before `scan()` checks for them.
82
+
83
+ ### Internal State
84
+
85
+ | Property | Type | Description |
86
+ |----------|------|-------------|
87
+ | `_assessing` | `Map<number, {riskAgent, dispatchedAt}>` | PRs currently awaiting a risk verdict |
88
+ | `_assessed` | `Set<number>` | PRs that have completed risk assessment |
89
+
90
+ ## Configuration
91
+
92
+ | Key | Type | Default | Description |
93
+ |-----|------|---------|-------------|
94
+ | `review.skipRiskAssessment` | `boolean` | `false` | When `true`, disables the risk workflow entirely. `assess()` returns early and `ReviewWorkflow.scan()` skips the label gate. |
95
+
96
+ ## Prompt Template
97
+
98
+ The risk prompt (`prompts/risk.md`) provides the risk agent with:
99
+ - The original planning objective
100
+ - The linked issue body
101
+ - PR metadata (number, title, branch, author)
102
+ - File change stats (filename, status, additions, deletions)
103
+ - Unified diff
104
+
105
+ The agent posts its verdict using the `comment` MCP tool with `risk: true` and the appropriate `level`. The comment tool applies the `loreli:{level}-risk` label automatically.
106
+
107
+ ## Errors
108
+
109
+ | Error | When | Resolution |
110
+ |-------|------|------------|
111
+ | Enlist failure | No opposing-provider backend available | PR marked as assessed; review proceeds without risk gate |
112
+ | Dispatch failure | Context gathering (diff, files, issue) fails | Risk agent killed; PR marked as assessed |
113
+ | Stall timeout | Risk agent doesn't respond within stall timeout | PR marked as assessed with `TIMEOUT` level |
114
+
115
+ ## Scope Boundary
116
+
117
+ **In scope**: Risk agent dispatch, verdict parsing, label routing, stale assessment cleanup, objective resolution, issue linkage parsing.
118
+
119
+ **Out of scope**: Reviewer dispatch (review package), label application (comment tool in mcp package), HITL escalation mechanics (review package).
@@ -0,0 +1,428 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { Workflow } from 'loreli/workflow';
4
+ import { logger } from 'loreli/log';
5
+ import { mark, has, parse } from 'loreli/marker';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const log = logger('risk');
9
+
10
+ /**
11
+ * Pattern matching GitHub's issue-closing keywords in PR bodies.
12
+ * Captures the issue number from "Closes #N", "Fixes #N", etc.
13
+ *
14
+ * @type {RegExp}
15
+ */
16
+ const CLOSES_RE = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/i;
17
+
18
+ /**
19
+ * Risk assessment workflow for Loreli's orchestration pipeline.
20
+ *
21
+ * Manages risk agents — dispatching them for new PRs, reading their
22
+ * verdicts, applying risk labels, and routing CRITICAL PRs to HITL.
23
+ * Runs before ReviewWorkflow in the reactor chain so labels are
24
+ * applied before scan() checks for them.
25
+ *
26
+ * The contract with ReviewWorkflow is GitHub labels:
27
+ * - `loreli:low-risk` — PR cleared for normal review
28
+ * - `loreli:medium-risk` — PR cleared with risk context warning
29
+ * - `loreli:critical-risk` — PR escalated to HITL, no reviewer dispatched
30
+ *
31
+ * @extends Workflow
32
+ */
33
+ export class RiskWorkflow extends Workflow {
34
+ /** @type {string} Agent role this workflow manages. */
35
+ static role = 'risk';
36
+
37
+ /** @type {string} Mustache template path for risk prompts. */
38
+ static template = join(__dirname, '..', 'prompts', 'risk.md');
39
+
40
+ /**
41
+ * PRs awaiting risk assessment before reviewer dispatch.
42
+ * Keys are PR numbers, values are { riskAgent, dispatchedAt }.
43
+ *
44
+ * @type {Map<number, {riskAgent: string, dispatchedAt: number}>}
45
+ */
46
+ _assessing = new Map();
47
+
48
+ /**
49
+ * PRs that have completed risk assessment (verdict received or timed out).
50
+ * Prevents assess() from re-dispatching risk agents for already-handled PRs.
51
+ *
52
+ * @type {Set<number>}
53
+ */
54
+ _assessed = new Set();
55
+
56
+ /**
57
+ * Report risk demand: how many PRs need risk assessment vs how many
58
+ * risk agents are active. Uses hydrated state so no extra API calls.
59
+ *
60
+ * @param {string} repo - Repository in "owner/name" format.
61
+ * @returns {Promise<{workload: number, supply: number, deficit: number}>}
62
+ */
63
+ async demand(repo) {
64
+ const skip = this.orchestrator.cfg?.get?.('review.skipRiskAssessment') ?? false;
65
+ if (skip) return { workload: 0, supply: 0, deficit: 0 };
66
+
67
+ const prs = await this.hub.pulls(repo, { state: 'open' });
68
+
69
+ let workload = 0;
70
+ for (const pr of prs) {
71
+ if (this._assessing.has(pr.number)) continue;
72
+ if (this._assessed.has(pr.number)) continue;
73
+
74
+ const hasRiskLabel = pr.labels?.some(function isRisk(l) {
75
+ const name = l.name ?? l;
76
+ return name.endsWith('-risk') && name.startsWith('loreli:');
77
+ });
78
+ if (hasRiskLabel) continue;
79
+
80
+ workload++;
81
+ }
82
+
83
+ const supply = this._assessing.size;
84
+
85
+ return { workload, supply, deficit: Math.max(0, workload - supply) };
86
+ }
87
+
88
+ /**
89
+ * Register reactor handlers for the orchestrator's tick loop.
90
+ * Hydrate → Assess → Verdict → Reap runs sequentially on each tick,
91
+ * before ReviewWorkflow's scan() so labels are applied first.
92
+ *
93
+ * @returns {Record<string, function>} Handler map.
94
+ */
95
+ reactor() {
96
+ const self = this;
97
+ return {
98
+ async 'risk-hydrate'(repo) { await self.hydrate(repo); },
99
+ async assess(repo) { await self.assess(repo); },
100
+ async verdict(repo) { await self.verdict(repo); },
101
+ async 'risk-reap'(repo) { await self.reap(repo); }
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Rehydrate in-memory risk state from GitHub artifacts.
107
+ *
108
+ * Called at the start of each reactor tick so that PRs assessed or
109
+ * being assessed by other participants are visible to verdict() and
110
+ * reap(). Without this, a process that restarts (or a second
111
+ * participant) would miss PRs already in the assessment pipeline.
112
+ *
113
+ * Derives state from two sources:
114
+ * - `loreli:*-risk` labels → marks PR as assessed
115
+ * - `risk-claim` marker comments → marks PR as mid-assessment
116
+ *
117
+ * @param {string} repo - Repository in "owner/name" format.
118
+ * @returns {Promise<void>}
119
+ */
120
+ async hydrate(repo) {
121
+ if (!this.hub) return;
122
+
123
+ const prs = await this.hub.pulls(repo, { state: 'open' });
124
+
125
+ for (const pr of prs) {
126
+ if (this._assessing.has(pr.number) || this._assessed.has(pr.number)) continue;
127
+
128
+ const hasRiskLabel = pr.labels?.some(function isRisk(l) {
129
+ const name = l.name ?? l;
130
+ return name.endsWith('-risk') && name.startsWith('loreli:');
131
+ });
132
+ if (hasRiskLabel) {
133
+ this._assessed.add(pr.number);
134
+ continue;
135
+ }
136
+
137
+ const comments = await this.hub.comments(repo, pr.number);
138
+ const riskClaim = comments.find(function isClaim(c) { return has(c.body, 'risk-claim'); });
139
+ if (!riskClaim) continue;
140
+
141
+ // Verdict already posted → assessed
142
+ const hasVerdict = comments.some(function isVerdict(c) { return has(c.body, 'risk'); });
143
+ if (hasVerdict) {
144
+ this._assessed.add(pr.number);
145
+ } else {
146
+ const agentName = parse(riskClaim.body, 'risk-claim')?.agent;
147
+ this._assessing.set(pr.number, {
148
+ riskAgent: agentName ?? 'unknown',
149
+ dispatchedAt: new Date(riskClaim.created ?? riskClaim.created_at).getTime()
150
+ });
151
+ }
152
+ }
153
+ }
154
+
155
+ // ── Helpers ───────────────────────────────────────────
156
+
157
+ /**
158
+ * Extract the first "Closes #N" or "Fixes #N" issue number from a body.
159
+ *
160
+ * @param {string} body - PR or issue body.
161
+ * @returns {number|null} Issue number, or null if not found.
162
+ */
163
+ closesIssue(body) {
164
+ if (!body) return null;
165
+ const m = body.match(CLOSES_RE);
166
+ return m ? parseInt(m[1], 10) : null;
167
+ }
168
+
169
+ /**
170
+ * Resolve the planning objective from the parent issue.
171
+ * Finds parent via loreli:parent label and sub-issue linkage.
172
+ *
173
+ * @param {string} repo - Repository in "owner/name" format.
174
+ * @param {number} childNumber - Child issue number (from Closes #N).
175
+ * @returns {Promise<string>} Objective string, or fallback.
176
+ */
177
+ async objective(repo, childNumber) {
178
+ const parents = await this.hub.issues(repo, { state: 'open', labels: ['loreli:parent'] });
179
+ const confirmed = parents.filter(function withMarker(i) { return has(i.body, 'parent'); });
180
+
181
+ for (const parent of confirmed) {
182
+ let subs;
183
+ try {
184
+ subs = await this.hub.subs(repo, parent.number);
185
+ } catch {
186
+ continue;
187
+ }
188
+ const isChild = subs.some(function match(s) { return s.number === childNumber; });
189
+ if (isChild) {
190
+ const data = parse(parent.body, 'parent');
191
+ return data?.objective ?? 'Planning objective';
192
+ }
193
+ }
194
+ return 'Planning objective';
195
+ }
196
+
197
+ // ── Assess ───────────────────────────────────────────
198
+
199
+ /**
200
+ * Scan for new PRs from action agents and dispatch risk agents.
201
+ * Only targets PRs that haven't been assessed yet and don't already
202
+ * have a risk label. Gathers diff, file stats, issue body, and
203
+ * planning objective as context for the risk agent.
204
+ *
205
+ * @param {string} repo - Repository in "owner/name" format.
206
+ * @returns {Promise<Array<{pr: number, agent: string}>>}
207
+ */
208
+ async assess(repo) {
209
+ const skip = this.orchestrator.cfg?.get?.('review.skipRiskAssessment') ?? false;
210
+ if (skip) return [];
211
+
212
+ const dispatched = [];
213
+ const prs = await this.hub.pulls(repo, { state: 'open' });
214
+ const actions = [...this.orchestrator.agents.values()]
215
+ .filter(function isAction(a) { return a.role === 'action'; });
216
+
217
+ for (const pr of prs) {
218
+ if (this._assessing.has(pr.number)) continue;
219
+ if (this._assessed.has(pr.number)) continue;
220
+
221
+ // Skip PRs that already have a risk label (from a previous assessment)
222
+ const hasRiskLabel = pr.labels?.some(function isRisk(l) {
223
+ const name = l.name ?? l;
224
+ return name.endsWith('-risk') && name.startsWith('loreli:');
225
+ });
226
+ if (hasRiskLabel) continue;
227
+
228
+ const agent = actions.find(function owns(a) {
229
+ return pr.head.startsWith(a.identity.name + '/');
230
+ });
231
+ if (!agent) continue;
232
+
233
+ // Verify a risk agent is available BEFORE claiming. Claiming
234
+ // without an available agent creates a deadlock: hydrate() sees the
235
+ // claim on the next tick, adds the PR to _assessing, and assess()
236
+ // skips it forever — but no agent was dispatched.
237
+ const riskAgents = this.agents().filter(function ready(a) {
238
+ return a.state === 'spawned' || a.state === 'working';
239
+ });
240
+ const assessing = this._assessing;
241
+ const riskAgent = riskAgents.find(function free(a) {
242
+ return ![...assessing.values()].some(function busy(t) { return t.riskAgent === a.identity.name; });
243
+ });
244
+ if (!riskAgent) {
245
+ log.debug(`assess: no risk agent available for PR #${pr.number} — waiting for scale()`);
246
+ continue;
247
+ }
248
+
249
+ // Check for existing risk-claim marker — another participant
250
+ // may have already dispatched a risk agent for this PR.
251
+ const prComments = await this.hub.comments(repo, pr.number);
252
+ if (prComments.some(function isClaimed(c) { return has(c.body, 'risk-claim'); })) continue;
253
+
254
+ // Optimistic claim: post a risk-claim marker as the risk agent, then verify first.
255
+ const visible = riskAgent.identity.claim?.() ?? `Claimed by **${riskAgent.identity.name}**`;
256
+ const won = await this.claimFirst(repo, pr.number, 'risk-claim', riskAgent.identity, riskAgent.role, visible);
257
+ if (!won) {
258
+ log.info(`assess: lost risk-claim race for PR #${pr.number} — skipping`);
259
+ continue;
260
+ }
261
+
262
+ try {
263
+ const childNum = this.closesIssue(pr.body);
264
+ let issueBody = pr.body;
265
+ if (childNum && typeof this.hub.issue === 'function') {
266
+ const linked = await this.hub.issue(repo, childNum);
267
+ issueBody = linked?.body ?? pr.body;
268
+ }
269
+ const obj = childNum && typeof this.hub.issues === 'function' && typeof this.hub.subs === 'function'
270
+ ? await this.objective(repo, childNum)
271
+ : 'Planning objective';
272
+
273
+ const files = await this.hub.files(repo, pr.number);
274
+ let diff = '';
275
+ if (typeof this.hub.diff === 'function') {
276
+ diff = await this.hub.diff(repo, pr.number);
277
+ }
278
+
279
+ await this.saveTask(riskAgent.identity.name, {
280
+ type: 'assess_risk',
281
+ pr: pr.number
282
+ });
283
+
284
+ const prompt = await this.render({
285
+ objective: obj,
286
+ issue: issueBody,
287
+ number: pr.number,
288
+ title: pr.title,
289
+ head: pr.head,
290
+ base: pr.base,
291
+ author: agent.identity.name,
292
+ authorProvider: agent.identity.provider,
293
+ files: files.map(function fmt(f) {
294
+ return {
295
+ filename: f.filename,
296
+ status: f.status,
297
+ additions: f.additions ?? 0,
298
+ deletions: f.deletions ?? 0
299
+ };
300
+ }),
301
+ diff
302
+ });
303
+
304
+ await riskAgent.send(prompt);
305
+ this.orchestrator.activity(riskAgent.identity.name);
306
+ this._assessing.set(pr.number, {
307
+ riskAgent: riskAgent.identity.name,
308
+ dispatchedAt: Date.now()
309
+ });
310
+ dispatched.push({ pr: pr.number, agent: riskAgent.identity.name });
311
+ log.info(`assess: dispatched ${riskAgent.identity.name} for PR #${pr.number}`);
312
+ } catch (err) {
313
+ log.warn(`assess: dispatch failed for PR #${pr.number}: ${err.message}`);
314
+ try { await this.orchestrator.kill(riskAgent.identity.name); } catch { /* best-effort */ }
315
+
316
+ // Apply fallback low-risk label so review's label gate passes
317
+ try {
318
+ const label = 'loreli:low-risk';
319
+ await this.hub.ensure(repo, [{ name: label, color: '0e8a16', description: 'Risk: LOW' }]);
320
+ await this.hub.label(repo, pr.number, [label]);
321
+ log.info(`assess: applied fallback ${label} to PR #${pr.number}`);
322
+ } catch { /* best-effort — review may still proceed on next scan */ }
323
+
324
+ this._assessed.add(pr.number);
325
+ }
326
+ }
327
+
328
+ return dispatched;
329
+ }
330
+
331
+ // ── Verdict ──────────────────────────────────────────
332
+
333
+ /**
334
+ * Check for risk verdicts on PRs being assessed. When a verdict
335
+ * comment is found, kills the risk agent and marks the PR as assessed.
336
+ * CRITICAL verdicts escalate to HITL via ReviewWorkflow.
337
+ *
338
+ * Labels are applied by the comment tool when the risk agent posts
339
+ * its verdict with `risk: true`. This handler just reads the result
340
+ * and routes accordingly.
341
+ *
342
+ * @param {string} repo - Repository in "owner/name" format.
343
+ * @returns {Promise<Array<{pr: number, level: string}>>}
344
+ */
345
+ async verdict(repo) {
346
+ if (!this._assessing.size) return [];
347
+
348
+ const results = [];
349
+ const stallTimeout = this.orchestrator.stallTimeout;
350
+ const now = Date.now();
351
+
352
+ for (const [prNum, tracking] of this._assessing) {
353
+ const elapsed = now - tracking.dispatchedAt;
354
+
355
+ // Stall timeout — risk agent didn't respond. Apply a fallback
356
+ // low-risk label so ReviewWorkflow.scan() can proceed — without
357
+ // a label the PR stays gated indefinitely.
358
+ if (elapsed > stallTimeout) {
359
+ log.warn(`verdict: risk assessment timed out for PR #${prNum} — applying low-risk fallback`);
360
+ this._assessing.delete(prNum);
361
+ this._assessed.add(prNum);
362
+ try { await this.orchestrator.kill(tracking.riskAgent); } catch { /* best-effort */ }
363
+
364
+ try {
365
+ const label = 'loreli:low-risk';
366
+ await this.hub.ensure(repo, [{ name: label, color: '0e8a16', description: 'Risk: LOW' }]);
367
+ await this.hub.label(repo, prNum, [label]);
368
+ log.info(`verdict: applied fallback ${label} to PR #${prNum}`);
369
+ } catch (err) {
370
+ log.warn(`verdict: failed to apply fallback label to PR #${prNum}: ${err.message}`);
371
+ }
372
+
373
+ results.push({ pr: prNum, level: 'TIMEOUT' });
374
+ continue;
375
+ }
376
+
377
+ const comments = await this.hub.comments(repo, prNum);
378
+ const riskComment = comments.find(function hasRisk(c) { return has(c.body, 'risk'); });
379
+ if (!riskComment) continue;
380
+
381
+ const data = parse(riskComment.body, 'risk');
382
+ const level = data?.level?.toUpperCase?.() ?? 'LOW';
383
+
384
+ try { await this.orchestrator.kill(tracking.riskAgent); } catch { /* best-effort */ }
385
+ this._assessing.delete(prNum);
386
+ this._assessed.add(prNum);
387
+
388
+ log.info(`verdict: PR #${prNum} assessed as ${level}`);
389
+ results.push({ pr: prNum, level });
390
+ }
391
+
392
+ return results;
393
+ }
394
+
395
+ // ── Reap ─────────────────────────────────────────────
396
+
397
+ /**
398
+ * Clean up stale risk assessments. Risk agents that die without
399
+ * posting a verdict leave PRs stuck in _assessing forever. This
400
+ * handler checks for dead agents and marks their PRs as assessed
401
+ * so ReviewWorkflow can proceed.
402
+ *
403
+ * @param {string} repo - Repository in "owner/name" format.
404
+ * @returns {Promise<void>}
405
+ */
406
+ async reap(repo) {
407
+ for (const [prNum, tracking] of this._assessing) {
408
+ const agent = this.orchestrator.agents.get(tracking.riskAgent);
409
+ if (!agent) {
410
+ log.info(`reap: risk agent ${tracking.riskAgent} gone — clearing PR #${prNum}`);
411
+ this._assessing.delete(prNum);
412
+ this._assessed.add(prNum);
413
+
414
+ // Apply fallback low-risk label so ReviewWorkflow's label gate
415
+ // can proceed. Without this, the PR enters a dead zone: assessed
416
+ // in memory but unlabeled on GitHub, permanently skipped by scan().
417
+ try {
418
+ const label = 'loreli:low-risk';
419
+ await this.hub.ensure(repo, [{ name: label, color: '0e8a16', description: 'Risk: LOW' }]);
420
+ await this.hub.label(repo, prNum, [label]);
421
+ log.info(`reap: applied fallback ${label} to PR #${prNum}`);
422
+ } catch (err) {
423
+ log.warn(`reap: failed to apply fallback label to PR #${prNum}: ${err.message}`);
424
+ }
425
+ }
426
+ }
427
+ }
428
+ }