agent-lyceum 0.4.12 → 0.5.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.
@@ -1,467 +1,7 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { realInvoker } from "./adapters/index.js";
4
- import { briefOf, doneContract, parseSteps } from "./format.js";
5
- import { ProtectedGuard } from "./guard.js";
6
- import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes } from "./mailbox.js";
7
- import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
8
- import { isInside } from "./paths.js";
9
- import { outboxDir, ownsDirs } from "./policy.js";
10
- import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
11
- import { atomicWrite } from "./fs-util.js";
12
- import { assertWorktreeRunnable, collectAgentChanges, existingAgentWorkspace, integrateAgentChanges, prepareAgentWorkspace, resolveWorkspaceMode, snapshotBase } from "./worktree.js";
13
- import { bindRunProject, newRunId, newRunState, saveRunState } from "./run-store.js";
14
- import { taskMessageBody } from "./task.js";
1
+ import { RunSession } from "./run-session.js";
2
+ import { newRunId } from "./run-store.js";
15
3
  export { newRunId };
16
- export const RESULT_FILE = "result.md";
17
- /** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
18
- export const MAX_DONE_REJECTIONS = 2;
19
- /** Prompt for a repeat attempt: the earlier one failed and may have changed things already. */
20
- function retryPrompt(prompt, attempt, error) {
21
- return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
22
- }
4
+ export { MAX_DONE_REJECTIONS, RESULT_FILE } from "./run-session.js";
23
5
  export async function runTeam(opts) {
24
- const { task, resume, runDir } = opts;
25
- if (!task && !resume)
26
- throw new Error("runTeam needs a task or a run to resume");
27
- const invoke = opts.invoker ?? realInvoker;
28
- const say = opts.log ?? ((s) => console.log(s));
29
- const cfg = opts.project.dispatcher;
30
- const runId = path.basename(runDir);
31
- const layout = resume ? resume.mail_layout : "run";
32
- // Each run gets its own memory directory per agent and (new runs) its own mailboxes.
33
- const project = bindRunProject(opts.project, runDir, layout);
34
- // Parallel agents work in their own git worktrees; refuse early (before anything is written) when that cannot be done.
35
- const wsMode = resolveWorkspaceMode(cfg);
36
- if (wsMode === "worktree")
37
- assertWorktreeRunnable(project, { fresh: !resume });
38
- ensureProjectDirs(project);
39
- fs.mkdirSync(runDir, { recursive: true });
40
- const logFile = path.join(runDir, "log.jsonl");
41
- const log = (event, data = {}) => fs.appendFileSync(logFile, JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + "\n");
42
- let state;
43
- if (resume) {
44
- state = { ...resume, max_rounds: cfg.max_rounds, pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
45
- // A wake that was running when the process died never finished; keep it in the history as interrupted.
46
- for (const [agent, a] of Object.entries(resume.active ?? {})) {
47
- state.wakes.push({
48
- round: a.round,
49
- agent,
50
- at: new Date().toISOString(),
51
- duration_ms: Date.now() - Date.parse(a.since),
52
- ok: false,
53
- handling: a.handling,
54
- error: "interrupted",
55
- });
56
- }
57
- }
58
- else {
59
- const t = taskMessageBody(task);
60
- state = newRunState({
61
- run_id: runId,
62
- project: project.name,
63
- task_source: task.source,
64
- task_path: task.sourcePath,
65
- started_at: new Date().toISOString(),
66
- rounds: 0,
67
- max_rounds: cfg.max_rounds,
68
- pid: process.pid,
69
- task_summary: t.subject,
70
- mail_layout: layout,
71
- workspace_mode: wsMode,
72
- });
73
- const seed = parseSteps(task.content, false);
74
- if (seed.length)
75
- state.steps = seed;
76
- }
77
- const saveState = () => saveRunState(runDir, state);
78
- const guard = new ProtectedGuard(project);
79
- if (resume) {
80
- log("resume", { rounds: state.rounds, interrupted: Object.keys(resume.active ?? {}) });
81
- }
82
- else {
83
- const t = taskMessageBody(task);
84
- guard.saveSnapshot(path.join(runDir, "snapshots"));
85
- deliver(project, { from: "user", to: project.lead, type: "task", subject: t.subject, body: t.body });
86
- log("start", { task_source: task.source, bytes: task.bytes, inline: task.inline });
87
- }
88
- saveState();
89
- let endReason;
90
- let doneMessage;
91
- /** The lead's done file; it leaves the outbox only after the result and end state are stored. */
92
- let pendingDoneFile;
93
- let doneOutcome;
94
- let doneNote;
95
- let doneVerification;
96
- /** Inbox mail each agent in the current batch is working on; it is marked read only after its output is routed. */
97
- const claims = new Map();
98
- const journal = () => new RouteJournal(runDir);
99
- const noSessionNoted = new Set();
100
- /** Git worktrees of the non-lead agents in the batch being woken (empty in shared mode and for the lead). */
101
- let batchSpaces = {};
102
- /** The attempt that produced each batch member's output; committed together with its claim. */
103
- const attempts = new Map();
104
- const note = (text) => {
105
- (state.notes ??= []).push(text);
106
- log("note", { text });
107
- say(` ${text}`);
108
- };
109
- const pendingAgents = () => {
110
- const rows = Object.values(project.agents)
111
- .map((a) => ({ a, unread: listUnread(project, a.name) }))
112
- .filter((r) => r.unread.length > 0)
113
- .map((r) => ({ a: r.a, first: path.basename(r.unread[0].file) }));
114
- rows.sort((x, y) => x.first.localeCompare(y.first));
115
- return rows.map((r) => r.a);
116
- };
117
- const pickBatch = (pending) => {
118
- const lead = pending.find((a) => a.name === project.lead);
119
- if (lead || cfg.max_parallel <= 1)
120
- return [lead ?? pending[0]];
121
- const batch = [];
122
- const taken = [];
123
- for (const a of pending) {
124
- if (batch.length >= cfg.max_parallel)
125
- break;
126
- const mine = ownsDirs(project, a);
127
- const clash = mine.some((x) => taken.some((y) => isInside(x, y) || isInside(y, x)));
128
- if (clash || mine.length === 0) {
129
- if (batch.length === 0)
130
- batch.push(a);
131
- continue;
132
- }
133
- batch.push(a);
134
- taken.push(...mine);
135
- }
136
- return batch;
137
- };
138
- const wake = async (agent) => {
139
- const queue = listUnread(project, agent.name);
140
- if (queue.length === 0)
141
- return { ok: true };
142
- // Oldest message per wake-up (the lead takes a run of replies at once); the rest stay unread for later wake-ups.
143
- const unread = pickMessages(project, agent, queue);
144
- const claim = claimMessages(runDir, agent.name, unread);
145
- claims.set(agent.name, claim);
146
- const workDir = path.join(runDir, "agents", agent.name);
147
- const aproject = Object.keys(batchSpaces).length ? { ...project, workspaces: batchSpaces } : project;
148
- const base = {
149
- project: aproject,
150
- agent,
151
- workDir,
152
- workspace: batchSpaces[agent.name],
153
- systemPrompt: buildSystemPrompt(aproject, agent),
154
- userPrompt: buildUserPrompt(project, agent, queue, unread.length),
155
- timeoutSec: cfg.wake_timeout_sec,
156
- };
157
- let result;
158
- for (let attempt = 0; attempt <= cfg.retry; attempt++) {
159
- if (state.rounds >= cfg.max_rounds) {
160
- result = { ok: false, text: "", exitCode: null, timedOut: false, error: "round limit reached" };
161
- break;
162
- }
163
- const round = ++state.rounds;
164
- const started = Date.now();
165
- const att = beginAttempt(runDir, claim.id);
166
- say(`[round ${state.rounds}/${cfg.max_rounds}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
167
- const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
168
- const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body) }));
169
- state.active[agent.name] = { round, since: new Date(started).toISOString(), handling };
170
- saveState();
171
- result = await invoke({ ...base, signal: opts.signal, logDir: attemptLogDir(runDir, att.id), userPrompt: attempt ? retryPrompt(base.userPrompt, attempt, result?.error) : base.userPrompt, sessionId }).catch((e) => ({
172
- ok: false,
173
- text: "",
174
- exitCode: null,
175
- timedOut: false,
176
- error: e.message,
177
- }));
178
- if (result.sessionId && agent.resume)
179
- state.sessions[agent.name] = result.sessionId;
180
- if (result.outputTokens)
181
- state.output_tokens += result.outputTokens;
182
- delete state.active[agent.name];
183
- state.wakes.push({
184
- round,
185
- agent: agent.name,
186
- at: new Date().toISOString(),
187
- duration_ms: Date.now() - started,
188
- ok: result.ok,
189
- output_tokens: result.outputTokens,
190
- handling,
191
- error: result.error,
192
- });
193
- state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error };
194
- log("wake", {
195
- agent: agent.name,
196
- runtime: agent.runtime,
197
- attempt,
198
- ok: result.ok,
199
- exit_code: result.exitCode,
200
- timed_out: result.timedOut,
201
- duration_ms: Date.now() - started,
202
- messages: unread.map((m) => m.meta.id),
203
- error: result.error,
204
- });
205
- saveState();
206
- if (result.ok) {
207
- attempts.set(agent.name, finishAttempt(runDir, att.id, "output_ready"));
208
- if (agent.resume && !result.sessionId && !state.sessions[agent.name] && !noSessionNoted.has(agent.name)) {
209
- noSessionNoted.add(agent.name);
210
- note(`${agent.name} has resume: true but its ${agent.runtime} CLI reported no session id, so every wake-up starts a fresh session.`);
211
- }
212
- break;
213
- }
214
- if (result.cancelled) {
215
- // Stopped on purpose: not a failure to report. Its output is set aside and its input stays unread for the resume.
216
- const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
217
- finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
218
- abandonClaim(runDir, claim.id);
219
- claims.delete(agent.name);
220
- say(` ${agent.name} stopped (cancelled)`);
221
- return { ok: false, cancelled: true };
222
- }
223
- // A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
224
- const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
225
- finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
226
- say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
227
- note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
228
- `Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
229
- }
230
- if (result?.ok)
231
- markOutputReady(runDir, claim.id);
232
- if (!result?.ok) {
233
- if (agent.name === project.lead)
234
- return { ok: false };
235
- deliverOnce(project, journal(), `wakefail:${claim.id}`, {
236
- from: "dispatcher",
237
- to: project.lead,
238
- type: "failure",
239
- subject: `${agent.name} failed`,
240
- body: `Agent "${agent.name}" could not handle message(s) "${unread.map((m) => m.meta.subject).join('", "')}".\n\nError: ${result?.error ?? "unknown"}\n\nDecide whether to retry, reassign, or adjust the plan.`,
241
- });
242
- return { ok: false };
243
- }
244
- return { ok: true };
245
- };
246
- /**
247
- * A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
248
- * sees them. When that cannot be done, nothing is applied, the member's work stays on its branch, and the lead is told.
249
- */
250
- const integrateMember = (agent, ws, key) => {
251
- const changes = collectAgentChanges(ws, agent.owns);
252
- const r = integrateAgentChanges(project, changes);
253
- log("integrate", { agent: agent.name, status: r.status, files: changes.files.map((f) => `${f.status} ${f.path}`), ...(r.status === "blocked" ? { reason: r.reason, report: r.report } : {}) });
254
- const blocked = (state.blocked_integrations ?? []).filter((b) => b.agent !== agent.name);
255
- if (r.status === "integrated") {
256
- state.blocked_integrations = blocked;
257
- if (r.files.length)
258
- say(` ${agent.name}: ${r.files.length} changed file(s) brought into the repo`);
259
- return;
260
- }
261
- state.blocked_integrations = [...blocked, { agent: agent.name, branch: r.branch, reason: r.reason, report: r.report }];
262
- say(` ${agent.name}: changes NOT brought into the repo (${r.reason.split("\n")[0]})`);
263
- deliverOnce(project, journal(), `integration:${key}`, {
264
- from: "dispatcher",
265
- to: project.lead,
266
- type: "failure",
267
- subject: `${agent.name}'s changes could not be brought into the repo`,
268
- body: `${agent.name} finished, but its changes could not be brought into the repo, and nothing was applied.\n\n${r.reason}\n\n` +
269
- `Its work is kept: branch \`${r.branch}\`, worktree \`${r.workspace}\`, report \`${r.report}\`, patch \`${r.patch}\`.\n` +
270
- `You can send ${agent.name} a task to redo or adjust it (it continues in the same worktree and the integration is retried after its next wake-up), or apply the patch yourself. ` +
271
- `Do not report the job \`completed\` while this work is missing from the repo.`,
272
- });
273
- saveState();
274
- };
275
- /** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
276
- const settle = (batch, results) => {
277
- const violations = guard.check(batch);
278
- for (const v of violations) {
279
- say(` protected file ${v.action}: ${v.file} (suspects: ${v.suspects.join(", ")})`);
280
- log("violation", { ...v });
281
- deliver(project, {
282
- from: "dispatcher",
283
- to: project.lead,
284
- type: "failure",
285
- subject: `Protected file ${v.action}`,
286
- body: `\`${v.file}\` was changed without permission and has been ${v.action}. Agents active at the time: ${v.suspects.join(", ")}.`,
287
- });
288
- }
289
- batch.forEach((a, i) => {
290
- const ws = batchSpaces[a.name];
291
- if (ws && results[i]?.ok)
292
- integrateMember(a, ws, claims.get(a.name)?.id ?? `${a.name}-${state.rounds}`);
293
- });
294
- const route = routeOutboxes(project);
295
- // A done that breaks the completion contract goes back to the lead (a limited number of times).
296
- let contract;
297
- if (route.done) {
298
- contract = doneContract(route.done.meta, route.done.body, state.steps);
299
- if (contract.missing.length && (state.done_rejections ?? 0) < MAX_DONE_REJECTIONS) {
300
- state.done_rejections = (state.done_rejections ?? 0) + 1;
301
- const list = contract.missing.map((m) => `- ${m}`).join("\n");
302
- deliverOnce(project, journal(), `done-reject:${sourceIdOf(route.done.from, route.done.file)}`, {
303
- from: "dispatcher",
304
- to: project.lead,
305
- type: "failure",
306
- subject: "Your done report was not accepted",
307
- body: `The run was not ended: your \`done\` report does not meet the completion contract. Missing:\n${list}\n\nSend \`type: done\` again with the frontmatter line \`outcome: completed|partial|blocked|failed\` and the headings \`## Result\`, \`## Files\`, \`## Verification\`, \`## Not done\`. \`completed\` needs all four and every step ticked; if work remains, report \`partial\` or \`blocked\` (those need only \`## Result\` and \`## Not done\`) or \`failed\`, or send the task that continues the work. (Reminder ${state.done_rejections} of ${MAX_DONE_REJECTIONS}.)`,
308
- });
309
- rejectDone(route.done.file);
310
- say(` done report sent back to ${project.lead}: missing ${contract.missing.join("; ")}`);
311
- log("done-rejected", { missing: contract.missing, count: state.done_rejections });
312
- route.done = undefined;
313
- contract = undefined;
314
- }
315
- }
316
- for (const d of route.delivered)
317
- log("route", d);
318
- const handedOff = (from, t) => {
319
- const w = [...state.wakes].reverse().find((x) => x.agent === from);
320
- if (w)
321
- (w.sent ??= []).push(t);
322
- };
323
- for (const d of route.delivered)
324
- handedOff(d.from, { to: d.to, type: d.type, subject: d.subject });
325
- if (route.done)
326
- handedOff(route.done.from, { to: "(done)", type: "done", subject: route.done.subject });
327
- if (route.steps)
328
- state.steps = route.steps;
329
- saveState();
330
- // Output is routed: only now is the input these wake-ups handled consumed.
331
- for (const a of batch) {
332
- const c = claims.get(a.name);
333
- if (!c)
334
- continue;
335
- commitClaim(runDir, c.id);
336
- claims.delete(a.name);
337
- const att = attempts.get(a.name);
338
- if (att)
339
- finishAttempt(runDir, att.id, "committed");
340
- attempts.delete(a.name);
341
- }
342
- for (const w of route.warnings) {
343
- say(` format warning: mail ${w.id} from ${w.from} lacks ${w.missing.join(", ")}`);
344
- log("format-warning", w);
345
- }
346
- for (const r of route.rejected) {
347
- say(` rejected mail from ${r.from}: ${r.reason}`);
348
- log("rejected", r);
349
- }
350
- if (route.done) {
351
- doneMessage = { subject: route.done.subject, body: route.done.body };
352
- const ok = !contract || contract.missing.length === 0;
353
- doneOutcome = ok ? contract.outcome : "partial";
354
- doneVerification = contract?.verification;
355
- doneNote = ok ? undefined : `Completion contract not met after ${state.done_rejections ?? 0} reminder(s): missing ${contract.missing.join("; ")}. Reported as partial; the lead's report is kept as written.`;
356
- const unintegrated = state.blocked_integrations ?? [];
357
- if (doneOutcome === "completed" && unintegrated.length) {
358
- doneOutcome = "blocked";
359
- doneNote = `Not completed: work of ${unintegrated.map((b) => `${b.agent} (branch ${b.branch})`).join(", ")} was never brought into the repo; see ${unintegrated.map((b) => b.report).join(", ")}.`;
360
- }
361
- atomicWrite(path.join(runDir, RESULT_FILE), `# ${route.done.subject}\n\n**Outcome:** ${doneOutcome}${doneNote ? ` (${doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n`);
362
- pendingDoneFile = route.done.file;
363
- log("done", { subject: route.done.subject });
364
- endReason = "done";
365
- return;
366
- }
367
- const leadIdx = batch.findIndex((a) => a.name === project.lead);
368
- if (leadIdx >= 0 && !results[leadIdx].ok && !results[leadIdx].cancelled) {
369
- endReason = "lead_failed";
370
- return;
371
- }
372
- };
373
- if (resume) {
374
- const rec = recoverRunMail(runDir, { outboxOf: (agent) => outboxDir(project, agent) });
375
- for (const i of rec.interrupted) {
376
- note(`${i.attempt.agent} attempt ${i.attempt.n} was interrupted${i.parked.length ? `; ${i.parked.length} output file(s) set aside in ${path.join("mail", "attempts", i.attempt.id, "outbox")}` : ""}. ` +
377
- `Anything it already did outside its mailbox (file edits, commands) may have happened and can happen again when the message is redone: side effects may have happened.`);
378
- }
379
- if (rec.abandoned.length || rec.finalized.length || rec.ready.length) {
380
- log("recover", { abandoned: rec.abandoned.map((c) => c.id), finalized: rec.finalized, ready: rec.ready.map((c) => c.id) });
381
- }
382
- // A member whose wake-up finished before the crash: bring its files in (repeating is harmless) before its mail is routed.
383
- if (wsMode === "worktree") {
384
- for (const c of rec.ready) {
385
- const agent = project.agents[c.agent];
386
- const ws = agent && agent.name !== project.lead ? existingAgentWorkspace(project, runId, agent.name) : undefined;
387
- if (agent && ws)
388
- integrateMember(agent, ws, c.id);
389
- }
390
- }
391
- for (const c of rec.abandoned)
392
- say(` ${c.agent}'s unfinished wake-up is redone: its ${c.message_ids.length} message(s) stay unread`);
393
- settle([], []);
394
- // Wake-ups that had finished before the crash: their output is routed now, so their input is consumed.
395
- for (const c of rec.ready)
396
- commitClaim(runDir, c.id);
397
- }
398
- // When the lead was the last one woken and left every mailbox empty without a `done`, remind it once instead of ending silently.
399
- let leadWasLast = false;
400
- let nudged = false;
401
- while (!endReason) {
402
- if (opts.signal?.aborted) {
403
- endReason = "cancelled";
404
- break;
405
- }
406
- let pending = pendingAgents();
407
- if (pending.length === 0 && leadWasLast && !nudged) {
408
- nudged = true;
409
- say(" lead left no mail and no done: reminding it to send one");
410
- log("nudge", { to: project.lead });
411
- deliverOnce(project, journal(), `nudge:${state.rounds}`, {
412
- from: "dispatcher",
413
- to: project.lead,
414
- type: "failure",
415
- subject: "No done message sent",
416
- body: `All mailboxes are empty and you have not sent a \`done\` message, so the run would end without a final report. If the job is finished and verified, send \`type: done\` now (with \`outcome: completed|partial|blocked|failed\` in the frontmatter and \`## Result\`, \`## Files\`, \`## Verification\`, \`## Not done\`${state.steps ? " and the full `## Steps` checklist" : ""}). If work remains, send the \`task\` that continues it. Writing a summary in your reply text does not count: only mail is delivered.`,
417
- });
418
- pending = pendingAgents();
419
- }
420
- if (pending.length === 0) {
421
- endReason = "idle";
422
- break;
423
- }
424
- if (state.rounds >= cfg.max_rounds) {
425
- endReason = "max_rounds";
426
- break;
427
- }
428
- const batch = pickBatch(pending);
429
- batchSpaces = {};
430
- const members = batch.filter((a) => a.name !== project.lead);
431
- if (wsMode === "worktree" && members.length) {
432
- state.snapshots = (state.snapshots ?? 0) + 1;
433
- saveState();
434
- const ref = snapshotBase(project, runId, state.snapshots);
435
- for (const a of members)
436
- batchSpaces[a.name] = prepareAgentWorkspace(project, runId, a.name, ref);
437
- log("workspaces", { snapshot: ref, agents: Object.fromEntries(Object.entries(batchSpaces).map(([n, w]) => [n, w.dir])) });
438
- }
439
- const results = await Promise.all(batch.map((a) => wake(a)));
440
- leadWasLast = batch.some((a) => a.name === project.lead);
441
- if (batch.some((a) => a.name !== project.lead))
442
- nudged = false;
443
- settle(batch, results);
444
- if (!endReason && opts.signal?.aborted)
445
- endReason = "cancelled";
446
- }
447
- const outcome = endReason === "done" ? doneOutcome ?? "partial" : endReason === "lead_failed" ? "failed" : endReason === "cancelled" ? "cancelled" : "partial";
448
- const outcomeNote = endReason === "done"
449
- ? doneNote
450
- : endReason === "idle"
451
- ? "All mailboxes were empty but the lead never sent a done message."
452
- : endReason === "max_rounds"
453
- ? "Stopped at the round limit before the lead sent done."
454
- : endReason === "cancelled"
455
- ? "Cancelled before the lead finished. Unread mail was kept; continue with `agent-lyceum resume`."
456
- : "The lead's wake-up failed.";
457
- state.end_reason = endReason;
458
- state.outcome = outcome;
459
- state.outcome_note = outcomeNote;
460
- state.verification = doneVerification;
461
- state.ended_at = new Date().toISOString();
462
- log("end", { reason: endReason, outcome, rounds: state.rounds });
463
- saveState();
464
- if (pendingDoneFile)
465
- finishDone(pendingDoneFile);
466
- return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: doneVerification, doneMessage };
6
+ return new RunSession(opts).run();
467
7
  }
package/dist/format.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { must } from "./assert.js";
1
2
  /** Required `##` sections per message type. `done` and `failure` are exempt. */
2
3
  export const REQUIRED_SECTIONS = {
3
4
  task: ["Goal", "Acceptance criteria", "Scope", "Upstream"],
@@ -8,7 +9,7 @@ export function missingSections(type, body) {
8
9
  const required = REQUIRED_SECTIONS[type];
9
10
  if (!required)
10
11
  return [];
11
- const headings = new Set([...body.matchAll(/^#{1,6}[ \t]+(.+?)[ \t]*:?[ \t]*#*[ \t]*$/gm)].map((m) => m[1].trim().toLowerCase()));
12
+ const headings = new Set([...body.matchAll(/^#{1,6}[ \t]+(.+?)[ \t]*:?[ \t]*#*[ \t]*$/gm)].map((m) => (m[1] ?? "").trim().toLowerCase()));
12
13
  return required.filter((s) => !headings.has(s.toLowerCase()));
13
14
  }
14
15
  export function formatWarning(type, missing) {
@@ -26,7 +27,7 @@ export function parseSteps(body, section = true) {
26
27
  text = next ? rest.slice(0, next.index) : rest;
27
28
  }
28
29
  return [...text.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\][ \t]+(.+?)[ \t]*$/gm)].map((m) => ({
29
- text: m[2],
30
+ text: m[2] ?? "",
30
31
  done: m[1] !== " ",
31
32
  }));
32
33
  }
@@ -56,10 +57,11 @@ const HEADING = /^#{1,6}[ \t]+(.+?)[ \t]*:?[ \t]*#*[ \t]*$/gm;
56
57
  /** Text under the `##` heading `name` (case-insensitive) up to the next heading, or undefined if there is no such heading. */
57
58
  export function sectionText(body, name) {
58
59
  const heads = [...body.matchAll(HEADING)];
59
- const i = heads.findIndex((m) => m[1].trim().toLowerCase() === name.toLowerCase());
60
+ const i = heads.findIndex((m) => (m[1] ?? "").trim().toLowerCase() === name.toLowerCase());
60
61
  if (i < 0)
61
62
  return undefined;
62
- const start = heads[i].index + heads[i][0].length;
63
+ const head = must(heads[i], "heading");
64
+ const start = head.index + head[0].length;
63
65
  const end = heads[i + 1]?.index ?? body.length;
64
66
  return body.slice(start, end).trim();
65
67
  }
@@ -77,7 +79,7 @@ export function doneContract(meta, body, stateSteps) {
77
79
  if (!outcome)
78
80
  missing.push(`\`outcome: ${DECLARABLE_OUTCOMES.join("|")}\` in the frontmatter${declared ? ` (got "${declared}")` : ""}`);
79
81
  const need = outcome === "completed" || !outcome ? ["Result", "Files", "Verification", "Not done"] : ["Result", "Not done"];
80
- const have = new Set([...body.matchAll(HEADING)].map((m) => m[1].trim().toLowerCase()));
82
+ const have = new Set([...body.matchAll(HEADING)].map((m) => (m[1] ?? "").trim().toLowerCase()));
81
83
  for (const h of need)
82
84
  if (!have.has(h.toLowerCase()))
83
85
  missing.push(`\`## ${h}\``);
package/dist/fs-util.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  /** Atomic write: temp file in the same dir, then rename. */
2
2
  export declare function atomicWrite(file: string, content: string): void;
3
+ /** Is `cmd` an executable file in one of the PATH directories? (No `which`: it may be missing in slim containers.) */
4
+ export declare function findOnPath(cmd: string, pathVar?: string): boolean;
package/dist/fs-util.js CHANGED
@@ -7,3 +7,18 @@ export function atomicWrite(file, content) {
7
7
  fs.writeFileSync(tmp, content);
8
8
  fs.renameSync(tmp, file);
9
9
  }
10
+ /** Is `cmd` an executable file in one of the PATH directories? (No `which`: it may be missing in slim containers.) */
11
+ export function findOnPath(cmd, pathVar = process.env.PATH ?? "") {
12
+ return pathVar
13
+ .split(path.delimiter)
14
+ .filter(Boolean)
15
+ .some((d) => {
16
+ try {
17
+ fs.accessSync(path.join(d, cmd), fs.constants.X_OK);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ });
24
+ }
package/dist/guard.d.ts CHANGED
@@ -3,7 +3,13 @@ export interface Violation {
3
3
  file: string;
4
4
  action: "restored" | "removed";
5
5
  suspects: string[];
6
+ /** Where the rejected version was kept (only for a project bound to a run, and when the file still existed). */
7
+ saved?: string;
8
+ sha256?: string;
9
+ /** The kept copy is cut to MAX_KEEP_BYTES. */
10
+ truncated?: boolean;
6
11
  }
12
+ export declare const MAX_KEEP_BYTES: number;
7
13
  /**
8
14
  * Detects (and reverts) changes to protected files made by agents that may not edit them.
9
15
  * This is the runtime-independent safety net behind the OS/tool-level enforcement.
@@ -15,6 +21,8 @@ export declare class ProtectedGuard {
15
21
  static protectedFiles(project: ResolvedProject): string[];
16
22
  /** Copy the current protected files into `dir` for the audit trail. */
17
23
  saveSnapshot(dir: string): void;
24
+ /** Keep what an agent wrote into a protected file before it is reverted. Nothing is written without a bound run. */
25
+ private keep;
18
26
  /** Compare against the baseline after `running` agents finished; revert unauthorized changes. */
19
27
  check(running: ResolvedAgent[]): Violation[];
20
28
  }
package/dist/guard.js CHANGED
@@ -1,6 +1,11 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { commonFile, mayEditProtected, repoInstructionFiles } from "./policy.js";
5
+ export const MAX_KEEP_BYTES = 1024 * 1024;
6
+ function safeName(file) {
7
+ return file.replace(/[\\/:]/g, "_").replace(/^_+/, "");
8
+ }
4
9
  /**
5
10
  * Detects (and reverts) changes to protected files made by agents that may not edit them.
6
11
  * This is the runtime-independent safety net behind the OS/tool-level enforcement.
@@ -28,10 +33,24 @@ export class ProtectedGuard {
28
33
  for (const [file, buf] of this.baseline) {
29
34
  if (!buf)
30
35
  continue;
31
- const safe = file.replace(/[\\/:]/g, "_").replace(/^_+/, "");
36
+ const safe = safeName(file);
32
37
  fs.writeFileSync(path.join(dir, safe), buf);
33
38
  }
34
39
  }
40
+ /** Keep what an agent wrote into a protected file before it is reverted. Nothing is written without a bound run. */
41
+ keep(file, content) {
42
+ const run = this.project.run;
43
+ if (!content || !run)
44
+ return {};
45
+ const sha256 = crypto.createHash("sha256").update(content).digest("hex");
46
+ const dir = path.join(run.dir, "violations");
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ const n = String(fs.readdirSync(dir).length + 1).padStart(3, "0");
49
+ const saved = path.join(dir, `${n}-${safeName(file)}`);
50
+ const truncated = content.length > MAX_KEEP_BYTES;
51
+ fs.writeFileSync(saved, truncated ? content.subarray(0, MAX_KEEP_BYTES) : content);
52
+ return { saved, sha256, ...(truncated ? { truncated } : {}) };
53
+ }
35
54
  /** Compare against the baseline after `running` agents finished; revert unauthorized changes. */
36
55
  check(running) {
37
56
  const violations = [];
@@ -45,13 +64,15 @@ export class ProtectedGuard {
45
64
  continue;
46
65
  }
47
66
  if (before === null) {
67
+ const kept = this.keep(file, now);
48
68
  fs.rmSync(file, { force: true });
49
- violations.push({ file, action: "removed", suspects: running.map((a) => a.name) });
69
+ violations.push({ file, action: "removed", suspects: running.map((a) => a.name), ...kept });
50
70
  }
51
71
  else {
72
+ const kept = this.keep(file, now);
52
73
  fs.mkdirSync(path.dirname(file), { recursive: true });
53
74
  fs.writeFileSync(file, before);
54
- violations.push({ file, action: "restored", suspects: running.map((a) => a.name) });
75
+ violations.push({ file, action: "restored", suspects: running.map((a) => a.name), ...kept });
55
76
  }
56
77
  }
57
78
  return violations;
package/dist/mailbox.d.ts CHANGED
@@ -54,6 +54,8 @@ export declare function deliverOnce(project: ResolvedProject, journal: RouteJour
54
54
  fresh: boolean;
55
55
  };
56
56
  export declare function readMessage(file: string): Message;
57
+ /** Paths of the unread messages in an agent's inbox, oldest name first. Does not open them. */
58
+ export declare function unreadFiles(project: ResolvedProject, agent: string): string[];
57
59
  export declare function listUnread(project: ResolvedProject, agent: string): Message[];
58
60
  export declare function markRead(files: string[]): void;
59
61
  export interface RouteResult {