faberun 0.10.0 → 0.12.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.
Files changed (60) hide show
  1. package/README.md +6 -0
  2. package/integrations/claude-code/statusline.sh +31 -2
  3. package/package.json +2 -2
  4. package/skills/faberun/references/spec-format.md +9 -0
  5. package/src/campaign/chain.mjs +2 -1
  6. package/src/campaign/index.mjs +102 -1
  7. package/src/campaign/metrics.mjs +2 -2
  8. package/src/cli/brand.mjs +2 -0
  9. package/src/cli/campaign-contract.mjs +55 -0
  10. package/src/cli/campaign.mjs +48 -13
  11. package/src/cli/init.mjs +8 -2
  12. package/src/cli/launch.mjs +2 -1
  13. package/src/cli/plan.mjs +3 -2
  14. package/src/cli/project.mjs +150 -0
  15. package/src/cli/skills.mjs +2 -3
  16. package/src/cli/update.mjs +7 -10
  17. package/src/cli.mjs +30 -4
  18. package/src/contract/index.mjs +44 -0
  19. package/src/contract/task-packet.mjs +12 -3
  20. package/src/engine/cancel.mjs +13 -0
  21. package/src/engine/dispatch.mjs +20 -2
  22. package/src/engine/lifecycle.mjs +38 -61
  23. package/src/engine/live-preflight.mjs +2 -1
  24. package/src/engine/notify-queue.mjs +9 -1
  25. package/src/engine/pricing-seed.json +543 -0
  26. package/src/engine/pricing-seed.mjs +54 -0
  27. package/src/engine/process.mjs +8 -1
  28. package/src/engine/result-file.mjs +2 -1
  29. package/src/engine/resume.mjs +6 -2
  30. package/src/engine/run-identity.mjs +9 -2
  31. package/src/engine/scheduler.mjs +40 -6
  32. package/src/engine/settle-judge.mjs +141 -0
  33. package/src/engine/settle.mjs +1 -1
  34. package/src/harnesses/protocol.mjs +46 -10
  35. package/src/harnesses/replay/bin.mjs +10 -1
  36. package/src/host/home.mjs +10 -3
  37. package/src/host/platform.mjs +111 -0
  38. package/src/host/preflight.mjs +43 -21
  39. package/src/host/projects.mjs +155 -0
  40. package/src/notify/index.mjs +88 -10
  41. package/src/plan/pipeline.mjs +32 -9
  42. package/src/plan/repo-facts.mjs +80 -2
  43. package/src/plan/spec.mjs +2 -1
  44. package/src/repo/declared-paths.mjs +7 -1
  45. package/src/repo/integrate.mjs +1 -1
  46. package/src/repo/scope-closure.mjs +2 -1
  47. package/src/repo/signal.mjs +24 -7
  48. package/src/repo/source-identity.mjs +7 -0
  49. package/src/repo/workspace.mjs +12 -2
  50. package/src/repo/worktree.mjs +89 -22
  51. package/src/report/next.mjs +66 -6
  52. package/src/report/progress.mjs +724 -0
  53. package/src/report/render.mjs +3 -3
  54. package/src/run/migrate.mjs +278 -0
  55. package/src/run/paths.mjs +189 -0
  56. package/src/seat/index.mjs +2 -1
  57. package/src/web/app.css +170 -0
  58. package/src/web/app.mjs +690 -0
  59. package/src/web/index.html +38 -283
  60. package/src/web/server.mjs +197 -182
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `faberun project`: re-associate a project whose repository moved, keeping
3
+ * its id -- and everything that will hang off that id -- untouched.
4
+ *
5
+ * `src/host/projects.mjs` is out of this node's write scope, and its public
6
+ * surface (`registerProject`, `findProjectByPath`, `readProject`) has no
7
+ * operation that keeps an existing id while giving it a new path:
8
+ * `registerProject` either finds the id already at a path or mints a fresh
9
+ * one, and a fresh id is exactly what a move must not produce. This module
10
+ * therefore reads and rewrites `projects/index.json` and
11
+ * `projects/<id>/project.json` directly, with the same temp-then-rename
12
+ * discipline the registry itself uses, rather than inventing a second
13
+ * on-disk format for the same directory.
14
+ */
15
+ import { join } from "node:path";
16
+ import { readJson, writeJsonAtomic } from "../run/store.mjs";
17
+ import { errorCode } from "../util.mjs";
18
+ import { findProjectByPath, projectsDir, readProject, resolveIdentity } from "../host/projects.mjs";
19
+ import { boundedGitSync } from "../repo/worktree.mjs";
20
+
21
+ /** @typedef {import("../host/projects.mjs").ProjectRecord} ProjectRecord */
22
+
23
+ /**
24
+ * The path-to-id index, read straight off `index.json`. Inlined at each of
25
+ * its two call sites rather than pulled out as a shared top-level helper: the
26
+ * identical helper already lives, unexported, in `host/projects.mjs`, which
27
+ * this module cannot import from without that file entering its write scope.
28
+ *
29
+ * @param {string} indexPath
30
+ * @returns {Record<string, string>}
31
+ */
32
+ function readIndexAt(indexPath) {
33
+ try {
34
+ return /** @type {Record<string, string>} */ (readJson(indexPath));
35
+ } catch (error) {
36
+ if (errorCode(error) === "ENOENT") return {};
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Every registered project, read straight off disk. Used only to search by
43
+ * remote when the operator names no `--from`.
44
+ *
45
+ * @param {string} home
46
+ * @returns {ProjectRecord[]}
47
+ */
48
+ function listProjects(home) {
49
+ const index = readIndexAt(join(projectsDir(home), "index.json"));
50
+ const ids = new Set(Object.values(index));
51
+ /** @type {ProjectRecord[]} */
52
+ const projects = [];
53
+ for (const id of ids) {
54
+ const project = readProject(home, id);
55
+ if (project) projects.push(project);
56
+ }
57
+ return projects;
58
+ }
59
+
60
+ /**
61
+ * The remote URLs a git repository at `path` currently reports, or `[]` when
62
+ * `path` is not a git repository, has no remote, or git is unavailable. An
63
+ * operator who names `--from` never needs this at all.
64
+ *
65
+ * @param {string} path
66
+ * @returns {string[]}
67
+ */
68
+ export function readRemotes(path) {
69
+ const result = boundedGitSync(["-C", path, "remote", "-v"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
70
+ if (result.error || result.status !== 0) return [];
71
+ /** @type {Set<string>} */
72
+ const urls = new Set();
73
+ for (const line of /** @type {string} */ (result.stdout).split("\n")) {
74
+ const match = /^\S+\s+(\S+)\s+\(/u.exec(line);
75
+ if (match) urls.add(match[1]);
76
+ }
77
+ return [...urls];
78
+ }
79
+
80
+ /**
81
+ * The registered project whose remotes overlap what `remotesOf(path)` reports
82
+ * right now, or null when none does, or when `path` reports no remote at all.
83
+ * More than one match is refused by the caller, not decided here.
84
+ *
85
+ * @param {string} home
86
+ * @param {string} path
87
+ * @param {(path: string) => string[]} remotesOf
88
+ * @returns {ProjectRecord[]}
89
+ */
90
+ function projectsMatchingRemotes(home, path, remotesOf) {
91
+ const remotes = new Set(remotesOf(path));
92
+ if (remotes.size === 0) return [];
93
+ return listProjects(home).filter((project) => project.remotes.some((remote) => remotes.has(remote)));
94
+ }
95
+
96
+ /**
97
+ * @param {string} home
98
+ * @param {ProjectRecord} project
99
+ * @param {string} newPath
100
+ * @returns {ProjectRecord}
101
+ */
102
+ function moveProject(home, project, newPath) {
103
+ const indexPath = join(projectsDir(home), "index.json");
104
+ const index = readIndexAt(indexPath);
105
+ delete index[project.path];
106
+ index[newPath] = project.id;
107
+ writeJsonAtomic(indexPath, index);
108
+ /** @type {ProjectRecord} */
109
+ const record = { ...project, path: newPath, updatedAt: new Date().toISOString() };
110
+ writeJsonAtomic(join(projectsDir(home), project.id, "project.json"), record);
111
+ return record;
112
+ }
113
+
114
+ /**
115
+ * Re-associate a project with the path its repository moved to.
116
+ *
117
+ * Idempotent by construction: when `newPath` is already the project's
118
+ * registered path -- whether this is the first call or a repeat of one that
119
+ * already landed -- the current record comes back untouched and nothing is
120
+ * written. That is deliberate: an operator unsure whether a previous attempt
121
+ * succeeded runs the same command again rather than inspecting the registry
122
+ * first.
123
+ *
124
+ * With `options.from`, the project once registered at that path is moved.
125
+ * Without it, the project is found by matching the git remotes `newPath`
126
+ * reports now against the remotes every registered project last recorded;
127
+ * no match, or more than one, is refused with a message naming what was
128
+ * looked for, rather than guessed.
129
+ *
130
+ * @param {string} home
131
+ * @param {string} newPath
132
+ * @param {{from?: string, remotesOf?: (path: string) => string[]}} [options]
133
+ * @returns {ProjectRecord}
134
+ */
135
+ export function reassociateProject(home, newPath, options = {}) {
136
+ const resolvedNew = resolveIdentity(newPath);
137
+ const already = findProjectByPath(home, resolvedNew);
138
+ if (already) return already;
139
+ if (options.from) {
140
+ const resolvedFrom = resolveIdentity(options.from);
141
+ const found = findProjectByPath(home, resolvedFrom);
142
+ if (!found) throw new Error(`no project is registered at ${resolvedFrom}`);
143
+ return moveProject(home, found, resolvedNew);
144
+ }
145
+ const remotesOf = options.remotesOf ?? readRemotes;
146
+ const matches = projectsMatchingRemotes(home, resolvedNew, remotesOf);
147
+ if (matches.length === 0) throw new Error(`no registered project has the remotes reported at ${resolvedNew}`);
148
+ if (matches.length > 1) throw new Error(`${matches.length} registered projects share a remote reported at ${resolvedNew}; pass --from to disambiguate`);
149
+ return moveProject(home, matches[0], resolvedNew);
150
+ }
@@ -20,7 +20,6 @@ import {
20
20
  readdirSync,
21
21
  realpathSync,
22
22
  rmSync,
23
- symlinkSync,
24
23
  } from "node:fs";
25
24
  import { homedir } from "node:os";
26
25
  import { join, resolve, sep } from "node:path";
@@ -28,7 +27,7 @@ import { fileURLToPath } from "node:url";
28
27
  import { parseArgs as parseFlags } from "node:util";
29
28
 
30
29
  import { faberunHome, installedVersionDir } from "../host/home.mjs";
31
- import { findExecutable } from "../host/preflight.mjs";
30
+ import { findExecutable, linkDirectory } from "../host/platform.mjs";
32
31
  import { colorLevel, statusToken } from "./brand.mjs";
33
32
 
34
33
  const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url));
@@ -304,7 +303,7 @@ function linkSkill(source, destination, force) {
304
303
  if (!existing.isSymbolicLink() && !force) return "skipped";
305
304
  rmSync(destination, { recursive: true, force: true });
306
305
  }
307
- symlinkSync(source, destination);
306
+ linkDirectory(destination, source);
308
307
  return "linked";
309
308
  }
310
309
 
@@ -13,9 +13,10 @@
13
13
  * rather than something to compare as if it were a release.
14
14
  */
15
15
  import { spawnSync } from "node:child_process";
16
- import { mkdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
16
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { compareVersions, currentLink, faberunHome, installedVersionDir, tmpDir, versionsDir, writeUpdateCheck } from "../host/home.mjs";
19
+ import { linkDirectory, tarExecutable } from "../host/platform.mjs";
19
20
  import { packageVersion } from "../host/package.mjs";
20
21
  import { errorMessage } from "../util.mjs";
21
22
 
@@ -170,7 +171,7 @@ async function downloadTarball(fetchImpl, url, destination) {
170
171
  * @returns {void}
171
172
  */
172
173
  function extractTarball(tarball, destination) {
173
- const result = spawnSync("tar", ["-xzf", tarball, "--strip-components=1", "-C", destination], { encoding: "utf8" });
174
+ const result = spawnSync(tarExecutable(), ["-xzf", tarball, "--strip-components=1", "-C", destination], { encoding: "utf8" });
174
175
  if (result.error) throw result.error;
175
176
  if (result.status !== 0) throw new Error(`tar failed: ${String(result.stderr ?? "").trim() || `exit ${result.status}`}`);
176
177
  }
@@ -193,21 +194,17 @@ function verifyVersion(partial, version) {
193
194
  }
194
195
 
195
196
  /**
196
- * Point `current` at the new version atomically: a `current.tmp` symlink then a
197
- * rename over `current`, so a concurrent reader sees either the old target or
198
- * the new one, never a missing link.
197
+ * Point `current` at the new version. `linkDirectory` owns how: a rename over
198
+ * a sibling temporary link where the platform makes that atomic, a remove and
199
+ * remake on Windows where it does not.
199
200
  *
200
201
  * @param {string} home
201
202
  * @param {string} version
202
203
  * @returns {void}
203
204
  */
204
205
  function repointCurrent(home, version) {
205
- const link = currentLink(home);
206
- const temporary = `${link}.tmp`;
207
- rmSync(temporary, { force: true });
208
206
  // SPEC.md's layout: `current -> versions/<v>`, relative to the home.
209
- symlinkSync(join("versions", version), temporary);
210
- renameSync(temporary, link);
207
+ linkDirectory(currentLink(home), join("versions", version));
211
208
  }
212
209
 
213
210
  /** @returns {Record<string, string>} */
package/src/cli.mjs CHANGED
@@ -9,8 +9,11 @@ import { parseArgs } from "node:util";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { modelsCommand } from "./harnesses/catalogue.mjs";
11
11
  import { bulkReadCommand } from "./engine/bulk-read.mjs";
12
- import { doctorCommand, environmentPreflight, findExecutable, notifyTransportCheck, reachableRuntimes, timeVerificationCommands } from "./host/preflight.mjs";
12
+ import { doctorCommand, environmentPreflight, notifyTransportCheck, reachableRuntimes, timeVerificationCommands } from "./host/preflight.mjs";
13
+ import { findExecutable } from "./host/platform.mjs";
13
14
  import { packageName, packageVersion } from "./host/package.mjs";
15
+ import { faberunHome } from "./host/home.mjs";
16
+ import { reassociateProject } from "./cli/project.mjs";
14
17
  import { colorLevel, renderBanner, renderUsage, statusToken } from "./cli/brand.mjs";
15
18
  import { noTransportWarning } from "./notify/index.mjs";
16
19
  import { renderFindings, renderReport, renderReportJson, renderStatus, renderStatusJson } from "./report/render.mjs";
@@ -19,6 +22,8 @@ import { renderNext, renderNextJson } from "./report/next.mjs";
19
22
  import {
20
23
  writeTextAtomic,
21
24
  } from "./run/store.mjs";
25
+ import { runDirectory, runsRoot } from "./run/paths.mjs";
26
+ import { migrateRunState } from "./run/migrate.mjs";
22
27
  import {
23
28
  acquire as acquireLock,
24
29
  validBootstrapNonce,
@@ -105,6 +110,8 @@ export const COMMAND_OPTIONS = {
105
110
  "bulk-read": { question: { type: "string" }, paths: { type: "string", multiple: true }, json: { type: "boolean" } },
106
111
  next: { cwd: { type: "string" }, json: { type: "boolean" } },
107
112
  update: { check: { type: "boolean" }, json: { type: "boolean" } },
113
+ project: { from: { type: "string" } },
114
+ migrate: { cwd: { type: "string" } },
108
115
  setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
109
116
  init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
110
117
  metrics: METRICS_OPTIONS,
@@ -145,7 +152,8 @@ function parseCli(argv, quiet = false) {
145
152
  if (command === "update" && parsed.positionals.length !== 0) return null;
146
153
  if (command === "setup" && parsed.positionals.length !== 0) return null;
147
154
  if (command === "init" && parsed.positionals.length !== 0) return null;
148
- if (command !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && parsed.positionals.length !== 1) return null;
155
+ if (command === "migrate" && parsed.positionals.length !== 0) return null;
156
+ if (command !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && command !== "migrate" && parsed.positionals.length !== 1) return null;
149
157
  return {
150
158
  command,
151
159
  target: parsed.positionals[0],
@@ -246,7 +254,7 @@ async function main(argv) {
246
254
  }
247
255
  if (command === "next") {
248
256
  const cwd = resolve(typeof values.cwd === "string" ? values.cwd : ".");
249
- const runsDir = join(cwd, ".runs");
257
+ const runsDir = runsRoot(cwd);
250
258
  process.stdout.write(values.json === true ? renderNextJson(runsDir, cwd) : renderNext(runsDir, cwd));
251
259
  return;
252
260
  }
@@ -288,13 +296,31 @@ async function main(argv) {
288
296
  });
289
297
  return;
290
298
  }
299
+ if (command === "migrate") {
300
+ const result = migrateRunState(resolve(typeof values.cwd === "string" ? values.cwd : "."), { home: faberunHome(process.env) });
301
+ if (!result.moved) {
302
+ process.stdout.write(`[migrate] nothing to move · ${result.legacy} does not exist\n`);
303
+ return;
304
+ }
305
+ const runs = `${result.runs} run${result.runs === 1 ? "" : "s"}`;
306
+ const campaigns = `${result.campaigns} campaign${result.campaigns === 1 ? "" : "s"}`;
307
+ process.stdout.write(`[migrate] ${result.legacy} -> ${result.target} · ${runs}, ${campaigns}\n`);
308
+ return;
309
+ }
291
310
  if (!target) { usage(); return; }
311
+ if (command === "project") {
312
+ const record = reassociateProject(faberunHome(process.env), target, {
313
+ from: typeof values.from === "string" && values.from ? values.from : undefined,
314
+ });
315
+ process.stdout.write(`[project] ${record.id} · ${record.path}\n`);
316
+ return;
317
+ }
292
318
  if (command === "run") {
293
319
  warnIfNoTransport();
294
320
  const absolute = resolve(target);
295
321
  const baseRef = typeof values["base-ref"] === "string" && values["base-ref"] ? values["base-ref"] : undefined;
296
322
  const contract = validateContractForLaunch(JSON.parse(readFileSync(absolute, "utf8")), absolute, { baseRef });
297
- const runDir = join(contract.cwd, ".runs", contract.id);
323
+ const runDir = runDirectory(contract.cwd, contract.id);
298
324
  setLaunchBaseRef(baseRef);
299
325
  // The base is what every worktree is cut from; a dirty tree only blocks
300
326
  // when the cwd HEAD *is* that base. A `--base-ref` elsewhere leaves the
@@ -20,6 +20,16 @@ import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-clos
20
20
 
21
21
  export { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
22
22
 
23
+ // Mirrors test/repo/source-shape.test.mjs's LINE_CEILING; that test refuses a
24
+ // file over it, at commit time. The margin is 100 lines: measured against
25
+ // this tree (`find src -name '*.mjs' | xargs wc -l`, 2026-09-18), it is the
26
+ // smallest round number that would have flagged both write targets a real
27
+ // node overran -- 783 and 749 lines, both later landing at 801 -- while
28
+ // catching only about 8 of this tree's ~230 modules today, so the warning
29
+ // stays rare enough to read instead of becoming routine noise.
30
+ const WRITE_FILE_LINE_CEILING = 800;
31
+ const WRITE_FILE_LINE_WARN_MARGIN = 100;
32
+
23
33
  const CONTRACT_FIELDS = new Set([
24
34
  "schemaVersion", "contractVersion", "id", "campaignId", "goal", "cwd", "sourceIdentity",
25
35
  "maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec",
@@ -337,6 +347,7 @@ export function validateContract(raw, contractPath, options = {}) {
337
347
  const warnings = nodes.flatMap((node, index) => [
338
348
  ...commandCoverageWarnings(node, index),
339
349
  ...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
350
+ ...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
340
351
  ]);
341
352
  const contract = /** @type {ValidatedContract} */ ({
342
353
  ...raw,
@@ -620,6 +631,39 @@ function dependencyCoversPath(closure, path, cwd) {
620
631
  return false;
621
632
  }
622
633
 
634
+ /**
635
+ * A `writeFiles` entry naming a file already close to the line ceiling is
636
+ * legal -- the ceiling refuses the file itself, at commit time, not the
637
+ * contract that names it -- but a worker cannot discover the file has no
638
+ * room for its diff until an attempt has already spent an invocation
639
+ * finding out. This warns, never refuses, so the author decides whether the
640
+ * write set needs a split before dispatch. A missing file has no current
641
+ * count to warn about, so it is skipped, not treated as zero.
642
+ *
643
+ * @param {ValidatedNode} node
644
+ * @param {number} index
645
+ * @param {string} cwd
646
+ * @returns {string[]}
647
+ */
648
+ function writeFileLineBudgetWarnings(node, index, cwd) {
649
+ const warnings = [];
650
+ for (const path of node.taskPacket.writeFiles ?? []) {
651
+ let text;
652
+ try {
653
+ text = readFileSync(resolve(cwd, path), "utf8");
654
+ } catch {
655
+ continue;
656
+ }
657
+ const lines = text.split("\n").length;
658
+ const remaining = WRITE_FILE_LINE_CEILING - lines;
659
+ if (remaining > WRITE_FILE_LINE_WARN_MARGIN) continue;
660
+ warnings.push(
661
+ `nodes[${index}] (${node.id}): writeFiles ${path} is already ${lines} lines, ${remaining} from the ${WRITE_FILE_LINE_CEILING}-line ceiling; confirm this node's write has room before it starts`,
662
+ );
663
+ }
664
+ return warnings;
665
+ }
666
+
623
667
  /**
624
668
  * @param {string} root
625
669
  * @param {string} cwd
@@ -27,6 +27,15 @@ const PROMPT_MAX_BYTES = 64 * 1024;
27
27
  */
28
28
  const VERIFICATION_PARAGRAPH = "The controller runs every command below after you report; its recorded results are the proof of this node. Running a command yourself is optional and only for one that finishes in seconds and spawns no long-lived process. Keep output bounded (pipe through `| tail -n 200`). Never wait on a background job, never run the whole test suite, and never run tests that start and terminate other processes.";
29
29
 
30
+ /**
31
+ * The `## Required output` schema every mode states: literally every key
32
+ * `validateWorkerResult` requires, so a worker never loses a valid result to
33
+ * a field this paragraph failed to name. What belongs in `artifacts` (if
34
+ * anything) is this packet's own content contract, stated in its own
35
+ * `instructions`, not repeated or guessed here.
36
+ */
37
+ const REQUIRED_OUTPUT_SCHEMA = 'Return exactly one JSON object, with no markdown or prose, matching this shape: {"status":"done"|"blocked_context","summary":"string","verification":["string"],"artifacts":["string"],"missingContext":["string"]}. status is "done" when the node is complete or "blocked_context" when required context is missing; summary is prose for a human reader; verification and artifacts are string arrays, sent as [] when this node ran no command or has nothing to put there; missingContext lists exactly what is absent, non-empty only for blocked_context. Use blocked_context only when missingContext is non-empty; use done only when missingContext is empty.';
38
+
30
39
  /**
31
40
  * `validateRelativePath`'s answer when a path is absent and the caller asked to
32
41
  * defer the missing-path verdict rather than throw it. Only contract loading
@@ -128,7 +137,7 @@ export function renderWorkerPrompt(packet, nodeId) {
128
137
  ...packet.verification.map((command) => `- ${command.argv.join(" ")}`),
129
138
  "",
130
139
  "## Required output",
131
- 'Return exactly one JSON object, with no markdown or prose: {"status":"done"|"blocked_context","summary":"string","verification":["string"],"artifacts":["string"],"missingContext":["string"]}. Use blocked_context only when missingContext is non-empty; use done only when missingContext is empty.',
140
+ REQUIRED_OUTPUT_SCHEMA,
132
141
  ];
133
142
  const prompt = `${lines.join("\n")}\n`;
134
143
  if (Buffer.byteLength(prompt, "utf8") > PROMPT_MAX_BYTES) {
@@ -432,7 +441,7 @@ function renderDiscoveryPrompt(packet, nodeId) {
432
441
  ...bulletOrNone(packet.nonGoals),
433
442
  "",
434
443
  "## Required output",
435
- 'Return exactly one worker-result JSON object, with no markdown or prose. Set status to "done", missingContext to [], and artifacts to an array containing exactly one JSON-stringified execution task packet with every required taskPacket field. The packet readFiles and writeFiles must be non-empty and scoped to this repository. Put structured findings meant to inform that packet in `output` (a JSON object, at most 65536 bytes); prose belongs in `summary`.',
444
+ `${REQUIRED_OUTPUT_SCHEMA} Put structured findings meant to inform this packet's own instructions in \`output\` instead (a JSON object, at most 65536 bytes).`,
436
445
  "",
437
446
  "## Verification",
438
447
  VERIFICATION_PARAGRAPH,
@@ -481,7 +490,7 @@ function renderAutonomousPrompt(packet, nodeId) {
481
490
  ...packet.verification.map((command) => `- ${command.argv.join(" ")}`),
482
491
  "",
483
492
  "## Required output",
484
- 'Return exactly one JSON object, with no markdown or prose: {"status":"done"|"blocked_context","summary":"string","verification":["string"],"artifacts":["string"],"missingContext":["string"]}. Use blocked_context only when missingContext is non-empty; use done only when missingContext is empty.',
493
+ REQUIRED_OUTPUT_SCHEMA,
485
494
  ];
486
495
  const prompt = `${lines.join("\n")}\n`;
487
496
  if (Buffer.byteLength(prompt, "utf8") > PROMPT_MAX_BYTES) throw new TypeError(`worker prompt exceeds ${PROMPT_MAX_BYTES} bytes`);
@@ -17,6 +17,7 @@ import { terminateInvocation } from "./process.mjs";
17
17
  import { join, resolve } from "node:path";
18
18
  import { readFileSync } from "node:fs";
19
19
  import { readRunNodes } from "./scheduler.mjs";
20
+ import { deleteRef, releaseAttemptWorktree, runRefName } from "../repo/worktree.mjs";
20
21
  import { syncAgentSignal } from "../repo/signal.mjs";
21
22
  import { transition, writeNode } from "./state.mjs";
22
23
  import { validateContract } from "../contract/index.mjs";
@@ -107,6 +108,18 @@ export async function cancelRun(runDirPath) {
107
108
  throw error;
108
109
  }
109
110
  if (!await waitForTerminal(runDir, 1_000)) throw new Error("cancel could not confirm a terminal run state");
111
+ // The run directory is evidence a campaign ledger may still want, so it
112
+ // stays; the run ref and every node's attempt branch are just git names
113
+ // the next launch of this same contract id needs back, and cancel is the
114
+ // operator saying this run is over. Releasing a name is not destroying a
115
+ // record -- each state's `worktree.branch`/`commit` fields, and the sha
116
+ // this ref pointed at, remain in the persisted snapshot regardless.
117
+ // Idempotent both ways: `removeWorktree` and `deleteRef` already tolerate
118
+ // an artefact a previous cancel (or the run itself) already released.
119
+ for (const state of states) {
120
+ if (state.worktree?.branch) releaseAttemptWorktree(contract.cwd, state.worktree.path, state.worktree.branch);
121
+ }
122
+ deleteRef(contract.cwd, runRefName(contract.id));
110
123
  syncAgentSignal(join(runDir, ".."));
111
124
  return true;
112
125
  } finally {
@@ -21,8 +21,8 @@ import {
21
21
  readWorkerResultFile,
22
22
  workerProtocolPrompt,
23
23
  } from "./result-file.mjs";
24
- import { attemptWorkspace } from "../repo/worktree.mjs";
25
- import { attemptWorktreePath, createAttemptWorktree, sealAttempt } from "../repo/worktree.mjs";
24
+ import { attemptWorkspace, createAttemptWorktree, sealAttempt } from "../repo/worktree.mjs";
25
+ import { attemptWorktreePath } from "../run/paths.mjs";
26
26
  import { basename, dirname, join } from "node:path";
27
27
  import { boundedUtf8, errorCode, errorMessage, stableJson } from "../util.mjs";
28
28
  import { captureWorkspaceScope, captureWorkspaceSnapshot } from "../repo/workspace.mjs";
@@ -387,6 +387,7 @@ function ensureAttemptWorkspace(contract, node, state, runDir, lock) {
387
387
  runId: contract.id,
388
388
  nodeId: node.id,
389
389
  attempt: state.attempt,
390
+ declaredReads: node.taskPacket.readFiles,
390
391
  base: previous?.sha,
391
392
  });
392
393
  const boundary = captureWorkspaceScope(worktree.path, workerScope(node.taskPacket));
@@ -668,6 +669,19 @@ export async function startJudge(contract, node, state, runDir, running, workerR
668
669
  error.code = "judge_prompt_too_large";
669
670
  throw error;
670
671
  }
672
+ // Captured at the last possible instant before the judge can touch
673
+ // anything, so the settlement pass's comparison proves what the judge
674
+ // itself wrote rather than racing whatever ran just before dispatch. A
675
+ // capture failure must not block dispatch -- the write check is a
676
+ // controller invariant on top of whatever the judge does, not a
677
+ // precondition for running it -- so it degrades to unchecked instead of
678
+ // to a refusal.
679
+ let judgeBaseline;
680
+ try {
681
+ judgeBaseline = captureWorkspaceSnapshot(workspace);
682
+ } catch {
683
+ judgeBaseline = null;
684
+ }
671
685
  const job = startProcess({
672
686
  contract, node, state, runtime, workspace,
673
687
  prompt: phasePlan.prompt,
@@ -678,6 +692,10 @@ export async function startJudge(contract, node, state, runDir, running, workerR
678
692
  }),
679
693
  onInvocation: (invocation, currentJob) => {
680
694
  stampInvocation(invocation, contract, node, runtime, state, runDir, "judge", phasePlan.mode, phasePlan.continuationId);
695
+ // Reusing the worker phase's own scratch field: a job is never both a
696
+ // worker and a judge, and this field carries no persisted shape of
697
+ // its own that a judge borrowing it would have to match.
698
+ currentJob.scopeBaseline = judgeBaseline;
681
699
  persistInvocation(runDir, state, invocation, currentJob, lock);
682
700
  persistInvocationIntent(runDir, invocation, {
683
701
  nodeId: node.id,
@@ -17,17 +17,8 @@ import {
17
17
  SETTLED,
18
18
  } from "./prompts.mjs";
19
19
  import {
20
- judgeReaskOutstanding,
21
- } from "./judge-gate.mjs";
22
- import {
23
- judgeVerdictEvidence,
24
- } from "../contract/review-modes.mjs";
25
- import {
26
- JUDGE_MAX_FAILURES,
27
- applyJudgeProtocolFailure,
28
20
  applyJudgeResult,
29
21
  applyJudgeRound,
30
- settleUnavailableJudge,
31
22
  } from "./review.mjs";
32
23
  import { routeRuntimeForState, routingBackoffActive, runtimeSnapshot } from "./failover.mjs";
33
24
  import {
@@ -65,6 +56,7 @@ import { startJudge, startResultMaterialization } from "./dispatch.mjs";
65
56
  import { raiseNodeAttention, settleDone } from "./settle.mjs";
66
57
  import { applyRejection, applyVerificationFailure } from "./settle.mjs";
67
58
  import { emitNodeAdvisories } from "./notify-queue.mjs";
59
+ import { judgeWorkspaceWriteViolation, settleJudgeRound } from "./settle-judge.mjs";
68
60
 
69
61
  /** @typedef {import("../repo/integrate.mjs").IntegrationResult} IntegrationResult */
70
62
  /** @typedef {import("./backoff.mjs").Transition} Transition */
@@ -80,7 +72,6 @@ import { emitNodeAdvisories } from "./notify-queue.mjs";
80
72
  /** @typedef {import("../contract/index.mjs").GateResult} GateResult */
81
73
  /** @typedef {import("../contract/index.mjs").SnapshotError} SnapshotError */
82
74
  /** @typedef {import("../contract/index.mjs").BoundedScope} BoundedScope */
83
- /** @typedef {import("../repo/workspace.mjs").WorkspaceSnapshot} WorkspaceSnapshot */
84
75
  /** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
85
76
  /** @typedef {ReturnType<typeof acquireLock>} LockHandle */
86
77
  /** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
@@ -90,7 +81,6 @@ import { emitNodeAdvisories } from "./notify-queue.mjs";
90
81
  /** @typedef {import("../contract/verification.mjs").VerificationAttempt} VerificationAttempt */
91
82
  /** @typedef {import("../contract/verification.mjs").VerificationAttemptResult} VerificationAttemptResult */
92
83
  /** @typedef {import("../contract/verification.mjs").VerificationResult} VerificationResult */
93
- /** @typedef {import("../repo/workspace.mjs").ScopeComparison} ScopeComparison */
94
84
  /** @typedef {import("../contract/worker-result.mjs").WorkerResult} WorkerResult */
95
85
  /** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
96
86
  /** @typedef {{path: string, campaign: Campaign}} CampaignRef */
@@ -277,7 +267,7 @@ export function autoRetryParkedNodes(contract, runDir, states, lock, previouslyP
277
267
  *
278
268
  * @param {NodeSnapshot} state
279
269
  */
280
- function clearTierExhaustion(state) {
270
+ export function clearTierExhaustion(state) {
281
271
  if (!state.routing || state.routing.tierExhaustion === undefined) return;
282
272
  const routing = { ...state.routing };
283
273
  delete routing.tierExhaustion;
@@ -393,6 +383,25 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
393
383
  appendUsageRecord(runDir, state.invocations.find((invocation) => invocation.id === job.invocation.id));
394
384
  state.usage = invocationUsage(state);
395
385
  state.costUsd = invocationCost(state);
386
+ // Checked before any other branch can act on how this invocation closed --
387
+ // a done verdict, a provider failure, a bounded re-dispatch, or exhaustion
388
+ // -- so a judge that wrote into its own workspace is caught here rather
389
+ // than laundered through a re-dispatch whose fresh baseline would already
390
+ // contain the write (the whole point of TECH-SPEC's write check: blocked
391
+ // outright, never re-asked). A comparison that cannot even be completed --
392
+ // an edited ignore source, a symlink escaping the tree, too many entries --
393
+ // is the same violation as an ordinary write, never silence: the worker
394
+ // path (`engine/scope.mjs`'s `checkWorkerScope`) already fails closed on
395
+ // exactly the same throw.
396
+ if (job.phase === "judge") {
397
+ const violation = judgeWorkspaceWriteViolation(job);
398
+ if (violation) {
399
+ clearTierExhaustion(state);
400
+ transition(runDir, state, "blocked", { phase: "judge", result: state.result, usage: state.usage, error: { code: "judge_protocol", message: violation.message } }, lock);
401
+ await raiseNodeAttention(campaignPath, runDir, state, "judge_protocol");
402
+ continue;
403
+ }
404
+ }
396
405
  // A closed worker whose canonical result file is valid and whose scope
397
406
  // passed is completed work, no matter what the provider envelope or the
398
407
  // exit code said. The durable file was read above, before the scope gate,
@@ -425,55 +434,17 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
425
434
  continue;
426
435
  }
427
436
  // A judge provider that failed outright (its turn died, its tool host was
428
- // gone) is a provider failure, never a verdict: the gate cannot adopt a
429
- // result the judge could not ground in inspection. Re-dispatch the judge
430
- // once on the same routing, then settle by review mode so a judge failure
431
- // is surfaced, never silently settled. A stream that never reached its
432
- // terminal envelope is a protocol defect instead and takes the bounded
433
- // re-ask below.
434
- if (job.phase === "judge" && envelope.status === "failed" && envelope.error?.code !== "incomplete_stream") {
435
- // A judge that lost its socket is not an unavailable judge. It buys the
436
- // same bounded network waits a worker does, on the runtime it already
437
- // warmed, and spends none of the one re-dispatch counted below.
438
- const network = networkTransition(contract, job.node, state, "judge", envelope, job.exitCode);
439
- if (network && handleProviderExhaustion(contract, runDir, job.node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) continue;
440
- clearTierExhaustion(state);
441
- // The provider died on the bounded re-ask itself, so the one permitted
442
- // re-ask is spent: settle by review mode here rather than dispatch a
443
- // third judge invocation behind a fresh failure count.
444
- // The provider died on the bounded re-ask itself, so the one permitted
445
- // re-ask is spent: settle by review mode here rather than dispatch a
446
- // third judge invocation behind a fresh failure count.
447
- if (judgeReaskOutstanding(state)) {
448
- await applyJudgeProtocolFailure(contract, job.node, state, runDir, running, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
449
- continue;
450
- }
451
- state.judgeFailures = (state.judgeFailures ?? 0) + 1;
452
- if (state.judgeFailures < JUDGE_MAX_FAILURES) {
453
- writeNode(runDir, state, lock);
454
- await applyJudgeRound(await startJudge(contract, job.node, state, runDir, running, state.result, lock, states, campaignPath),
455
- contract, job.node, state, runDir, running, lock, states, campaignPath, state.result);
456
- continue;
457
- }
458
- await settleUnavailableJudge(contract, job.node, state, runDir, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
459
- continue;
460
- } // Whatever else this invocation produced, it is not exactly one usable
461
- // verdict: no verdict at all, several of them in separate agent messages,
462
- // an unparseable one, a stream cut off before its terminal envelope, or a
463
- // phase killed on its wall clock. One bounded re-ask, then the review mode
464
- // decides — advisory completes, blocking enters attention with the work
465
- // preserved so a retry in place can re-judge it.
437
+ // gone) is a provider failure, never a verdict, and a verdict that arrived
438
+ // but is not exactly one usable one (none at all, several of them, an
439
+ // unparseable one, a stream cut off before its terminal envelope, or a
440
+ // phase killed on its wall clock) is a protocol defect rather than a
441
+ // pass. Both are settled by `settleJudgeRound`, moved out of this file
442
+ // for the same reason `engine/settle.mjs` was: this file and
443
+ // `test/engine/judge.test.mjs` both sit on the 800-line ceiling
444
+ // `test/repo/source-shape.test.mjs` enforces. The write check above has
445
+ // already run, so nothing here can adopt or launder a judge's own write.
466
446
  if (job.phase === "judge") {
467
- const evidence = judgeVerdictEvidence(envelope);
468
- if (!evidence.ok) {
469
- const network = networkTransition(contract, job.node, state, "judge", envelope, job.exitCode);
470
- if (network && handleProviderExhaustion(contract, runDir, job.node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) continue;
471
- clearTierExhaustion(state);
472
- await applyJudgeProtocolFailure(contract, job.node, state, runDir, running, lock, states, campaignPath, evidence.reason);
473
- continue;
474
- }
475
- clearTierExhaustion(state);
476
- await applyJudgeResult(contract, job.node, state, evidence.result, runDir, lock, running, states, campaignPath);
447
+ await settleJudgeRound(contract, job, state, runDir, running, lock, states, campaignPath, envelope, { clearTierExhaustion, handleProviderExhaustion });
477
448
  continue;
478
449
  }
479
450
  // An empty final message is a missing worker result, not a no-op worker:
@@ -550,7 +521,13 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
550
521
  await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, errorMessage(error), states, campaignPath);
551
522
  continue;
552
523
  }
553
- if (job.node.taskPacket.mode === "discovery" && workerResult.status === "done") {
524
+ // The artifact demand follows the documented discovery contract, not the
525
+ // mode alone: only a packet with no read files -- the one exception to
526
+ // closed scope, allowed to read the repository to produce an execution
527
+ // packet -- owes that artifact. A discovery packet closed to its listed
528
+ // read files delivers through `output` (a planning node's plan or
529
+ // findings) and carries none.
530
+ if (job.node.taskPacket.mode === "discovery" && job.node.taskPacket.readFiles.length === 0 && workerResult.status === "done") {
554
531
  try {
555
532
  parseDiscoveryResult(workerResult, attemptWorkspace(state) ?? contract.cwd);
556
533
  } catch (error) {