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.
- package/LICENSE +1 -1
- package/README.md +670 -97
- package/bin/loreli.js +89 -0
- package/package.json +74 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/src/index.js +656 -0
- package/packages/agent/README.md +517 -0
- package/packages/agent/src/backends/claude.js +287 -0
- package/packages/agent/src/backends/codex.js +278 -0
- package/packages/agent/src/backends/cursor.js +294 -0
- package/packages/agent/src/backends/index.js +329 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +198 -0
- package/packages/agent/src/factory.js +119 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +141 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/config/README.md +833 -0
- package/packages/config/src/defaults.js +134 -0
- package/packages/config/src/index.js +192 -0
- package/packages/config/src/schema.js +273 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1558 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +237 -0
- package/packages/knowledge/src/index.js +412 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +279 -0
- package/packages/mcp/instructions.md +121 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +453 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +571 -0
- package/packages/mcp/src/tools/agents.js +429 -0
- package/packages/mcp/src/tools/context.js +199 -0
- package/packages/mcp/src/tools/github.js +1199 -0
- package/packages/mcp/src/tools/hitl.js +149 -0
- package/packages/mcp/src/tools/index.js +17 -0
- package/packages/mcp/src/tools/start.js +835 -0
- package/packages/mcp/src/tools/status.js +146 -0
- package/packages/mcp/src/tools/work.js +124 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1226 -0
- package/packages/planner/README.md +168 -0
- package/packages/planner/src/index.js +1166 -0
- package/packages/review/README.md +129 -0
- package/packages/review/src/index.js +1283 -0
- package/packages/risk/README.md +119 -0
- package/packages/risk/src/index.js +428 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +452 -0
- package/packages/workflow/README.md +313 -0
- package/packages/workflow/src/index.js +481 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1076 -0
- 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
|
+
}
|