loreli 0.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/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -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/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 +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -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 +1597 -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 +217 -0
- package/packages/knowledge/src/index.js +243 -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 +323 -0
- package/packages/mcp/instructions.md +126 -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 +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- 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 +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- 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 +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -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 +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- package/index.js +0 -8
|
@@ -0,0 +1,439 @@
|
|
|
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
|
+
* - `loreli:risk-unassessed` — assessment failed; escalated to HITL (fail-safe)
|
|
31
|
+
*
|
|
32
|
+
* @extends Workflow
|
|
33
|
+
*/
|
|
34
|
+
export class RiskWorkflow extends Workflow {
|
|
35
|
+
/** @type {string} Agent role this workflow manages. */
|
|
36
|
+
static role = 'risk';
|
|
37
|
+
|
|
38
|
+
/** @type {string} Mustache template path for risk prompts. */
|
|
39
|
+
static template = join(__dirname, '..', 'prompts', 'risk.md');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* PRs awaiting risk assessment before reviewer dispatch.
|
|
43
|
+
* Keys are PR numbers, values are { riskAgent, dispatchedAt }.
|
|
44
|
+
*
|
|
45
|
+
* @type {Map<number, {riskAgent: string, dispatchedAt: number}>}
|
|
46
|
+
*/
|
|
47
|
+
_assessing = new Map();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PRs that have completed risk assessment (verdict received or timed out).
|
|
51
|
+
* Prevents assess() from re-dispatching risk agents for already-handled PRs.
|
|
52
|
+
*
|
|
53
|
+
* @type {Set<number>}
|
|
54
|
+
*/
|
|
55
|
+
_assessed = new Set();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Report risk demand: how many PRs need risk assessment vs how many
|
|
59
|
+
* risk agents are active. Uses hydrated state so no extra API calls.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
62
|
+
* @returns {Promise<{workload: number, supply: number, deficit: number}>}
|
|
63
|
+
*/
|
|
64
|
+
async demand(repo) {
|
|
65
|
+
const skip = this.orchestrator.cfg?.get?.('workflows.risk.skip') ?? false;
|
|
66
|
+
if (skip) return { workload: 0, supply: 0, deficit: 0 };
|
|
67
|
+
|
|
68
|
+
const prs = await this.hub.pulls(repo, { state: 'open' });
|
|
69
|
+
|
|
70
|
+
let workload = 0;
|
|
71
|
+
for (const pr of prs) {
|
|
72
|
+
if (this._assessing.has(pr.number)) continue;
|
|
73
|
+
if (this._assessed.has(pr.number)) continue;
|
|
74
|
+
|
|
75
|
+
const hasRiskLabel = pr.labels?.some(function isRisk(l) {
|
|
76
|
+
const name = l.name ?? l;
|
|
77
|
+
return (name.endsWith('-risk') || name === 'loreli:risk-unassessed') && name.startsWith('loreli:');
|
|
78
|
+
});
|
|
79
|
+
if (hasRiskLabel) continue;
|
|
80
|
+
|
|
81
|
+
workload++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const supply = this._assessing.size;
|
|
85
|
+
|
|
86
|
+
return { workload, supply, deficit: Math.max(0, workload - supply) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register reactor handlers for the orchestrator's tick loop.
|
|
91
|
+
* Hydrate → Assess → Verdict → Reap runs sequentially on each tick,
|
|
92
|
+
* before ReviewWorkflow's scan() so labels are applied first.
|
|
93
|
+
*
|
|
94
|
+
* @returns {Record<string, function>} Handler map.
|
|
95
|
+
*/
|
|
96
|
+
reactor() {
|
|
97
|
+
const self = this;
|
|
98
|
+
return {
|
|
99
|
+
async 'risk-hydrate'(repo) { await self.hydrate(repo); },
|
|
100
|
+
async assess(repo) { await self.assess(repo); },
|
|
101
|
+
async verdict(repo) { await self.verdict(repo); },
|
|
102
|
+
async 'risk-reap'(repo) { await self.reap(repo); }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rehydrate in-memory risk state from GitHub artifacts.
|
|
108
|
+
*
|
|
109
|
+
* Called at the start of each reactor tick so that PRs assessed or
|
|
110
|
+
* being assessed by other participants are visible to verdict() and
|
|
111
|
+
* reap(). Without this, a process that restarts (or a second
|
|
112
|
+
* participant) would miss PRs already in the assessment pipeline.
|
|
113
|
+
*
|
|
114
|
+
* Derives state from two sources:
|
|
115
|
+
* - `loreli:*-risk` labels → marks PR as assessed
|
|
116
|
+
* - `risk-claim` marker comments → marks PR as mid-assessment
|
|
117
|
+
*
|
|
118
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
119
|
+
* @returns {Promise<void>}
|
|
120
|
+
*/
|
|
121
|
+
async hydrate(repo) {
|
|
122
|
+
if (!this.hub) return;
|
|
123
|
+
|
|
124
|
+
const prs = await this.hub.pulls(repo, { state: 'open' });
|
|
125
|
+
|
|
126
|
+
for (const pr of prs) {
|
|
127
|
+
if (this._assessing.has(pr.number) || this._assessed.has(pr.number)) continue;
|
|
128
|
+
|
|
129
|
+
const hasRiskLabel = pr.labels?.some(function isRisk(l) {
|
|
130
|
+
const name = l.name ?? l;
|
|
131
|
+
return (name.endsWith('-risk') || name === 'loreli:risk-unassessed') && name.startsWith('loreli:');
|
|
132
|
+
});
|
|
133
|
+
if (hasRiskLabel) {
|
|
134
|
+
this._assessed.add(pr.number);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const comments = await this.hub.comments(repo, pr.number);
|
|
139
|
+
const riskClaim = comments.find(function isClaim(c) { return has(c.body, 'risk-claim'); });
|
|
140
|
+
if (!riskClaim) continue;
|
|
141
|
+
|
|
142
|
+
// Verdict already posted → assessed
|
|
143
|
+
const hasVerdict = comments.some(function isVerdict(c) { return has(c.body, 'risk'); });
|
|
144
|
+
if (hasVerdict) {
|
|
145
|
+
this._assessed.add(pr.number);
|
|
146
|
+
} else {
|
|
147
|
+
const agentName = parse(riskClaim.body, 'risk-claim')?.agent;
|
|
148
|
+
this._assessing.set(pr.number, {
|
|
149
|
+
riskAgent: agentName ?? 'unknown',
|
|
150
|
+
dispatchedAt: new Date(riskClaim.created ?? riskClaim.created_at).getTime()
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Helpers ───────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Extract the first "Closes #N" or "Fixes #N" issue number from a body.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} body - PR or issue body.
|
|
162
|
+
* @returns {number|null} Issue number, or null if not found.
|
|
163
|
+
*/
|
|
164
|
+
closesIssue(body) {
|
|
165
|
+
if (!body) return null;
|
|
166
|
+
const m = body.match(CLOSES_RE);
|
|
167
|
+
return m ? parseInt(m[1], 10) : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the planning objective from the parent issue.
|
|
172
|
+
* Finds parent via loreli:parent label and sub-issue linkage.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
175
|
+
* @param {number} childNumber - Child issue number (from Closes #N).
|
|
176
|
+
* @returns {Promise<string>} Objective string, or fallback.
|
|
177
|
+
*/
|
|
178
|
+
async objective(repo, childNumber) {
|
|
179
|
+
const parents = await this.hub.issues(repo, { state: 'open', labels: ['loreli:parent'] });
|
|
180
|
+
const confirmed = parents.filter(function withMarker(i) { return has(i.body, 'parent'); });
|
|
181
|
+
|
|
182
|
+
for (const parent of confirmed) {
|
|
183
|
+
let subs;
|
|
184
|
+
try {
|
|
185
|
+
subs = await this.hub.subs(repo, parent.number);
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const isChild = subs.some(function match(s) { return s.number === childNumber; });
|
|
190
|
+
if (isChild) {
|
|
191
|
+
const data = parse(parent.body, 'parent');
|
|
192
|
+
return data?.objective ?? 'Planning objective';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return 'Planning objective';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Assess ───────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Scan for new PRs from action agents and dispatch risk agents.
|
|
202
|
+
* Only targets PRs that haven't been assessed yet and don't already
|
|
203
|
+
* have a risk label. Gathers diff, file stats, issue body, and
|
|
204
|
+
* planning objective as context for the risk agent.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
207
|
+
* @returns {Promise<Array<{pr: number, agent: string}>>}
|
|
208
|
+
*/
|
|
209
|
+
async assess(repo) {
|
|
210
|
+
const skip = this.orchestrator.cfg?.get?.('workflows.risk.skip') ?? false;
|
|
211
|
+
if (skip) return [];
|
|
212
|
+
|
|
213
|
+
const dispatched = [];
|
|
214
|
+
const prs = await this.hub.pulls(repo, { state: 'open' });
|
|
215
|
+
const actions = [...this.orchestrator.agents.values()]
|
|
216
|
+
.filter(function isAction(a) { return a.role === 'action'; });
|
|
217
|
+
|
|
218
|
+
for (const pr of prs) {
|
|
219
|
+
if (this._assessing.has(pr.number)) continue;
|
|
220
|
+
if (this._assessed.has(pr.number)) continue;
|
|
221
|
+
|
|
222
|
+
// Skip PRs that already have a risk label (from a previous assessment)
|
|
223
|
+
// or a risk-unassessed label (from a failed assessment)
|
|
224
|
+
const hasRiskLabel = pr.labels?.some(function isRisk(l) {
|
|
225
|
+
const name = l.name ?? l;
|
|
226
|
+
return (name.endsWith('-risk') || name === 'loreli:risk-unassessed') && name.startsWith('loreli:');
|
|
227
|
+
});
|
|
228
|
+
if (hasRiskLabel) continue;
|
|
229
|
+
|
|
230
|
+
const agent = actions.find(function owns(a) {
|
|
231
|
+
return pr.head.startsWith(a.identity.name + '/');
|
|
232
|
+
});
|
|
233
|
+
if (!agent) continue;
|
|
234
|
+
|
|
235
|
+
// Verify a risk agent is available BEFORE claiming. Claiming
|
|
236
|
+
// without an available agent creates a deadlock: hydrate() sees the
|
|
237
|
+
// claim on the next tick, adds the PR to _assessing, and assess()
|
|
238
|
+
// skips it forever — but no agent was dispatched.
|
|
239
|
+
const riskAgents = this.agents().filter(function ready(a) {
|
|
240
|
+
return a.state === 'spawned' || a.state === 'working';
|
|
241
|
+
});
|
|
242
|
+
const assessing = this._assessing;
|
|
243
|
+
const riskAgent = riskAgents.find(function free(a) {
|
|
244
|
+
return ![...assessing.values()].some(function busy(t) { return t.riskAgent === a.identity.name; });
|
|
245
|
+
});
|
|
246
|
+
if (!riskAgent) {
|
|
247
|
+
log.debug(`assess: no risk agent available for PR #${pr.number} — waiting for scale()`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Check for existing risk-claim marker — another participant
|
|
252
|
+
// may have already dispatched a risk agent for this PR.
|
|
253
|
+
const prComments = await this.hub.comments(repo, pr.number);
|
|
254
|
+
if (prComments.some(function isClaimed(c) { return has(c.body, 'risk-claim'); })) continue;
|
|
255
|
+
|
|
256
|
+
// Optimistic claim: post a risk-claim marker as the risk agent, then verify first.
|
|
257
|
+
const visible = riskAgent.identity.claim?.() ?? `Claimed by **${riskAgent.identity.name}**`;
|
|
258
|
+
const won = await this.claimFirst(repo, pr.number, 'risk-claim', riskAgent.identity, riskAgent.role, visible);
|
|
259
|
+
if (!won) {
|
|
260
|
+
log.info(`assess: lost risk-claim race for PR #${pr.number} — skipping`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const childNum = this.closesIssue(pr.body);
|
|
266
|
+
let issueBody = pr.body;
|
|
267
|
+
if (childNum && typeof this.hub.issue === 'function') {
|
|
268
|
+
const linked = await this.hub.issue(repo, childNum);
|
|
269
|
+
issueBody = linked?.body ?? pr.body;
|
|
270
|
+
}
|
|
271
|
+
const obj = childNum && typeof this.hub.issues === 'function' && typeof this.hub.subs === 'function'
|
|
272
|
+
? await this.objective(repo, childNum)
|
|
273
|
+
: 'Planning objective';
|
|
274
|
+
|
|
275
|
+
const files = await this.hub.files(repo, pr.number);
|
|
276
|
+
let diff = '';
|
|
277
|
+
if (typeof this.hub.diff === 'function') {
|
|
278
|
+
diff = await this.hub.diff(repo, pr.number);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await this.saveTask(riskAgent.identity.name, {
|
|
282
|
+
type: 'assess_risk',
|
|
283
|
+
pr: pr.number
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const mapped = files.map(function fmt(f) {
|
|
287
|
+
return {
|
|
288
|
+
filename: f.filename,
|
|
289
|
+
status: f.status,
|
|
290
|
+
additions: f.additions ?? 0,
|
|
291
|
+
deletions: f.deletions ?? 0
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const stats = {
|
|
296
|
+
total: mapped.length,
|
|
297
|
+
additions: mapped.reduce(function sum(s, f) { return s + f.additions; }, 0),
|
|
298
|
+
deletions: mapped.reduce(function sum(s, f) { return s + f.deletions; }, 0)
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const prompt = await this.render({
|
|
302
|
+
objective: obj,
|
|
303
|
+
issue: issueBody,
|
|
304
|
+
number: pr.number,
|
|
305
|
+
title: pr.title,
|
|
306
|
+
head: pr.head,
|
|
307
|
+
base: pr.base,
|
|
308
|
+
author: agent.identity.name,
|
|
309
|
+
authorProvider: agent.identity.provider,
|
|
310
|
+
files: mapped,
|
|
311
|
+
stats,
|
|
312
|
+
diff
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await riskAgent.send(prompt);
|
|
316
|
+
this.orchestrator.activity(riskAgent.identity.name);
|
|
317
|
+
this._assessing.set(pr.number, {
|
|
318
|
+
riskAgent: riskAgent.identity.name,
|
|
319
|
+
dispatchedAt: Date.now()
|
|
320
|
+
});
|
|
321
|
+
dispatched.push({ pr: pr.number, agent: riskAgent.identity.name });
|
|
322
|
+
log.info(`assess: dispatched ${riskAgent.identity.name} for PR #${pr.number}`);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
log.warn(`assess: dispatch failed for PR #${pr.number}: ${err.message}`);
|
|
325
|
+
try { await this.orchestrator.kill(riskAgent.identity.name); } catch { /* best-effort */ }
|
|
326
|
+
|
|
327
|
+
// Fail-safe: unassessed PRs must not pass as low-risk.
|
|
328
|
+
// The risk-unassessed label routes to HITL in ReviewWorkflow.scan().
|
|
329
|
+
try {
|
|
330
|
+
const label = 'loreli:risk-unassessed';
|
|
331
|
+
await this.hub.ensure(repo, [{ name: label, color: 'e11d48', description: 'Risk: assessment failed — requires human review' }]);
|
|
332
|
+
await this.hub.label(repo, pr.number, [label]);
|
|
333
|
+
log.info(`assess: applied fail-safe ${label} to PR #${pr.number}`);
|
|
334
|
+
} catch { /* best-effort */ }
|
|
335
|
+
|
|
336
|
+
this._assessed.add(pr.number);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return dispatched;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Verdict ──────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Check for risk verdicts on PRs being assessed. When a verdict
|
|
347
|
+
* comment is found, kills the risk agent and marks the PR as assessed.
|
|
348
|
+
* CRITICAL verdicts escalate to HITL via ReviewWorkflow.
|
|
349
|
+
*
|
|
350
|
+
* Labels are applied by the comment tool when the risk agent posts
|
|
351
|
+
* its verdict with `risk: true`. This handler just reads the result
|
|
352
|
+
* and routes accordingly.
|
|
353
|
+
*
|
|
354
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
355
|
+
* @returns {Promise<Array<{pr: number, level: string}>>}
|
|
356
|
+
*/
|
|
357
|
+
async verdict(repo) {
|
|
358
|
+
if (!this._assessing.size) return [];
|
|
359
|
+
|
|
360
|
+
const results = [];
|
|
361
|
+
const stallTimeout = this.orchestrator.stallTimeout;
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
|
|
364
|
+
for (const [prNum, tracking] of this._assessing) {
|
|
365
|
+
const elapsed = now - tracking.dispatchedAt;
|
|
366
|
+
|
|
367
|
+
// Stall timeout — risk agent didn't respond. Fail-safe: apply
|
|
368
|
+
// risk-unassessed label so ReviewWorkflow escalates to HITL
|
|
369
|
+
// instead of silently passing as low-risk.
|
|
370
|
+
if (elapsed > stallTimeout) {
|
|
371
|
+
log.warn(`verdict: risk assessment timed out for PR #${prNum} — applying fail-safe label`);
|
|
372
|
+
this._assessing.delete(prNum);
|
|
373
|
+
this._assessed.add(prNum);
|
|
374
|
+
try { await this.orchestrator.kill(tracking.riskAgent); } catch { /* best-effort */ }
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const label = 'loreli:risk-unassessed';
|
|
378
|
+
await this.hub.ensure(repo, [{ name: label, color: 'e11d48', description: 'Risk: assessment failed — requires human review' }]);
|
|
379
|
+
await this.hub.label(repo, prNum, [label]);
|
|
380
|
+
log.info(`verdict: applied fail-safe ${label} to PR #${prNum}`);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
log.warn(`verdict: failed to apply fail-safe label to PR #${prNum}: ${err.message}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
results.push({ pr: prNum, level: 'TIMEOUT' });
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const comments = await this.hub.comments(repo, prNum);
|
|
390
|
+
const riskComment = comments.find(function hasRisk(c) { return has(c.body, 'risk'); });
|
|
391
|
+
if (!riskComment) continue;
|
|
392
|
+
|
|
393
|
+
const data = parse(riskComment.body, 'risk');
|
|
394
|
+
const level = data?.level?.toUpperCase?.() ?? 'LOW';
|
|
395
|
+
|
|
396
|
+
try { await this.orchestrator.kill(tracking.riskAgent); } catch { /* best-effort */ }
|
|
397
|
+
this._assessing.delete(prNum);
|
|
398
|
+
this._assessed.add(prNum);
|
|
399
|
+
|
|
400
|
+
log.info(`verdict: PR #${prNum} assessed as ${level}`);
|
|
401
|
+
results.push({ pr: prNum, level });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return results;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Reap ─────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Clean up stale risk assessments. Risk agents that die without
|
|
411
|
+
* posting a verdict leave PRs stuck in _assessing forever. This
|
|
412
|
+
* handler checks for dead agents and marks their PRs as assessed
|
|
413
|
+
* so ReviewWorkflow can proceed.
|
|
414
|
+
*
|
|
415
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
416
|
+
* @returns {Promise<void>}
|
|
417
|
+
*/
|
|
418
|
+
async reap(repo) {
|
|
419
|
+
for (const [prNum, tracking] of this._assessing) {
|
|
420
|
+
const agent = this.orchestrator.agents.get(tracking.riskAgent);
|
|
421
|
+
if (!agent) {
|
|
422
|
+
log.info(`reap: risk agent ${tracking.riskAgent} gone — clearing PR #${prNum}`);
|
|
423
|
+
this._assessing.delete(prNum);
|
|
424
|
+
this._assessed.add(prNum);
|
|
425
|
+
|
|
426
|
+
// Fail-safe: unassessed PRs must not pass as low-risk.
|
|
427
|
+
// The risk-unassessed label routes to HITL in ReviewWorkflow.scan().
|
|
428
|
+
try {
|
|
429
|
+
const label = 'loreli:risk-unassessed';
|
|
430
|
+
await this.hub.ensure(repo, [{ name: label, color: 'e11d48', description: 'Risk: assessment failed — requires human review' }]);
|
|
431
|
+
await this.hub.label(repo, prNum, [label]);
|
|
432
|
+
log.info(`reap: applied fail-safe ${label} to PR #${prNum}`);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
log.warn(`reap: failed to apply fail-safe label to PR #${prNum}: ${err.message}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# loreli/session
|
|
2
|
+
|
|
3
|
+
Persistent session storage for detached Loreli agents.
|
|
4
|
+
|
|
5
|
+
This package manages the `~/.loreli/` state directory where agent sessions and their runtime data are persisted. It exists as a standalone package because session persistence is needed by multiple packages (agent, orchestrator, MCP server) — centralizing it avoids duplication and provides a single, generic solution for `~/.loreli` state management.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add loreli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Directory Layout
|
|
14
|
+
|
|
15
|
+
Storage organizes state into per-session directories under `~/.loreli/sessions/`:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
~/.loreli/
|
|
19
|
+
sessions/
|
|
20
|
+
<session-id>/
|
|
21
|
+
config.json # Session metadata (id, created, repo, etc.)
|
|
22
|
+
agents/
|
|
23
|
+
optimus-0.json # Agent runtime state
|
|
24
|
+
megatron-0.json
|
|
25
|
+
logs/ # Session-scoped log files
|
|
26
|
+
registry.json # Identity registry snapshot
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## API Reference
|
|
30
|
+
|
|
31
|
+
### `new Storage(opts?)`
|
|
32
|
+
|
|
33
|
+
Create a new Storage instance.
|
|
34
|
+
|
|
35
|
+
- `opts.home` `{string}` — Override the base directory. Falls back to `LORELI_HOME` env var, then `~/.loreli`.
|
|
36
|
+
|
|
37
|
+
The following example shows the precedence order for resolving the home directory. Options take priority over environment variables, which take priority over the default.
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import { Storage } from 'loreli/session';
|
|
41
|
+
|
|
42
|
+
// Uses ~/.loreli by default
|
|
43
|
+
const storage = new Storage();
|
|
44
|
+
|
|
45
|
+
// Explicit override
|
|
46
|
+
const custom = new Storage({ home: '/tmp/test-loreli' });
|
|
47
|
+
|
|
48
|
+
// Environment variable fallback
|
|
49
|
+
// LORELI_HOME=/opt/loreli node app.js
|
|
50
|
+
const envStorage = new Storage(); // uses /opt/loreli
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### `storage.init(id, config?)` → `Promise<string>`
|
|
54
|
+
|
|
55
|
+
Initialize a session directory. Creates the `agents/` and `logs/` subdirectories and writes `config.json` with the session ID, creation timestamp, and any additional config fields. Returns the session ID.
|
|
56
|
+
|
|
57
|
+
Safe to call multiple times (idempotent) — existing directories are preserved. The `github.token` field is automatically stripped from the persisted `config.json` to prevent plaintext credential storage on disk.
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
const id = await storage.init('session-abc', { repo: 'owner/repo', theme: 'transformers' });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `storage.save(sessionId, agentName, data)` → `Promise<void>`
|
|
64
|
+
|
|
65
|
+
Save agent state to disk. Writes atomically (temp file + rename) to prevent corruption from crashes during write.
|
|
66
|
+
|
|
67
|
+
The following example shows saving an agent's runtime state, which is typically called during state transitions or periodic checkpoints.
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
await storage.save('session-abc', 'optimus-0', {
|
|
71
|
+
identity: { name: 'optimus-0', provider: 'openai' },
|
|
72
|
+
state: 'working',
|
|
73
|
+
paneId: '%3',
|
|
74
|
+
claimedIssue: 42
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `storage.load(sessionId, agentName)` → `Promise<object|null>`
|
|
79
|
+
|
|
80
|
+
Load agent state from disk. Returns `null` if the agent file does not exist.
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
const data = await storage.load('session-abc', 'optimus-0');
|
|
84
|
+
if (data) {
|
|
85
|
+
console.log(`Agent ${data.identity.name} was in state: ${data.state}`);
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### `storage.agents(sessionId)` → `Promise<string[]>`
|
|
90
|
+
|
|
91
|
+
List all agent names in a session (filenames without `.json` extension). Returns empty array for missing sessions.
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
const names = await storage.agents('session-abc');
|
|
95
|
+
// ['optimus-0', 'megatron-0']
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### `storage.sessions()` → `Promise<string[]>`
|
|
99
|
+
|
|
100
|
+
List all session IDs (directory names under `sessions/`). Returns empty array when no sessions exist.
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
const ids = await storage.sessions();
|
|
104
|
+
// ['session-abc', 'session-def']
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `storage.sessionDir(id)` → `string`
|
|
108
|
+
|
|
109
|
+
Get the absolute path to a session's directory. Synchronous.
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
storage.sessionDir('session-abc');
|
|
113
|
+
// '/Users/you/.loreli/sessions/session-abc'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `storage.remove(id)` → `Promise<void>`
|
|
117
|
+
|
|
118
|
+
Remove a single session and all its contents (agents, logs, config). Does not throw when the session does not exist.
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
await storage.remove('session-abc');
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `storage.prune(maxAge)` → `Promise<string[]>`
|
|
125
|
+
|
|
126
|
+
Remove sessions older than `maxAge` milliseconds. Reads each session's `config.json` for the `created` timestamp. Sessions with missing or unparseable config are treated as stale and pruned. Returns an array of pruned session IDs.
|
|
127
|
+
|
|
128
|
+
Called automatically at start when `cleanup.autoprune` is `true` (the default), using `cleanup.retention` as the max age.
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
const pruned = await storage.prune(12 * 60 * 60 * 1000); // 12 hours
|
|
132
|
+
// ['old-session-1', 'old-session-2']
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### `storage.sweep()` → `Promise<string[]>`
|
|
136
|
+
|
|
137
|
+
Remove known stray files from the loreli home root directory. Files like `mcp-ready` can end up at the root level when the MCP server's working directory happens to be `~/.loreli`. Returns an array of removed file names.
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
const swept = await storage.sweep();
|
|
141
|
+
// ['mcp-ready']
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `storage.atomic(filePath, content)` → `Promise<void>`
|
|
145
|
+
|
|
146
|
+
Write a file atomically using temp file + rename. Used internally by `save()` and `init()`, but exposed for custom state files that need crash-safe writes.
|
|
147
|
+
|
|
148
|
+
## Error Handling
|
|
149
|
+
|
|
150
|
+
| Scenario | Behavior |
|
|
151
|
+
|----------|----------|
|
|
152
|
+
| Session directory missing | `load()` and `agents()` return `null` / `[]` — no throw |
|
|
153
|
+
| No sessions directory | `sessions()` and `prune()` return `[]` — no throw |
|
|
154
|
+
| Missing session for `remove()` | Silent no-op |
|
|
155
|
+
| Missing/invalid `config.json` during prune | Session treated as stale and pruned |
|
|
156
|
+
| `github.token` in config | Automatically stripped during `init()` |
|
|
157
|
+
| File write interrupted | Atomic writes prevent partial files from appearing at the target path |
|
|
158
|
+
|
|
159
|
+
## Testing
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
node --test packages/session/test/index.test.js
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Tests use temporary directories and do not require tmux.
|