agent-lyceum 0.31.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,6 +79,8 @@ Folder layout, every agent field and the rules `doctor` checks: [Configuration](
79
79
 
80
80
  Older versions refuse a `team.yaml` or `project.yaml` that contains `model_tiers` (a load error, "Unrecognized key"). Before using an older binary, remove `model_tiers` from every `project.yaml` and from `team.yaml`. Other mail and run state are unaffected: older versions ignore a `tier` line in mail. See [Model tiers](docs/configuration.md#model-tiers). (Spec 24.3.2, 24.13)
81
81
 
82
+ Likewise, older versions refuse a `project.yaml` that contains `verify` ("Unrecognized key"). Remove the `verify` block before using an older binary. Run state is unaffected. See [Verify](docs/configuration.md#verify). (Spec 27.4)
83
+
82
84
  ## Topics
83
85
 
84
86
  | Topic | Read |
package/README.zh-TW.md CHANGED
@@ -79,6 +79,8 @@ agents:
79
79
 
80
80
  舊版程式會拒絕載入含 `model_tiers` 的 `team.yaml` 或 `project.yaml`(載入錯誤,「Unrecognized key」)。改用舊版前,請先從每個 `project.yaml` 與 `team.yaml` 拿掉 `model_tiers`。其他信件與 run 狀態不受影響:舊版會忽略信件中的 `tier` 行。見[模型分級](docs/configuration.zh-TW.md#模型分級)。(spec 24.3.2、24.13)
81
81
 
82
+ 同樣地,舊版程式會拒絕載入含 `verify` 的 `project.yaml`(「Unrecognized key」)。改用舊版前,請先拿掉 `verify` 區塊。run 狀態不受影響。見[驗證](docs/configuration.zh-TW.md#驗證verify)。(spec 27.4)
83
+
82
84
  ## 主題
83
85
 
84
86
  | 主題 | 閱讀 |
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
14
14
  import { acquireProjectLock, forceUnlock, inspectRunLock, listRunLocks, listUnreadableRunLocks, LOCK_FILE, lockHolderAlive } from "./project-lock.js";
15
15
  import { planWorkspace } from "./worktree.js";
16
16
  import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, roundsLabel, runLabel, slugify, uniqueName } from "./run-store.js";
17
- import { failureGuide } from "./run-outcome.js";
17
+ import { printRun } from "./run-print.js";
18
18
  import { exitCodeForOutcome, RUNTIMES } from "./schema.js";
19
19
  import { absPath, assertName, NAME_RE, projectPaths, resolveHome } from "./paths.js";
20
20
  import { addProject, initHome, removeProject } from "./scaffold.js";
@@ -192,30 +192,6 @@ function cancelOnSignals() {
192
192
  },
193
193
  };
194
194
  }
195
- function printRun(summary, runDir) {
196
- console.log(`\nRun ${runLabel({ name: summary.name, run_id: summary.runId })} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
197
- if (summary.outcomeNote)
198
- console.log(`Note: ${summary.outcomeNote}`);
199
- if (summary.failure)
200
- console.log(failureGuide(summary.project, summary.name ?? summary.runId, summary.failure, runDir));
201
- if (summary.verification)
202
- console.log(`Verification (as reported by the lead, not checked by agent-lyceum): ${summary.verification}`);
203
- if (summary.resultBranch?.files) {
204
- console.log(`\nThe work is on branch ${summary.resultBranch.branch} (${summary.resultBranch.files} file(s)); your working tree was not touched.\nBring it in with: git merge ${summary.resultBranch.branch}${summary.resultBranch.patch ? `\n(or apply the patch: git apply ${summary.resultBranch.patch})` : ""}`);
205
- }
206
- if (summary.pr) {
207
- if (summary.pr.status === "opened")
208
- console.log(`\nPull request ${summary.pr.updated ? "updated" : "opened"}: ${summary.pr.url}`);
209
- else if (summary.pr.status === "skipped")
210
- console.log(`\nA pull request was asked for but not opened: ${summary.pr.reason}`);
211
- else
212
- console.log(`\nThe pull request could not be opened (${summary.pr.step}: ${summary.pr.reason}).${summary.pr.manual.length ? ` To finish by hand:\n${summary.pr.manual.map((m) => ` ${m}`).join("\n")}` : ""}`);
213
- }
214
- if (summary.doneMessage) {
215
- console.log(`\nLead's final message — ${summary.doneMessage.subject}\n\n${summary.doneMessage.body}`);
216
- console.log(`\nResult saved to: ${path.join(runDir, RESULT_FILE)}`);
217
- }
218
- }
219
195
  function reportRun(summary, runDir) {
220
196
  printRun(summary, runDir);
221
197
  process.exit(exitCodeForOutcome(summary.outcome));
package/dist/config.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ProjectPaths } from "./paths.js";
2
2
  import type { AgentWorkspace } from "./worktree.js";
3
- import { type DispatcherSettings, type Effort, type FallbackEntry, type SandboxPartialT, type Runtime, type WebLevel } from "./schema.js";
3
+ import { type VerifySettings, type DispatcherSettings, type Effort, type FallbackEntry, type SandboxPartialT, type Runtime, type WebLevel } from "./schema.js";
4
4
  export declare class ConfigError extends Error {
5
5
  }
6
6
  export type Source = "global" | "project" | "default" | "model";
@@ -64,6 +64,8 @@ export interface ResolvedProject {
64
64
  agents: Record<string, ResolvedAgent>;
65
65
  /** Spec 24: non-empty model ladders per runtime, lowest first (project.yaml replaces team.yaml's per runtime). */
66
66
  modelTiers: Partial<Record<Runtime, string[]>>;
67
+ /** Spec 27: dispatcher-run verification commands from project.yaml; absent = off. Read once when the project is loaded. */
68
+ verify?: VerifySettings;
67
69
  /** Which file each ladder in `modelTiers` came from. */
68
70
  modelTiersFrom: Partial<Record<Runtime, "global" | "project">>;
69
71
  paths: ProjectPaths;
@@ -128,6 +130,14 @@ export declare function loadProjectRaw(home: string, name: string): {
128
130
  "claude-code"?: string[] | undefined;
129
131
  codex?: string[] | undefined;
130
132
  } | undefined;
133
+ verify?: {
134
+ max_rejections?: number | undefined;
135
+ commands: {
136
+ name: string;
137
+ run: string;
138
+ timeout_sec?: number | undefined;
139
+ }[];
140
+ } | undefined;
131
141
  dispatcher: {
132
142
  max_rounds?: number | undefined;
133
143
  max_parallel?: unknown;
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import YAML from "yaml";
4
4
  import { absPath, canonicalPath, escapesParent, expandHome, homePaths, projectPaths, } from "./paths.js";
5
5
  import { availableTiers } from "./tiers.js";
6
- import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, RUNTIMES, } from "./schema.js";
6
+ import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, VERIFY_DEFAULTS, inferRuntime, RUNTIMES, } from "./schema.js";
7
7
  export class ConfigError extends Error {
8
8
  }
9
9
  function readYaml(file, schema, label) {
@@ -71,6 +71,12 @@ export function resolveProjectFrom(home, name, g, p) {
71
71
  modelTiersFrom[rt] = fromProject ? "project" : "global";
72
72
  }
73
73
  }
74
+ const verify = p.verify?.commands.length
75
+ ? {
76
+ max_rejections: p.verify.max_rejections ?? VERIFY_DEFAULTS.max_rejections,
77
+ commands: p.verify.commands.map((c) => ({ name: c.name, run: c.run, timeout_sec: c.timeout_sec ?? VERIFY_DEFAULTS.timeout_sec })),
78
+ }
79
+ : undefined;
74
80
  const agents = {};
75
81
  for (const agentName of Object.keys(p.agents)) {
76
82
  const gl = resolveLayer(g.agents[agentName], home);
@@ -201,6 +207,7 @@ export function resolveProjectFrom(home, name, g, p) {
201
207
  agents,
202
208
  modelTiers,
203
209
  modelTiersFrom,
210
+ ...(verify ? { verify } : {}),
204
211
  paths,
205
212
  ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
206
213
  };
package/dist/doctor.js CHANGED
@@ -158,6 +158,14 @@ export async function diagnoseProject(project, opts = {}) {
158
158
  ? { level: "info", subject: "pr", message: `A run cannot open a pull request (a lead asks for one with \`open_pr: true\`): ${missing.join("; ")}.` }
159
159
  : { level: "ok", subject: "pr", message: "`gh` and `origin` found: a run can open a pull request when the lead asks for one." });
160
160
  }
161
+ if (project.verify) {
162
+ const cmds = project.verify.commands.map((c) => `${c.name}: ${c.run} (timeout ${c.timeout_sec}s)`).join("; ");
163
+ checks.push({
164
+ level: "warn",
165
+ subject: "verify",
166
+ message: `Before it accepts a lead's \`completed\` done, the dispatcher runs ${project.verify.commands.length} verify command(s), up to ${project.verify.max_rejections} rejection(s): ${cmds}. They run OUTSIDE the agents' sandbox with your permissions and execute code the agents wrote, in the lead's worktree, or directly in ${project.dir} when runs do not use worktrees. Only you can change them (project.yaml).`,
167
+ });
168
+ }
161
169
  for (const lock of listRunLocks(project.paths.root)) {
162
170
  const alive = lockHolderAlive(lock);
163
171
  checks.push({
package/dist/fs-util.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Atomic write: temp file in the same dir, then rename. */
2
- export declare function atomicWrite(file: string, content: string): void;
2
+ export declare function atomicWrite(file: string, content: string | Buffer): void;
3
3
  /** Is `cmd` an executable file in one of the PATH directories? (No `which`: it may be missing in slim containers.) */
4
4
  export declare function findOnPath(cmd: string, pathVar?: string): boolean;
5
5
  /**
@@ -15,10 +15,15 @@ export interface RunnerOptions {
15
15
  }
16
16
  /** Pids below `root` (children, grandchildren, ...) by parent links; catches descendants that left the process group (setsid, daemons). */
17
17
  export declare function descendantsOf(root: number): number[];
18
+ /** Make a child visible to `killAllRunning` (also used by verify commands, spec 27). */
19
+ export declare function register(pid: number): void;
20
+ export declare function unregister(pid: number): void;
18
21
  /** Sampling interval of the descendant timer (default 2000 ms); for tests. Takes effect for the next timer. */
19
22
  export declare function setSamplerIntervalMs(ms: number): void;
20
23
  /** Whether the descendant timer is running (for tests). */
21
24
  export declare function samplerActive(): boolean;
25
+ /** Signal the runtime's group, the descendants recorded earlier that are still in the process table, and its current descendants. */
26
+ export declare function killTree(pid: number, sig: NodeJS.Signals): void;
22
27
  /** How many runtimes are registered as running (for tests). */
23
28
  export declare function runningCount(): number;
24
29
  /** Synchronously SIGKILL every runtime still running, with the descendants seen when it was started or last checked. Used when agent-lyceum itself is going away. */
@@ -88,7 +88,8 @@ function sample() {
88
88
  for (const d of below(kids, pid))
89
89
  seen.add(d);
90
90
  }
91
- function register(pid) {
91
+ /** Make a child visible to `killAllRunning` (also used by verify commands, spec 27). */
92
+ export function register(pid) {
92
93
  live.set(pid, new Set());
93
94
  if (!exitHook) {
94
95
  exitHook = true;
@@ -99,7 +100,7 @@ function register(pid) {
99
100
  sampler.unref();
100
101
  }
101
102
  }
102
- function unregister(pid) {
103
+ export function unregister(pid) {
103
104
  live.delete(pid);
104
105
  if (!live.size && sampler) {
105
106
  clearInterval(sampler);
@@ -123,7 +124,7 @@ function signalPid(pid, sig, group) {
123
124
  }
124
125
  }
125
126
  /** Signal the runtime's group, the descendants recorded earlier that are still in the process table, and its current descendants. */
126
- function killTree(pid, sig) {
127
+ export function killTree(pid, sig) {
127
128
  const kids = processTable();
128
129
  const seen = live.get(pid) ?? new Set();
129
130
  const now = below(kids, pid);
@@ -0,0 +1,40 @@
1
+ import type { VerifyCommandRecord, VerifyRecord } from "./run-store.js";
2
+ /** Spec 28: has the same verify failure come back on an unchanged workspace? Pure functions except `fingerprintPaths`. */
3
+ /** How much of a command's raw output is looked at before it is cut into lines. */
4
+ export declare const FINGERPRINT_WINDOW_BYTES: number;
5
+ export declare const FINGERPRINT_LINES = 100;
6
+ /** A literal path and what it is replaced with. */
7
+ export type PathRule = [literal: string, replacement: string];
8
+ /** The paths of this run that should not make two outputs differ: the working directory, the run directory and the temp directory. */
9
+ export declare function fingerprintPaths(cwd: string, runDir: string): PathRule[];
10
+ /** The last FINGERPRINT_LINES lines of `text` with ANSI codes, overwritten progress, clock times, durations and this run's paths removed. */
11
+ export declare function normalizeOutput(text: string, paths: PathRule[]): string;
12
+ /** 16 hex characters over the command name, how it failed, and the normalised tail of its output. */
13
+ export declare function failureFingerprint(c: Pick<VerifyCommandRecord, "name" | "exit" | "timed_out" | "error">, tail: string, paths: PathRule[]): string;
14
+ export type HashRelation = "same" | "changed" | "unknown" | "no-worktree";
15
+ export type RepeatAction = "none" | "label" | "stop" | "limit";
16
+ export interface RepeatComparison {
17
+ previous?: string;
18
+ sameFingerprint: boolean;
19
+ hash: HashRelation;
20
+ }
21
+ /** Compared with the newest record of the last rejected `done` of this segment, never with the current one (a replay adds a second record for the same source). */
22
+ export declare function compareWithLastRejection(a: {
23
+ source: string;
24
+ fingerprint: string;
25
+ hash: string | undefined;
26
+ worktree: boolean;
27
+ records: VerifyRecord[] | undefined;
28
+ rejected: string[] | undefined;
29
+ }): RepeatComparison;
30
+ /** True when this `done` was already stopped as a repeat in this segment (`segment` is the number of reopens so far). */
31
+ export declare function stoppedInSegment(records: VerifyRecord[] | undefined, source: string, segment: number): boolean;
32
+ export declare function repeatAction(cmp: RepeatComparison, s: {
33
+ counted: boolean;
34
+ stoppedBefore: boolean;
35
+ left: number;
36
+ }): RepeatAction;
37
+ /** The label at the top of a rejection mail; the message itself is unchanged. */
38
+ export declare function withRepeatLabel(action: RepeatAction, hash: HashRelation, message: string): string;
39
+ /** The outcome note when a repeated failure on an unchanged workspace is accepted as partial; `failure` is `describeFailure`'s text. */
40
+ export declare function repeatStopNote(name: string, run: string, failure: string, log: string, used: number, max: number): string;
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import { stripVTControlCharacters } from "node:util";
5
+ /** Spec 28: has the same verify failure come back on an unchanged workspace? Pure functions except `fingerprintPaths`. */
6
+ /** How much of a command's raw output is looked at before it is cut into lines. */
7
+ export const FINGERPRINT_WINDOW_BYTES = 256 * 1024;
8
+ export const FINGERPRINT_LINES = 100;
9
+ const TMP = /(?<![\w./-])(?:\/private)?(?:\/tmp|\/var\/tmp|\/var\/folders\/[^\s/]+\/[^\s/]+\/T)(?:\/[^\s:'"`()[\]<>,;]*)?/g;
10
+ const STAMP = /(?<![\w.:-])\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}(?::?\d{2})?)?(?![\w:])/g;
11
+ const CLOCK = /(?<![\w.:-])\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?(?![\w:])/g;
12
+ const DUR = /(?<![\w./-])(?:\d+h\s?)?(?:\d+m\s?)?\d+(?:\.\d+)?(?:ms|µs|ns|s|secs?|seconds?|mins?|minutes?)(?![\w/-]|\.\w)/g;
13
+ /** The paths of this run that should not make two outputs differ: the working directory, the run directory and the temp directory. */
14
+ export function fingerprintPaths(cwd, runDir) {
15
+ const rules = [];
16
+ const add = (p, to) => {
17
+ if (p && !rules.some(([l]) => l === p))
18
+ rules.push([p, to]);
19
+ };
20
+ const real = (p) => {
21
+ try {
22
+ return fs.realpathSync.native(p);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ };
28
+ for (const [p, to] of [[cwd, "<cwd>"], [runDir, "<run-dir>"], [os.tmpdir(), "/tmp"]]) {
29
+ add(p, to);
30
+ const r = real(p);
31
+ if (r)
32
+ add(r, to);
33
+ }
34
+ return rules;
35
+ }
36
+ const PATH_NEXT = /[\w.-]/;
37
+ /** Replace each literal that is not directly followed by a name character (`/w/x` does not eat `/w/x2` or `/w/x.bak`). */
38
+ function replacePaths(line, paths) {
39
+ let s = line;
40
+ for (const [literal, to] of [...paths].sort((a, b) => b[0].length - a[0].length)) {
41
+ if (!literal || literal === to)
42
+ continue;
43
+ let out = "";
44
+ let from = 0;
45
+ for (;;) {
46
+ const i = s.indexOf(literal, from);
47
+ if (i < 0)
48
+ break;
49
+ const next = s[i + literal.length];
50
+ out += s.slice(from, i);
51
+ if (next !== undefined && PATH_NEXT.test(next))
52
+ out += literal;
53
+ else
54
+ out += to;
55
+ from = i + literal.length;
56
+ }
57
+ s = out + s.slice(from);
58
+ }
59
+ return s;
60
+ }
61
+ /** The last FINGERPRINT_LINES lines of `text` with ANSI codes, overwritten progress, clock times, durations and this run's paths removed. */
62
+ export function normalizeOutput(text, paths) {
63
+ const lines = stripVTControlCharacters(text)
64
+ .split("\n")
65
+ .map((l) => {
66
+ const noCr = l.replace(/\r+$/, "");
67
+ return noCr.slice(noCr.lastIndexOf("\r") + 1).trimEnd();
68
+ });
69
+ while (lines.length && lines[lines.length - 1] === "")
70
+ lines.pop();
71
+ return lines
72
+ .slice(-FINGERPRINT_LINES)
73
+ .map((l) => replacePaths(l, paths).replace(TMP, "<tmp>").replace(STAMP, "<time>").replace(CLOCK, "<time>").replace(DUR, "<duration>"))
74
+ .join("\n");
75
+ }
76
+ /** 16 hex characters over the command name, how it failed, and the normalised tail of its output. */
77
+ export function failureFingerprint(c, tail, paths) {
78
+ const status = c.error ? `error:${normalizeOutput(c.error, paths)}` : c.timed_out ? "timeout" : c.exit === null ? "signal" : `exit:${c.exit}`;
79
+ return createHash("sha256").update(`${c.name}\0${status}\0${normalizeOutput(tail, paths)}`).digest("hex").slice(0, 16);
80
+ }
81
+ /** Compared with the newest record of the last rejected `done` of this segment, never with the current one (a replay adds a second record for the same source). */
82
+ export function compareWithLastRejection(a) {
83
+ const records = a.records ?? [];
84
+ let last;
85
+ for (let i = records.length - 1; i >= 0; i--) {
86
+ const r = records[i];
87
+ if (r.source !== a.source && (a.rejected ?? []).includes(r.source)) {
88
+ last = r;
89
+ break;
90
+ }
91
+ }
92
+ const prev = last?.commands.find((c) => typeof c.fingerprint === "string")?.fingerprint;
93
+ const hash = !a.worktree ? "no-worktree" : a.hash === undefined || last?.diff_hash === undefined ? "unknown" : a.hash === last.diff_hash ? "same" : "changed";
94
+ return { ...(last ? { previous: last.source } : {}), sameFingerprint: prev !== undefined && prev === a.fingerprint, hash };
95
+ }
96
+ /** True when this `done` was already stopped as a repeat in this segment (`segment` is the number of reopens so far). */
97
+ export function stoppedInSegment(records, source, segment) {
98
+ return (records ?? []).some((r) => r.source === source && r.repeat_stop_segment === segment);
99
+ }
100
+ export function repeatAction(cmp, s) {
101
+ if (s.counted)
102
+ return cmp.sameFingerprint ? "label" : "none";
103
+ if (s.stoppedBefore)
104
+ return "stop";
105
+ if (s.left <= 0)
106
+ return "limit";
107
+ if (!cmp.sameFingerprint)
108
+ return "none";
109
+ return cmp.hash === "same" ? "stop" : "label";
110
+ }
111
+ /** The label at the top of a rejection mail; the message itself is unchanged. */
112
+ export function withRepeatLabel(action, hash, message) {
113
+ if (action !== "label")
114
+ return message;
115
+ const changed = hash === "changed" ? " The workspace changed since then, but this result did not." : "";
116
+ return `**Same failure as your last rejected \`done\`** (same command, same exit status, same last lines of output; times and paths ignored).${changed}\n\n${message}`;
117
+ }
118
+ /** The outcome note when a repeated failure on an unchanged workspace is accepted as partial; `failure` is `describeFailure`'s text. */
119
+ export function repeatStopNote(name, run, failure, log, used, max) {
120
+ return `Verification stopped early: \`${name}\` (\`${run}\`) ${failure}; this is the same failure as the last rejected attempt and the workspace has not changed since, so no progress is likely and the \`done\` was not sent back again (${used} of ${max} rejection(s) used); see ${log}. Reported as partial; the lead's report is kept as written.`;
121
+ }
@@ -7,7 +7,11 @@ export interface UnintegratedWork {
7
7
  report: string;
8
8
  }
9
9
  /** What a lead's `done` amounts to: its declared outcome, unless the contract is broken or member work never reached the repo. */
10
- export declare function resolveDoneOutcome(contract: DoneContract, rejections: number, unintegrated: UnintegratedWork[]): {
10
+ export declare function resolveDoneOutcome(contract: DoneContract, rejections: number, unintegrated: UnintegratedWork[],
11
+ /** Spec 27/28: a verify command failed and no rejections were left, or the same failure repeated on an unchanged workspace; `note` says which and how. */
12
+ verifyFailure?: {
13
+ note: string;
14
+ }): {
11
15
  outcome: RunOutcome;
12
16
  note?: string;
13
17
  verification?: string;
@@ -1,6 +1,8 @@
1
1
  import path from "node:path";
2
2
  /** What a lead's `done` amounts to: its declared outcome, unless the contract is broken or member work never reached the repo. */
3
- export function resolveDoneOutcome(contract, rejections, unintegrated) {
3
+ export function resolveDoneOutcome(contract, rejections, unintegrated,
4
+ /** Spec 27/28: a verify command failed and no rejections were left, or the same failure repeated on an unchanged workspace; `note` says which and how. */
5
+ verifyFailure) {
4
6
  const ok = contract.missing.length === 0;
5
7
  let outcome = ok ? (contract.outcome ?? "partial") : "partial";
6
8
  let note = ok ? undefined : `Completion contract not met after ${rejections} reminder(s): missing ${contract.missing.join("; ")}. Reported as partial; the lead's report is kept as written.`;
@@ -8,6 +10,12 @@ export function resolveDoneOutcome(contract, rejections, unintegrated) {
8
10
  outcome = "blocked";
9
11
  note = `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(", ")}.`;
10
12
  }
13
+ if (verifyFailure) {
14
+ // blocked (work never reached the repo) is the worse verdict and stays; the note names both
15
+ if (outcome === "completed")
16
+ outcome = "partial";
17
+ note = note ? `${note} ${verifyFailure.note}` : verifyFailure.note;
18
+ }
11
19
  return { outcome, note, verification: contract.verification };
12
20
  }
13
21
  /** How a run turned out, from why it stopped (`done` carries the lead's own verdict). */
@@ -0,0 +1,3 @@
1
+ import { type RunSummary } from "./dispatcher.js";
2
+ /** What a finished run prints on the terminal. */
3
+ export declare function printRun(summary: RunSummary, runDir: string): void;
@@ -0,0 +1,31 @@
1
+ import path from "node:path";
2
+ import { RESULT_FILE } from "./dispatcher.js";
3
+ import { failureGuide } from "./run-outcome.js";
4
+ import { runLabel } from "./run-store.js";
5
+ /** What a finished run prints on the terminal. */
6
+ export function printRun(summary, runDir) {
7
+ console.log(`\nRun ${runLabel({ name: summary.name, run_id: summary.runId })} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
8
+ if (summary.outcomeNote)
9
+ console.log(`Note: ${summary.outcomeNote}`);
10
+ if (summary.failure)
11
+ console.log(failureGuide(summary.project, summary.name ?? summary.runId, summary.failure, runDir));
12
+ if (summary.verification)
13
+ console.log(summary.dispatcherVerification ? `Verification (the lead's own report): ${summary.verification}` : `Verification (as reported by the lead, not checked by agent-lyceum): ${summary.verification}`);
14
+ if (summary.dispatcherVerification)
15
+ console.log(`Dispatcher verification (run by agent-lyceum): ${summary.dispatcherVerification.text}`);
16
+ if (summary.resultBranch?.files) {
17
+ console.log(`\nThe work is on branch ${summary.resultBranch.branch} (${summary.resultBranch.files} file(s)); your working tree was not touched.\nBring it in with: git merge ${summary.resultBranch.branch}${summary.resultBranch.patch ? `\n(or apply the patch: git apply ${summary.resultBranch.patch})` : ""}`);
18
+ }
19
+ if (summary.pr) {
20
+ if (summary.pr.status === "opened")
21
+ console.log(`\nPull request ${summary.pr.updated ? "updated" : "opened"}: ${summary.pr.url}`);
22
+ else if (summary.pr.status === "skipped")
23
+ console.log(`\nA pull request was asked for but not opened: ${summary.pr.reason}`);
24
+ else
25
+ console.log(`\nThe pull request could not be opened (${summary.pr.step}: ${summary.pr.reason}).${summary.pr.manual.length ? ` To finish by hand:\n${summary.pr.manual.map((m) => ` ${m}`).join("\n")}` : ""}`);
26
+ }
27
+ if (summary.doneMessage) {
28
+ console.log(`\nLead's final message — ${summary.doneMessage.subject}\n\n${summary.doneMessage.body}`);
29
+ console.log(`\nResult saved to: ${path.join(runDir, RESULT_FILE)}`);
30
+ }
31
+ }
@@ -23,6 +23,11 @@ export interface RunSummary {
23
23
  outcome: RunOutcome;
24
24
  outcomeNote?: string;
25
25
  verification?: string;
26
+ /** Spec 27: the dispatcher's own verification of this done, when `verify` is configured and ran. */
27
+ dispatcherVerification?: {
28
+ passed: boolean;
29
+ text: string;
30
+ };
26
31
  doneMessage?: {
27
32
  subject: string;
28
33
  body: string;
@@ -68,6 +73,11 @@ export interface RunOptions {
68
73
  * shared memory), or undefined. Asked before activation, after an instance is created and when a run is resumed. Tests inject one.
69
74
  */
70
75
  instanceCheck?: (role: string, k: number) => string | undefined;
76
+ /** Spec 27: kill grace and pipe wait of verify commands (milliseconds); tests shorten them. */
77
+ verifyTiming?: {
78
+ killGraceMs?: number;
79
+ settleWaitMs?: number;
80
+ };
71
81
  }
72
82
  export declare class RunSession {
73
83
  private readonly opts;
@@ -107,6 +117,8 @@ export declare class RunSession {
107
117
  /** Models cooling down after a rate limit or quota error; one table for the run, shared by parallel wake-ups. */
108
118
  private readonly cooldowns;
109
119
  private doneVerification;
120
+ /** What the dispatcher's own verification of the accepted done came to (spec 27); set only when it ran. */
121
+ private dispatcherVerification;
110
122
  /** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
111
123
  private openPrRequested;
112
124
  private readonly prRunner;
@@ -162,6 +174,12 @@ export declare class RunSession {
162
174
  private refuseMember;
163
175
  /** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
164
176
  private settle;
177
+ /**
178
+ * Run the project's verify commands on a completed `done` (spec 27). `passed`: accept; `exhausted`: accept as partial;
179
+ * `rejected`: sent back to the lead (the done file is set aside); `cancelled`: no verdict, the done file stays in the outbox.
180
+ * The commands are `project.verify` as loaded when this process started (project.yaml is not read again during a run).
181
+ */
182
+ private verifyDone;
165
183
  /** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
166
184
  private deliverAnswers;
167
185
  /** Store the questions of this pass's valid `ask` mails; malformed ones go back to their sender. Returns true when the run should now wait. */
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import { invokeWithSandboxChecks } from "./sandbox-invocation.js";
4
4
  import { realInvoker } from "./adapters/index.js";
5
5
  import { agentForCandidate, candidatesFor, Cooldowns, defaultSleep, deferredAttempts, planAttempts, runWithFallback } from "./fallback.js";
6
+ import { compareWithLastRejection, repeatAction, repeatStopNote, stoppedInSegment, withRepeatLabel } from "./repeat-detect.js";
7
+ import { commandPassed, describeFailure, rejectionMessage, runVerify, verificationSection } from "./verify.js";
6
8
  import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./format.js";
7
9
  import { ProtectedGuard } from "./guard.js";
8
10
  import { listRunLocks, lockHolderAlive } from "./project-lock.js";
@@ -18,6 +20,7 @@ import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
18
20
  import { collectDrafts, priorFailedAttempts, redoUserPrompt } from "./redo.js";
19
21
  import { atomicWrite, readFileCapped } from "./fs-util.js";
20
22
  import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
23
+ import { workingTreeHash } from "./worktree.js";
21
24
  import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRunState } from "./run-store.js";
22
25
  import { soloProject } from "./solo.js";
23
26
  import { taskMessageBody } from "./task.js";
@@ -119,6 +122,8 @@ export class RunSession {
119
122
  /** Models cooling down after a rate limit or quota error; one table for the run, shared by parallel wake-ups. */
120
123
  cooldowns;
121
124
  doneVerification;
125
+ /** What the dispatcher's own verification of the accepted done came to (spec 27); set only when it ran. */
126
+ dispatcherVerification;
122
127
  /** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
123
128
  openPrRequested = false;
124
129
  prRunner;
@@ -213,6 +218,8 @@ export class RunSession {
213
218
  state.max_rounds = roundLimit(state, cfg.max_rounds);
214
219
  state.result_branch = undefined;
215
220
  state.done_rejections = 0;
221
+ state.verify_rejections = undefined;
222
+ state.verify_rejected = undefined;
216
223
  // The progress so far stays shown: the follow-up's own steps are added after it.
217
224
  state.step_base = resume.steps;
218
225
  if (recut && resume.pr_url) {
@@ -862,7 +869,7 @@ export class RunSession {
862
869
  this.saveState();
863
870
  }
864
871
  /** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
865
- settle(batch, results) {
872
+ async settle(batch, results) {
866
873
  const { say, runDir, project, log, state, guard, claims, attempts } = this;
867
874
  const violations = guard.check(batch);
868
875
  for (const v of violations) {
@@ -913,6 +920,23 @@ export class RunSession {
913
920
  contract = undefined;
914
921
  }
915
922
  }
923
+ // Spec 27: a completed done that met the contract is judged by the project's verify commands (the contract came first).
924
+ let verified;
925
+ if (route.done && contract && contract.missing.length === 0 && contract.outcome === "completed" && project.verify) {
926
+ const v = await this.verifyDone(route.done, project.verify);
927
+ if (v.kind === "cancelled") {
928
+ // No verdict: the done stays in the outbox (routed again on resume); the rest of this settle still runs.
929
+ route.done = undefined;
930
+ contract = undefined;
931
+ this.endReason = "cancelled";
932
+ }
933
+ else if (v.kind === "rejected") {
934
+ route.done = undefined;
935
+ contract = undefined;
936
+ }
937
+ else
938
+ verified = v;
939
+ }
916
940
  this.noteTierIgnored(route);
917
941
  const askAccepted = this.handleAsks(route);
918
942
  for (const d of route.delivered)
@@ -961,11 +985,16 @@ export class RunSession {
961
985
  }
962
986
  this.doneMessage = { subject: route.done.subject, body: route.done.body };
963
987
  this.openPrRequested = route.done.meta.open_pr === true;
964
- const resolved = resolveDoneOutcome(must(contract, "completion contract of the done report"), state.done_rejections ?? 0, state.blocked_integrations ?? []);
988
+ const resolved = resolveDoneOutcome(must(contract, "completion contract of the done report"), state.done_rejections ?? 0, state.blocked_integrations ?? [], verified?.failure);
965
989
  this.doneOutcome = resolved.outcome;
966
990
  this.doneNote = resolved.note;
967
991
  this.doneVerification = resolved.verification;
968
- const report = `# ${route.done.subject}\n\n**Outcome:** ${this.doneOutcome}${this.doneNote ? ` (${this.doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n`;
992
+ if (verified) {
993
+ const bad = verified.record.commands.find((c) => !commandPassed(c));
994
+ const spec = bad ? project.verify?.commands.find((c) => c.name === bad.name) : undefined;
995
+ this.dispatcherVerification = bad ? { passed: false, text: `FAILED: ${bad.name} (${spec?.run ?? "?"}) ${describeFailure(bad, spec?.timeout_sec)}` } : { passed: true, text: `passed (${verified.record.commands.map((c) => c.name).join(", ")})` };
996
+ }
997
+ const report = `# ${route.done.subject}\n\n**Outcome:** ${this.doneOutcome}${this.doneNote ? ` (${this.doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n${verified ? `\n${verificationSection(verified.record, must(project.verify, "verify settings"), verified.cwd)}` : ""}`;
969
998
  atomicWrite(path.join(runDir, RESULT_FILE), report);
970
999
  atomicWrite(path.join(runDir, `done-${(state.reopened?.length ?? 0) + 1}.md`), report);
971
1000
  this.pendingDoneFile = route.done.file;
@@ -973,6 +1002,8 @@ export class RunSession {
973
1002
  this.endReason = "done";
974
1003
  return;
975
1004
  }
1005
+ if (this.endReason)
1006
+ return; // cancelled while verifying: nothing later may change why the run ended
976
1007
  if (askAccepted) {
977
1008
  this.endReason = "waiting";
978
1009
  return;
@@ -987,6 +1018,75 @@ export class RunSession {
987
1018
  return;
988
1019
  }
989
1020
  }
1021
+ /**
1022
+ * Run the project's verify commands on a completed `done` (spec 27). `passed`: accept; `exhausted`: accept as partial;
1023
+ * `rejected`: sent back to the lead (the done file is set aside); `cancelled`: no verdict, the done file stays in the outbox.
1024
+ * The commands are `project.verify` as loaded when this process started (project.yaml is not read again during a run).
1025
+ */
1026
+ async verifyDone(done, verify) {
1027
+ const { say, runDir, project, log, state } = this;
1028
+ const cwd = this.leadSpace?.dir ?? project.dir;
1029
+ const source = sourceIdOf(done.from, done.file);
1030
+ // The tree is hashed before any command runs; with the lead's worktree this uses the pinned git, never a bare one.
1031
+ const diffHash = await workingTreeHash(project, this.leadSpace, { signal: this.opts.signal });
1032
+ say(` verifying ${project.lead}'s done: ${verify.commands.map((c) => c.name).join(", ")}`);
1033
+ const out = await runVerify(verify, runDir, state.verify, { cwd, signal: this.opts.signal, ...this.opts.verifyTiming }, {
1034
+ start: (name) => log("verify-start", { name }),
1035
+ end: (c) => log("verify-end", { name: c.name, exit: c.exit, ms: c.ms, timed_out: c.timed_out, ...(c.error ? { error: c.error } : {}) }),
1036
+ });
1037
+ if (out.cancelled) {
1038
+ say(" verification cancelled: no verdict; the done report stays for the next resume");
1039
+ log("verify-cancelled", { source });
1040
+ return { kind: "cancelled" };
1041
+ }
1042
+ const record = { at: new Date().toISOString(), passed: out.passed, source, ...(diffHash ? { diff_hash: diffHash } : {}), commands: out.commands };
1043
+ state.verify = [...(state.verify ?? []), record];
1044
+ if (out.passed)
1045
+ return { kind: "passed", record, cwd };
1046
+ const failed = must(out.failed, "failed verify command");
1047
+ const spec = must(verify.commands.find((c) => c.name === failed.name), "verify command");
1048
+ const counted = state.verify_rejected?.includes(source) ?? false;
1049
+ const used = state.verify_rejections ?? 0;
1050
+ const cmp = compareWithLastRejection({ source, fingerprint: must(failed.fingerprint, "failure fingerprint"), hash: diffHash, worktree: this.leadSpace !== undefined, records: state.verify, rejected: state.verify_rejected });
1051
+ const segment = state.reopened?.length ?? 0;
1052
+ const left = verify.max_rejections - used;
1053
+ const action = repeatAction(cmp, { counted, stoppedBefore: stoppedInSegment(state.verify, source, segment), left });
1054
+ log("verify-repeat", { source, name: failed.name, fingerprint: failed.fingerprint, previous: cmp.previous ?? null, same_fingerprint: cmp.sameFingerprint, hash: cmp.hash, action, rejections_left: left });
1055
+ if (action === "limit") {
1056
+ const note = `Verification failed after ${used} rejection(s): \`${failed.name}\` (\`${spec.run}\`) ${describeFailure(failed, spec.timeout_sec)}; see ${failed.log}. Reported as partial; the lead's report is kept as written.`;
1057
+ log("verify-failed", { name: failed.name, exit: failed.exit });
1058
+ return { kind: "exhausted", record, cwd, failure: { note } };
1059
+ }
1060
+ if (action === "stop") {
1061
+ // The verdict is stored before anything else, so a replay after a crash keeps it even if the command's output differs this time.
1062
+ record.repeat_stop_segment = segment;
1063
+ const note = repeatStopNote(failed.name, spec.run, describeFailure(failed, spec.timeout_sec), failed.log, used, verify.max_rejections);
1064
+ this.saveState();
1065
+ fault("verify:stopped");
1066
+ log("verify-failed", { name: failed.name, exit: failed.exit, repeat: true });
1067
+ say(` done report not sent back: ${failed.name} failed the same way as last time and the workspace has not changed; accepted as partial`);
1068
+ return { kind: "exhausted", record, cwd, failure: { note } };
1069
+ }
1070
+ // Counted once per done file: the count and its source id are saved before the mail goes out, so a replay after a crash neither counts nor sends twice.
1071
+ if (!counted) {
1072
+ state.verify_rejections = used + 1;
1073
+ state.verify_rejected = [...(state.verify_rejected ?? []), source];
1074
+ }
1075
+ this.saveState();
1076
+ fault("verify:counted");
1077
+ deliverOnce(project, this.journal(), `verify-reject:${source}`, {
1078
+ from: "dispatcher",
1079
+ to: project.lead,
1080
+ type: "failure",
1081
+ subject: "Your done report failed verification",
1082
+ body: withRepeatLabel(action, cmp.hash, rejectionMessage(failed, spec.run, out.tails[failed.name] ?? "", spec.timeout_sec, state.verify_rejections ?? used, verify.max_rejections)),
1083
+ });
1084
+ fault("verify:sent");
1085
+ rejectDone(done.file);
1086
+ say(` done report sent back to ${project.lead}: ${failed.name} ${describeFailure(failed, spec.timeout_sec)}`);
1087
+ log("verify-rejected", { name: failed.name, exit: failed.exit, count: state.verify_rejections });
1088
+ return { kind: "rejected" };
1089
+ }
990
1090
  /** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
991
1091
  deliverAnswers() {
992
1092
  const { runDir, project, log } = this;
@@ -1126,7 +1226,7 @@ export class RunSession {
1126
1226
  return result;
1127
1227
  }
1128
1228
  /** On resume: sort out what the interrupted process left behind before anything is woken. */
1129
- recover() {
1229
+ async recover() {
1130
1230
  const { say, runId, runDir, project, wsMode, log } = this;
1131
1231
  if (this.resume?.end_reason === "waiting")
1132
1232
  this.deliverAnswers();
@@ -1149,14 +1249,14 @@ export class RunSession {
1149
1249
  }
1150
1250
  for (const c of rec.abandoned)
1151
1251
  say(` ${c.agent}'s unfinished wake-up is redone: its ${c.message_ids.length} message(s) stay unread`);
1152
- this.settle([], []);
1252
+ await this.settle([], []);
1153
1253
  // Wake-ups that had finished before the crash: their output is routed now, so their input is consumed.
1154
1254
  for (const c of rec.ready)
1155
1255
  commitClaim(runDir, c.id);
1156
1256
  }
1157
1257
  async run() {
1158
1258
  if (this.resume)
1159
- this.recover();
1259
+ await this.recover();
1160
1260
  const { say, runId, runDir, wsMode, log, state, opts } = this;
1161
1261
  let project = this.project;
1162
1262
  // When the lead was the last one woken and left every mailbox empty without a `done`, remind it once instead of ending silently.
@@ -1241,7 +1341,7 @@ export class RunSession {
1241
1341
  leadWasLast = awake.some((a) => a.name === project.lead);
1242
1342
  if (awake.some((a) => a.name !== project.lead))
1243
1343
  nudged = false;
1244
- this.settle(awake, results);
1344
+ await this.settle(awake, results);
1245
1345
  if (!this.endReason && opts.signal?.aborted)
1246
1346
  this.endReason = "cancelled";
1247
1347
  }
@@ -1264,8 +1364,9 @@ export class RunSession {
1264
1364
  }
1265
1365
  log("end", { reason: endReason, outcome, rounds: state.rounds });
1266
1366
  this.saveState();
1367
+ fault("run:ended");
1267
1368
  if (this.pendingDoneFile)
1268
1369
  finishDone(this.pendingDoneFile);
1269
- return { runId, name: state.name, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr, project: project.name, failure: this.failure };
1370
+ return { runId, name: state.name, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, ...(this.dispatcherVerification ? { dispatcherVerification: this.dispatcherVerification } : {}), doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr, project: project.name, failure: this.failure };
1270
1371
  }
1271
1372
  }
@@ -68,6 +68,29 @@ export interface RunFailure {
68
68
  attempts: number;
69
69
  tried?: TriedModel[];
70
70
  }
71
+ /** What one verify command did (spec 27.7). `log` is relative to the run dir. */
72
+ export interface VerifyCommandRecord {
73
+ name: string;
74
+ exit: number | null;
75
+ ms: number;
76
+ timed_out: boolean;
77
+ log: string;
78
+ /** Set when the command could not be started (for example no shell). */
79
+ error?: string;
80
+ /** spec 28: hash of the failure (name, status, normalised last lines); only on a failed command. */
81
+ fingerprint?: string;
82
+ }
83
+ /** One verification of a lead's `done` (spec 27.7). `diff_hash` is the git tree of the lead's workspace when it started; absent outside a git repo. */
84
+ export interface VerifyRecord {
85
+ at: string;
86
+ passed: boolean;
87
+ /** The `done` mail's source id. */
88
+ source: string;
89
+ diff_hash?: string;
90
+ /** spec 28: this done was stopped as a repeat, in this segment (state.reopened length). */
91
+ repeat_stop_segment?: number;
92
+ commands: VerifyCommandRecord[];
93
+ }
71
94
  export interface RunState {
72
95
  /** 1 for runs written before versioning; 2 for runs with their own mailboxes. */
73
96
  schema_version: number;
@@ -90,6 +113,12 @@ export interface RunState {
90
113
  verification?: string;
91
114
  /** How many `done` mails were sent back because they broke the completion contract. */
92
115
  done_rejections?: number;
116
+ /** Spec 27: one record per verification the dispatcher ran, oldest first (kept across reopens). */
117
+ verify?: VerifyRecord[];
118
+ /** Spec 27: `done` reports sent back because a verify command failed, in the current segment (reset at reopen). */
119
+ verify_rejections?: number;
120
+ /** Spec 27: source ids of the `done` files already counted in `verify_rejections`, so a replay does not count twice. */
121
+ verify_rejected?: string[];
93
122
  /** How many malformed `ask` mails each agent had sent back (the limit is MAX_ASK_REJECTIONS). */
94
123
  ask_rejections?: Record<string, number>;
95
124
  /** Recovery remarks (interrupted attempts whose side effects may have happened). */
package/dist/run-store.js CHANGED
@@ -52,6 +52,9 @@ const WireState = z
52
52
  outcome_note: z.string().optional(),
53
53
  verification: z.string().optional(),
54
54
  done_rejections: nonNeg.optional(),
55
+ verify: z.array(z.any()).optional(),
56
+ verify_rejections: nonNeg.optional(),
57
+ verify_rejected: z.array(z.string()).optional(),
55
58
  notes: z.array(z.string()).optional(),
56
59
  ask_rejections: z.record(z.string(), nonNeg).optional(),
57
60
  sessions: z.record(z.string(), z.string()).optional(),
package/dist/schema.d.ts CHANGED
@@ -191,6 +191,32 @@ export declare const GlobalConfig: z.ZodObject<{
191
191
  owns: z.ZodOptional<z.ZodUnknown>;
192
192
  }, z.core.$strict>>>;
193
193
  }, z.core.$strict>;
194
+ /** Spec 27: commands the dispatcher runs before it accepts a lead's `completed` done. Only the user writes them (project.yaml). */
195
+ export declare const VerifyCommandPartial: z.ZodObject<{
196
+ name: z.ZodString;
197
+ run: z.ZodString;
198
+ timeout_sec: z.ZodOptional<z.ZodNumber>;
199
+ }, z.core.$strict>;
200
+ export declare const VerifyPartial: z.ZodObject<{
201
+ max_rejections: z.ZodOptional<z.ZodNumber>;
202
+ commands: z.ZodDefault<z.ZodArray<z.ZodObject<{
203
+ name: z.ZodString;
204
+ run: z.ZodString;
205
+ timeout_sec: z.ZodOptional<z.ZodNumber>;
206
+ }, z.core.$strict>>>;
207
+ }, z.core.$strict>;
208
+ export interface VerifySettings {
209
+ max_rejections: number;
210
+ commands: {
211
+ name: string;
212
+ run: string;
213
+ timeout_sec: number;
214
+ }[];
215
+ }
216
+ export declare const VERIFY_DEFAULTS: {
217
+ readonly max_rejections: 2;
218
+ readonly timeout_sec: 600;
219
+ };
194
220
  export declare const ProjectConfig: z.ZodObject<{
195
221
  dir: z.ZodString;
196
222
  team: z.ZodObject<{
@@ -200,6 +226,14 @@ export declare const ProjectConfig: z.ZodObject<{
200
226
  "claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
201
227
  codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
202
228
  }, z.core.$strict>>;
229
+ verify: z.ZodOptional<z.ZodObject<{
230
+ max_rejections: z.ZodOptional<z.ZodNumber>;
231
+ commands: z.ZodDefault<z.ZodArray<z.ZodObject<{
232
+ name: z.ZodString;
233
+ run: z.ZodString;
234
+ timeout_sec: z.ZodOptional<z.ZodNumber>;
235
+ }, z.core.$strict>>>;
236
+ }, z.core.$strict>>;
203
237
  dispatcher: z.ZodDefault<z.ZodObject<{
204
238
  max_rounds: z.ZodOptional<z.ZodNumber>;
205
239
  max_parallel: z.ZodOptional<z.ZodUnknown>;
package/dist/schema.js CHANGED
@@ -99,11 +99,35 @@ export const GlobalConfig = z
99
99
  agents: z.record(z.string(), AgentPartial).default({}),
100
100
  })
101
101
  .strict();
102
+ /** Spec 27: commands the dispatcher runs before it accepts a lead's `completed` done. Only the user writes them (project.yaml). */
103
+ export const VerifyCommandPartial = z
104
+ .object({
105
+ name: z.string().regex(/^[a-z0-9-]+$/, "must be lowercase letters, digits and '-'"),
106
+ run: z.string().min(1),
107
+ timeout_sec: z.number().int().min(1).max(7200).optional(),
108
+ })
109
+ .strict();
110
+ export const VerifyPartial = z
111
+ .object({
112
+ max_rejections: z.number().int().min(0).max(5).optional(),
113
+ commands: z.array(VerifyCommandPartial).default([]),
114
+ })
115
+ .strict()
116
+ .superRefine((v, ctx) => {
117
+ const seen = new Set();
118
+ v.commands.forEach((c, i) => {
119
+ if (seen.has(c.name))
120
+ ctx.addIssue({ code: "custom", path: ["commands", i, "name"], message: `"${c.name}" is used twice; names must be unique` });
121
+ seen.add(c.name);
122
+ });
123
+ });
124
+ export const VERIFY_DEFAULTS = { max_rejections: 2, timeout_sec: 600 };
102
125
  export const ProjectConfig = z
103
126
  .object({
104
127
  dir: z.string(),
105
128
  team: z.object({ lead: z.string() }).strict(),
106
129
  model_tiers: ModelTiersPartial.optional(),
130
+ verify: VerifyPartial.optional(),
107
131
  dispatcher: DispatcherPartial.default({}),
108
132
  agents: z.record(z.string(), AgentPartial).default({}),
109
133
  })
package/dist/status.js CHANGED
@@ -227,7 +227,7 @@ export function buildRunReport(run, project, opts = {}) {
227
227
  ask = { path: askReplyPath(run.dir), total: 0, unanswered: 0 };
228
228
  }
229
229
  }
230
- return {
230
+ const report = {
231
231
  run_id: s.run_id,
232
232
  name: s.name,
233
233
  dir: run.dir,
@@ -265,7 +265,13 @@ export function buildRunReport(run, project, opts = {}) {
265
265
  ...(s.instances ? { instances: s.instances.roles } : {}),
266
266
  ...(ask ? { ask } : {}),
267
267
  };
268
+ const lastVerify = s.verify?.at(-1);
269
+ if (lastVerify)
270
+ lastVerifyOf.set(report, lastVerify); // human output only: `status --json` stays as it was
271
+ return report;
268
272
  }
273
+ /** The latest dispatcher verification (spec 27), kept off the report so `status --json` is unchanged. */
274
+ const lastVerifyOf = new WeakMap();
269
275
  /** A run that ended with a `completed` or `partial` outcome: the only kinds the monitor stops listing (blocked, failed and cancelled stay). */
270
276
  export const isFinishedRun = (r) => r.state === "ended" && (r.outcome === "completed" || r.outcome === "partial");
271
277
  const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
@@ -400,6 +406,9 @@ function summaryBlock(run, now, c) {
400
406
  if (s.end_reason === "done" && s.result_path && s.result_head.length) {
401
407
  lines.push(`${L("結果:")}${s.result_path}`, ...s.result_head.map((l) => c.dim(" " + clip(l, 80))));
402
408
  }
409
+ const dv = lastVerifyOf.get(s);
410
+ if (dv)
411
+ lines.push(`${L("dispatcher 驗證:")}${dv.passed ? c.green("通過") : c.red("未通過")}(${dv.commands.map((x) => `${x.name} ${x.timed_out ? "逾時" : x.error ? "無法執行" : `exit ${x.exit}`}`).join("、")};${dv.at})`);
403
412
  const lastWake = (s.wakes ?? []).at(-1);
404
413
  if (lastWake)
405
414
  lines.push(`${L("上一次:")}#${lastWake.round} ${c.cyan(lastWake.agent)} ${okText(c, lastWake.ok)} → ${sentText(lastWake.sent)}`);
@@ -0,0 +1,67 @@
1
+ import type { VerifyCommandRecord, VerifyRecord } from "./run-store.js";
2
+ import type { VerifySettings } from "./schema.js";
3
+ /** Most output kept in a verify log; a longer output keeps its end and says so at the top. */
4
+ export declare const VERIFY_LOG_BYTES: number;
5
+ /** Most output of one command put into the mail to the lead and into the report. */
6
+ export declare const VERIFY_TAIL_BYTES = 4096;
7
+ export declare const VERIFY_DIR = "verify";
8
+ /** The last `max` bytes of `buf` as text, never starting in the middle of a UTF-8 character. */
9
+ export declare function tailText(buf: Buffer, max: number): string;
10
+ export interface RunCommandOptions {
11
+ cwd: string;
12
+ timeoutSec: number;
13
+ signal?: AbortSignal;
14
+ /** Called with each chunk of output as it arrives (a readiness barrier for tests). */
15
+ onOutput?: (chunk: Buffer) => void;
16
+ /** Between SIGTERM and SIGKILL. Default 5000. */
17
+ killGraceMs?: number;
18
+ /** How long to wait for the output pipes after the shell is gone. Default 1000. */
19
+ settleWaitMs?: number;
20
+ }
21
+ export type CommandResult = {
22
+ cancelled: true;
23
+ } | {
24
+ cancelled: false;
25
+ exit: number | null;
26
+ ms: number;
27
+ timedOut: boolean;
28
+ error?: string;
29
+ output: Buffer;
30
+ };
31
+ /**
32
+ * Run one command with `sh -c` in its own process group: stdin closed, output (both streams, in arrival order) kept in memory up to
33
+ * 2 x VERIFY_LOG_BYTES. Timeout and cancel stop the whole tree (SIGTERM, SIGKILL after `killGraceMs`); this settles once, whatever
34
+ * still holds the pipes. The child is registered with the process runner, so `killAllRunning` takes it down when agent-lyceum exits.
35
+ */
36
+ export declare function runCommand(run: string, opts: RunCommandOptions): Promise<CommandResult>;
37
+ /** The output as it goes into the log file: at most VERIFY_LOG_BYTES, with a first line saying so when the start was cut. */
38
+ export declare function logBody(output: Buffer): Buffer;
39
+ /** The next free log number: above every record and every file already in `verify/`, so no attempt (also not an unrecorded one from a crash) is overwritten. */
40
+ export declare function nextLogNumber(runDir: string, records: VerifyRecord[] | undefined): number;
41
+ export type VerifyOutcome = {
42
+ cancelled: true;
43
+ } | {
44
+ cancelled: false;
45
+ passed: boolean;
46
+ commands: VerifyCommandRecord[];
47
+ tails: Record<string, string>;
48
+ failed?: VerifyCommandRecord;
49
+ };
50
+ export interface VerifyHooks {
51
+ start?: (name: string) => void;
52
+ end?: (c: VerifyCommandRecord) => void;
53
+ }
54
+ /** Run the commands in order, stopping at the first failure (fail-fast). Cancelling gives no verdict. */
55
+ export declare function runVerify(verify: VerifySettings, runDir: string, records: VerifyRecord[] | undefined, opts: {
56
+ cwd: string;
57
+ signal?: AbortSignal;
58
+ killGraceMs?: number;
59
+ settleWaitMs?: number;
60
+ }, hooks?: VerifyHooks): Promise<VerifyOutcome>;
61
+ export declare const commandPassed: (c: VerifyCommandRecord) => boolean;
62
+ /** How a failed command reads: "exit 1", "timed out after 600s", ... */
63
+ export declare function describeFailure(c: VerifyCommandRecord, timeoutSec?: number): string;
64
+ /** `## Dispatcher verification`: appended by the dispatcher after the lead's own report. */
65
+ export declare function verificationSection(rec: VerifyRecord, verify: VerifySettings, cwd: string): string;
66
+ /** The mail that sends a failed `done` back to the lead. */
67
+ export declare function rejectionMessage(failed: VerifyCommandRecord, run: string, tail: string, timeoutSec: number, count: number, max: number): string;
package/dist/verify.js ADDED
@@ -0,0 +1,184 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { atomicWrite } from "./fs-util.js";
5
+ import { killTree, register, unregister } from "./process-runner.js";
6
+ import { FINGERPRINT_WINDOW_BYTES, failureFingerprint, fingerprintPaths } from "./repeat-detect.js";
7
+ /** Most output kept in a verify log; a longer output keeps its end and says so at the top. */
8
+ export const VERIFY_LOG_BYTES = 1024 * 1024;
9
+ /** Most output of one command put into the mail to the lead and into the report. */
10
+ export const VERIFY_TAIL_BYTES = 4096;
11
+ export const VERIFY_DIR = "verify";
12
+ /** The last `max` bytes of `buf` as text, never starting in the middle of a UTF-8 character. */
13
+ export function tailText(buf, max) {
14
+ if (buf.length <= max)
15
+ return buf.toString("utf8");
16
+ let start = buf.length - max;
17
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80)
18
+ start++;
19
+ return buf.subarray(start).toString("utf8");
20
+ }
21
+ /**
22
+ * Run one command with `sh -c` in its own process group: stdin closed, output (both streams, in arrival order) kept in memory up to
23
+ * 2 x VERIFY_LOG_BYTES. Timeout and cancel stop the whole tree (SIGTERM, SIGKILL after `killGraceMs`); this settles once, whatever
24
+ * still holds the pipes. The child is registered with the process runner, so `killAllRunning` takes it down when agent-lyceum exits.
25
+ */
26
+ export function runCommand(run, opts) {
27
+ const grace = opts.killGraceMs ?? 5000;
28
+ const settleWait = opts.settleWaitMs ?? 1000;
29
+ if (opts.signal?.aborted)
30
+ return Promise.resolve({ cancelled: true });
31
+ return new Promise((resolve) => {
32
+ const started = Date.now();
33
+ let child;
34
+ try {
35
+ child = spawn("sh", ["-c", run], { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
36
+ }
37
+ catch (e) {
38
+ return resolve({ cancelled: false, exit: null, ms: 0, timedOut: false, error: e.message, output: Buffer.alloc(0) });
39
+ }
40
+ if (child.pid !== undefined)
41
+ register(child.pid);
42
+ let buf = Buffer.alloc(0);
43
+ let dropped = 0;
44
+ let exit = null;
45
+ let reason;
46
+ let spawnError;
47
+ let settled = false;
48
+ const timers = [];
49
+ const kill = (sig) => {
50
+ if (child.pid !== undefined)
51
+ killTree(child.pid, sig);
52
+ };
53
+ const finish = () => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ for (const t of timers)
58
+ clearTimeout(t);
59
+ opts.signal?.removeEventListener("abort", onAbort);
60
+ kill("SIGKILL"); // whatever of the tree is still around
61
+ if (child.pid !== undefined)
62
+ unregister(child.pid);
63
+ child.stdout?.destroy();
64
+ child.stderr?.destroy();
65
+ // An abort that came after the process was gone but before the result was judged also withholds the verdict.
66
+ if (reason === "cancel" || opts.signal?.aborted)
67
+ return resolve({ cancelled: true });
68
+ const out = dropped ? Buffer.concat([Buffer.from(`[agent-lyceum: ${dropped} byte(s) of earlier output dropped]\n`), buf]) : buf;
69
+ resolve({ cancelled: false, exit, ms: Date.now() - started, timedOut: reason === "timeout", ...(spawnError ? { error: spawnError } : {}), output: out });
70
+ };
71
+ const stop = (why) => {
72
+ if (settled)
73
+ return;
74
+ // Cancel outranks a timeout that is still cleaning up: the verdict is withheld either way, and the tree is already being stopped.
75
+ if (reason) {
76
+ if (why === "cancel")
77
+ reason = "cancel";
78
+ return;
79
+ }
80
+ reason = why;
81
+ kill("SIGTERM");
82
+ timers.push(setTimeout(() => kill("SIGKILL"), grace));
83
+ timers.push(setTimeout(finish, grace + settleWait));
84
+ };
85
+ function onAbort() {
86
+ stop("cancel");
87
+ }
88
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
89
+ timers.push(setTimeout(() => stop("timeout"), opts.timeoutSec * 1000));
90
+ const onData = (chunk) => {
91
+ opts.onOutput?.(chunk);
92
+ buf = Buffer.concat([buf, chunk]);
93
+ if (buf.length > VERIFY_LOG_BYTES * 2) {
94
+ let start = buf.length - VERIFY_LOG_BYTES;
95
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80)
96
+ start++;
97
+ dropped += start;
98
+ buf = buf.subarray(start);
99
+ }
100
+ };
101
+ child.stdout?.on("data", onData);
102
+ child.stderr?.on("data", onData);
103
+ child.on("error", (e) => {
104
+ spawnError = e.code === "ENOENT" ? `${e.message} (sh or the working directory ${opts.cwd} not found)` : e.message;
105
+ finish();
106
+ });
107
+ child.on("exit", (code) => {
108
+ exit = code;
109
+ timers.push(setTimeout(finish, settleWait)); // a descendant may still hold the pipes
110
+ });
111
+ child.on("close", finish);
112
+ });
113
+ }
114
+ /** The output as it goes into the log file: at most VERIFY_LOG_BYTES, with a first line saying so when the start was cut. */
115
+ export function logBody(output) {
116
+ if (output.length <= VERIFY_LOG_BYTES)
117
+ return output;
118
+ let start = output.length - VERIFY_LOG_BYTES;
119
+ while (start < output.length && (output[start] & 0xc0) === 0x80)
120
+ start++;
121
+ return Buffer.concat([Buffer.from(`[agent-lyceum: output truncated; the first ${start} byte(s) are not kept, the last ${output.length - start} follow]\n`), output.subarray(start)]);
122
+ }
123
+ /** The next free log number: above every record and every file already in `verify/`, so no attempt (also not an unrecorded one from a crash) is overwritten. */
124
+ export function nextLogNumber(runDir, records) {
125
+ let max = 0;
126
+ for (const r of records ?? [])
127
+ for (const c of r.commands)
128
+ max = Math.max(max, Number(/^verify\/(\d+)-/.exec(c.log)?.[1] ?? 0));
129
+ try {
130
+ for (const f of fs.readdirSync(path.join(runDir, VERIFY_DIR)))
131
+ max = Math.max(max, Number(/^(\d+)-/.exec(f)?.[1] ?? 0));
132
+ }
133
+ catch {
134
+ /* no verify dir yet */
135
+ }
136
+ return max + 1;
137
+ }
138
+ /** Run the commands in order, stopping at the first failure (fail-fast). Cancelling gives no verdict. */
139
+ export async function runVerify(verify, runDir, records, opts, hooks = {}) {
140
+ const commands = [];
141
+ const tails = {};
142
+ let n = nextLogNumber(runDir, records);
143
+ for (const c of verify.commands) {
144
+ const rel = `${VERIFY_DIR}/${n++}-${c.name}.log`;
145
+ hooks.start?.(c.name);
146
+ const r = await runCommand(c.run, { cwd: opts.cwd, timeoutSec: c.timeout_sec, signal: opts.signal, killGraceMs: opts.killGraceMs, settleWaitMs: opts.settleWaitMs });
147
+ if (r.cancelled)
148
+ return { cancelled: true };
149
+ const body = logBody(r.output);
150
+ fs.mkdirSync(path.join(runDir, VERIFY_DIR), { recursive: true });
151
+ atomicWrite(path.join(runDir, rel), body);
152
+ const rec = { name: c.name, exit: r.exit, ms: r.ms, timed_out: r.timedOut, log: rel, ...(r.error ? { error: r.error } : {}) };
153
+ if (!commandPassed(rec))
154
+ rec.fingerprint = failureFingerprint(rec, tailText(r.output, FINGERPRINT_WINDOW_BYTES), fingerprintPaths(opts.cwd, runDir));
155
+ commands.push(rec);
156
+ tails[c.name] = tailText(r.output, VERIFY_TAIL_BYTES).replace(/\r\n/g, "\n");
157
+ hooks.end?.(rec);
158
+ if (!commandPassed(rec))
159
+ return { cancelled: false, passed: false, commands, tails, failed: rec };
160
+ }
161
+ if (opts.signal?.aborted)
162
+ return { cancelled: true };
163
+ return { cancelled: false, passed: true, commands, tails };
164
+ }
165
+ export const commandPassed = (c) => c.exit === 0 && !c.timed_out && !c.error;
166
+ /** How a failed command reads: "exit 1", "timed out after 600s", ... */
167
+ export function describeFailure(c, timeoutSec) {
168
+ if (c.error)
169
+ return `could not be run (${c.error})`;
170
+ if (c.timed_out)
171
+ return `timed out${timeoutSec ? ` after ${timeoutSec}s` : ""} and was killed`;
172
+ return c.exit === null ? "was killed by a signal" : `exited with code ${c.exit}`;
173
+ }
174
+ const secs = (ms) => `${(ms / 1000).toFixed(1)}s`;
175
+ /** `## Dispatcher verification`: appended by the dispatcher after the lead's own report. */
176
+ export function verificationSection(rec, verify, cwd) {
177
+ const lines = rec.commands.map((c) => `- \`${c.name}\`: ${commandPassed(c) ? "passed" : `FAILED, ${describeFailure(c, verify.commands.find((x) => x.name === c.name)?.timeout_sec)}`} (${secs(c.ms)}, log ${c.log})`);
178
+ const skipped = verify.commands.slice(rec.commands.length).map((c) => `- \`${c.name}\`: not run (an earlier command failed)`);
179
+ return `## Dispatcher verification\n\nRun by the dispatcher in ${cwd}, not by an agent: ${rec.passed ? "all commands passed" : "NOT all commands passed"}.\n\n${[...lines, ...skipped].join("\n")}\n`;
180
+ }
181
+ /** The mail that sends a failed `done` back to the lead. */
182
+ export function rejectionMessage(failed, run, tail, timeoutSec, count, max) {
183
+ return `The run was not ended: before accepting your \`done\` (outcome completed) the dispatcher ran the project's verify commands, and one failed.\n\nCommand \`${failed.name}\` (\`${run}\`) ${describeFailure(failed, timeoutSec)}. Later commands were not run.\n\nLast output (at most ${VERIFY_TAIL_BYTES} bytes; the full log is ${failed.log} in the run directory):\n\n\`\`\`\n${tail.trimEnd()}\n\`\`\`\n\nFix the cause, then send \`done\` again. If the work cannot pass, report \`partial\` or \`blocked\` honestly (those are not verified). (Verification rejection ${count} of ${max}.)`;
184
+ }
@@ -81,6 +81,15 @@ export declare function snapshotHead(project: ResolvedProject, runId: string): s
81
81
  * `from`: the checkout to snapshot — the lead's worktree in a concurrent run; by default your repo's working tree.
82
82
  */
83
83
  export declare function snapshotBase(project: ResolvedProject, runId: string, n: number, from?: AgentWorkspace): string;
84
+ /**
85
+ * The git tree of the working tree as it is now (tracked changes and new, non-ignored files), through a throwaway index:
86
+ * nothing of the repo is touched. A worktree is read with the pinned git (no hooks, fsmonitor, filters from the worktree).
87
+ * Undefined when `dir` is not a git repository or the tree cannot be read (a nested repository in a worktree, ...).
88
+ */
89
+ export declare function workingTreeHash(project: ResolvedProject, from?: AgentWorkspace, opts?: {
90
+ signal?: AbortSignal;
91
+ timeoutMs?: number;
92
+ }): Promise<string | undefined>;
84
93
  export declare const workspaceRoot: (project: ResolvedProject, runId: string, agent: string) => string;
85
94
  /**
86
95
  * The agent's own checkout of the snapshot `baseRef`, at `<project home>/worktrees/<run>/<agent>`.
package/dist/worktree.js CHANGED
@@ -1,10 +1,11 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawn } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { atomicWrite } from "./fs-util.js";
7
7
  import { escapesParent } from "./paths.js";
8
+ import { killTree, register, unregister } from "./process-runner.js";
8
9
  const IDENTITY = {
9
10
  GIT_AUTHOR_NAME: "agent-lyceum",
10
11
  GIT_AUTHOR_EMAIL: "agent-lyceum@localhost",
@@ -291,6 +292,84 @@ export function snapshotBase(project, runId, n, from) {
291
292
  fs.rmSync(index, { force: true });
292
293
  }
293
294
  }
295
+ /**
296
+ * The git tree of the working tree as it is now (tracked changes and new, non-ignored files), through a throwaway index:
297
+ * nothing of the repo is touched. A worktree is read with the pinned git (no hooks, fsmonitor, filters from the worktree).
298
+ * Undefined when `dir` is not a git repository or the tree cannot be read (a nested repository in a worktree, ...).
299
+ */
300
+ export async function workingTreeHash(project, from, opts = {}) {
301
+ const index = path.join(os.tmpdir(), `agent-lyceum-index-${crypto.randomBytes(6).toString("hex")}`);
302
+ const deadline = Date.now() + (opts.timeoutMs ?? HASH_BUDGET_MS);
303
+ // No hooks (git runs post-index-change after writing an index, also a temporary one) and no fsmonitor, in both modes.
304
+ // Clean filters named in the repo's own config still run during `add -A` (as in snapshotBase); only the deadline bounds them.
305
+ const run = (cwd, args, env = {}) => boundedGit(cwd, from ? pinnedArgs(args) : ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ...args], from ? pinnedEnv(from, env) : env, deadline, opts.signal);
306
+ try {
307
+ let cwd;
308
+ if (from) {
309
+ assertWorkspaceIntact(from);
310
+ assertNoNestedRepos(from); // sync, no hooks, quick
311
+ cwd = from.root;
312
+ }
313
+ else
314
+ cwd = await run(project.dir, ["rev-parse", "--show-toplevel"]);
315
+ const env = { GIT_INDEX_FILE: index };
316
+ const head = await run(cwd, ["rev-parse", "--verify", "HEAD"]).catch(() => undefined);
317
+ if (head)
318
+ await run(cwd, ["read-tree", head], env);
319
+ await run(cwd, ["add", "-A"], env);
320
+ return await run(cwd, ["write-tree"], env);
321
+ }
322
+ catch {
323
+ return undefined;
324
+ }
325
+ finally {
326
+ fs.rmSync(index, { force: true });
327
+ fs.rmSync(`${index}.lock`, { force: true }); // a git killed during `add` leaves its lock behind
328
+ }
329
+ }
330
+ /** The most the tree hash may take in total (spec 27.7 record, spec 28 comparison); then it is absent and verification goes on. */
331
+ const HASH_BUDGET_MS = 30_000;
332
+ /** git in its own process group, killed with SIGKILL (group and descendants) at `deadline` or on `signal`; the event loop stays free meanwhile. */
333
+ function boundedGit(cwd, args, env, deadline, signal) {
334
+ return new Promise((resolve, reject) => {
335
+ if (signal?.aborted)
336
+ return reject(new Error("aborted"));
337
+ const child = spawn("git", args, { cwd, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
338
+ if (child.pid !== undefined)
339
+ register(child.pid);
340
+ let out = "";
341
+ let err = "";
342
+ let done = false;
343
+ const end = (e) => {
344
+ if (done)
345
+ return;
346
+ done = true;
347
+ clearTimeout(timer);
348
+ signal?.removeEventListener("abort", onAbort);
349
+ if (child.pid !== undefined) {
350
+ killTree(child.pid, "SIGKILL"); // also what a filter left behind
351
+ unregister(child.pid);
352
+ }
353
+ child.stdout?.destroy();
354
+ child.stderr?.destroy();
355
+ if (e)
356
+ reject(e);
357
+ else
358
+ resolve(out.replace(/\n$/, ""));
359
+ };
360
+ const onAbort = () => end(new Error("aborted"));
361
+ const timer = setTimeout(() => end(new Error("timed out")), Math.max(0, deadline - Date.now()));
362
+ signal?.addEventListener("abort", onAbort, { once: true });
363
+ child.stdout?.on("data", (c) => {
364
+ out += c.toString("utf8");
365
+ });
366
+ child.stderr?.on("data", (c) => {
367
+ err += c.toString("utf8").slice(0, 4096);
368
+ });
369
+ child.on("error", (e) => end(e));
370
+ child.on("close", (code) => end(code === 0 ? undefined : new Error(`git ${args.join(" ")} failed: ${err.trim()}`)));
371
+ });
372
+ }
294
373
  export const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
295
374
  /** The user's repo's git common dir, as a real absolute path. Asked of the user's repo, never of a worktree. */
296
375
  const commonDirOf = (top) => fs.realpathSync(git(top, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",