agent-lyceum 0.31.1 → 0.33.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. */