faberun 0.28.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@ import { bootstrapAckPath, bootstrapAttemptPath, bootstrapPath, cleanupBootstrap
16
16
  import { bootstrapFailureMatchesChild, bootstrapMatchesChild, processStartToken, readLock, sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
17
17
  import { cleanupBootstrapNonce, discardNodeLessRunDirectory } from "../engine/detach.mjs";
18
18
  import { delay, errorCode } from "../util.mjs";
19
- import { existsSync, readFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { join, resolve } from "node:path";
22
22
  import { randomUUID } from "node:crypto";
@@ -232,16 +232,33 @@ export function bootstrapRunDir(command, target) {
232
232
  */
233
233
  export function writeBootstrapFailure(command, target, error) {
234
234
  const runDir = bootstrapRunDir(command, target);
235
- if (!runDir || !existsSync(runDir)) return;
235
+ if (!runDir) return;
236
236
  const nonce = validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE) ? process.env.FABERUN_BOOTSTRAP_NONCE : null;
237
237
  const failure = { status: "failed", pid: process.pid, processStartToken: processStartToken(process.pid), runDir, nonce, at: new Date().toISOString(), error: error.message };
238
+ const detachedChild = nonce !== null && !process.argv.includes("--detach");
239
+ if (!existsSync(runDir)) {
240
+ // A refusal that happens before the run directory exists — a dirty tree, a
241
+ // base ref that does not resolve — is the one the launcher cannot
242
+ // otherwise learn: the child's stdio is discarded, so all it could say was
243
+ // "failed before readiness for pid N". Measured 2026-09-27 (AP10): a whole
244
+ // `plan` stage died that way over two uncommitted paths. Only a detached
245
+ // child creates the directory, and only to carry this record;
246
+ // `waitForBootstrap` removes a node-less one as it reads it.
247
+ if (!detachedChild) return;
248
+ try {
249
+ mkdirSync(runDir, { recursive: true });
250
+ } catch {
251
+ // The refusal being reported matters more than the directory that would
252
+ // have carried it; without one the launcher falls back to its own message.
253
+ return;
254
+ }
255
+ }
238
256
  // A `run` that names an existing run directory is refused before it writes
239
257
  // anything, and its failure must not be stamped into that run's bootstrap
240
258
  // record. The detached child is the exception: it wrote contract.json and
241
259
  // then died before readiness, and the launcher has no other channel to
242
260
  // learn the controller's error, so it must record that error before it
243
261
  // exits.
244
- const detachedChild = nonce !== null && !process.argv.includes("--detach");
245
262
  if (command === "run" && !detachedChild && existsSync(join(runDir, "contract.json"))) return;
246
263
  /** @type {BootstrapRecord|null} */
247
264
  let current = null;
package/src/cli/plan.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  * sequencing and every decision the pipeline makes.
6
6
  */
7
7
  import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
8
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
- import { dirname, join, resolve } from "node:path";
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
10
  import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
11
11
  import { classifyRunProgress } from "../campaign/chain.mjs";
12
12
  import { runProgress } from "../engine/supervise.mjs";
@@ -17,6 +17,7 @@ import { colorLevel, statusToken } from "./brand.mjs";
17
17
  import { delay } from "../util.mjs";
18
18
  import { runPlanningPipeline } from "../plan/pipeline.mjs";
19
19
  import { parseAnswerFlags, resolvePlanningPipeline } from "../plan/resolve.mjs";
20
+ import { abandonedPlanProgress } from "../plan/progress.mjs";
20
21
  import { resolveReviewerList } from "../plan/reviewer.mjs";
21
22
  import { campaignTree, runDirectory } from "../run/paths.mjs";
22
23
  import { readCampaign } from "../campaign/record.mjs";
@@ -112,6 +113,46 @@ export function loadRuntimesCatalogue(path) {
112
113
  return runtimes;
113
114
  }
114
115
 
116
+ /**
117
+ * The campaign's own checked-in planning inputs live beside the spec it
118
+ * already keeps in the repository: `docs/campaigns/<id>/plan-inputs/`. A file
119
+ * that exists there is used as if it had been passed on the command line,
120
+ * because that is what it is for; an absent one changes nothing.
121
+ *
122
+ * Measured 2026-09-27 (AP19): a campaign carried
123
+ * `plan-inputs/runtimes.json` with a raised output ceiling, `plan` fell back
124
+ * to the 8192 default without a word, and the operator found out only after
125
+ * burning rounds on a truncated draft.
126
+ *
127
+ * @param {string} cwd @param {string} campaignId @param {string} file
128
+ * @returns {string}
129
+ */
130
+ function campaignInputPath(cwd, campaignId, file) {
131
+ return join(cwd, "docs", "campaigns", campaignId, "plan-inputs", file);
132
+ }
133
+
134
+ /**
135
+ * The catalogue a `plan` reads runtimes from, and the file it came from when
136
+ * that file is the campaign's own. `--runtimes` wins over the checked-in one:
137
+ * an operator passing a path is overriding, not asking.
138
+ *
139
+ * `source` is returned rather than printed here so a caller decides the
140
+ * wording; `planCli` says it out loud because silence is the whole defect. The
141
+ * path is resolved from the launch cwd, never from the plan's own target, so
142
+ * the same command from a subdirectory reads the same file the campaign does.
143
+ *
144
+ * @param {{cwd: string, campaignId: string, runtimes?: unknown}} options
145
+ * @returns {{runtimes: Record<string, import("../contract/index.mjs").ValidatedRuntime>, source: string|null}}
146
+ */
147
+ export function resolvePlanRuntimes({ cwd, campaignId, runtimes }) {
148
+ if (typeof runtimes === "string" && runtimes) return { runtimes: loadRuntimesCatalogue(runtimes), source: null };
149
+ const campaignRuntimes = campaignInputPath(cwd, campaignId, "runtimes.json");
150
+ if (!existsSync(campaignRuntimes)) {
151
+ return { runtimes: /** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */ (DISCOVERY_RUNTIME_DEFINITIONS), source: null };
152
+ }
153
+ return { runtimes: loadRuntimesCatalogue(campaignRuntimes), source: campaignRuntimes };
154
+ }
155
+
115
156
  /**
116
157
  * A `--verification <path>` catalogue: a JSON object carrying either or both
117
158
  * of the contract's own suite keys, `sharedVerification` and
@@ -160,9 +201,10 @@ export async function planCli(target, values) {
160
201
  // R19: the planner's own reviewer list, never `runtimeDefaults.judge` --
161
202
  // `--reviewers` wins over the machine's own `config.reviewers` default.
162
203
  const reviewers = resolveReviewerList({ reviewers: parseReviewerList(values.reviewers) }, readUserConfig(process.env));
163
- const runtimes = typeof values.runtimes === "string" && values.runtimes
164
- ? loadRuntimesCatalogue(values.runtimes)
165
- : DISCOVERY_RUNTIME_DEFINITIONS;
204
+ const { runtimes, source: runtimesSource } = resolvePlanRuntimes({ cwd: process.cwd(), campaignId, runtimes: values.runtimes });
205
+ // Said out loud because silence is the whole defect: the default and the
206
+ // campaign's own catalogue look identical from the outside.
207
+ if (runtimesSource) process.stdout.write(`[plan] runtimes · ${relative(process.cwd(), runtimesSource)}\n`);
166
208
  const verification = typeof values.verification === "string" && values.verification
167
209
  ? loadVerificationSuites(values.verification)
168
210
  : {};
@@ -171,6 +213,15 @@ export async function planCli(target, values) {
171
213
  ? undefined
172
214
  : validateJudgeIndependence(values["judge-independence"], "--judge-independence");
173
215
 
216
+ // A record with no live process behind it is the only trace a planning run
217
+ // that died without a word leaves. Measured 2026-09-26 (AP4): `--detach` pid
218
+ // 64270 vanished during repo-facts and the phase directory stayed empty, so
219
+ // the next launch is the one place the operator can still be told.
220
+ const abandoned = abandonedPlanProgress(process.cwd(), campaignId, phase);
221
+ if (abandoned) {
222
+ process.stderr.write(`[plan] a previous planning process (pid ${String(abandoned.pid ?? "?")}) stopped during ${String(abandoned.stage ?? "?")} at ${String(abandoned.at ?? "?")} and never reached the end\n`);
223
+ }
224
+
174
225
  if (values.detach === true) {
175
226
  const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
176
227
  if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
package/src/cli.mjs CHANGED
@@ -579,7 +579,11 @@ function advisoryToken() {
579
579
  function prunedLine(pruned) {
580
580
  if (!pruned.length) return "[prune] no finished, integrated run holds a worktree";
581
581
  const removed = pruned.reduce((sum, run) => sum + run.removed, 0);
582
- return `[prune] released ${removed} worktree${removed === 1 ? "" : "s"} of ${pruned.length} integrated run${pruned.length === 1 ? "" : "s"} · each kept under refs/faberun-archive/`;
582
+ const branches = pruned.reduce((sum, run) => sum + run.branches.length, 0);
583
+ const line = removed === 0
584
+ ? "[prune] an earlier prune already released every worktree of an integrated run"
585
+ : `[prune] released ${removed} worktree${removed === 1 ? "" : "s"} of ${pruned.length} integrated run${pruned.length === 1 ? "" : "s"} · each kept under refs/faberun-archive/`;
586
+ return branches === 0 ? line : `${line} · deleted ${branches} attempt branch${branches === 1 ? "" : "es"} already held by another ref`;
583
587
  }
584
588
 
585
589
  /**
@@ -279,6 +279,61 @@ export function assertInlineScriptProofsParse(nodes) {
279
279
  }
280
280
  }
281
281
 
282
+ /**
283
+ * The quote a command proof's text leaves open, or null when every quote it
284
+ * opens is closed. `kind: "command"` goes through `/bin/sh -c`, so a text with
285
+ * an odd number of `'` or `"` is refused by the shell before the command runs,
286
+ * with `unexpected EOF while looking for matching`. Measured 2026-09-27 (AP13
287
+ * of safe-to-hand-to-friend): the freeze accepted
288
+ * `node --test --test-name-pattern=a stranger's first campaign completes
289
+ * offline test/…` — an apostrophe inside an unquoted test title — the node did
290
+ * the work and passed its own test in 15 s, and both attempts were spent on a
291
+ * command `/bin/sh` never ran.
292
+ *
293
+ * This is a quote scanner, not a shell parser: it reports the two failures a
294
+ * proof can carry without expanding anything (an open quote, or nothing at
295
+ * all), and leaves every other shell construct to the shell. A `$'…'` string
296
+ * and a quote inside a here-document are the constructs it does not model.
297
+ *
298
+ * @param {string} commandText
299
+ * @returns {string|null}
300
+ */
301
+ export function unclosedQuote(commandText) {
302
+ /** @type {string|null} */
303
+ let quote = null;
304
+ let escaped = false;
305
+ for (const character of commandText) {
306
+ if (escaped) { escaped = false; continue; }
307
+ // Outside double quotes a backslash escapes the next character; a
308
+ // backslash inside single quotes is literal, which POSIX also gives it.
309
+ if (quote !== "'" && character === "\\") { escaped = true; continue; }
310
+ if (quote === null && (character === "'" || character === '"')) { quote = character; continue; }
311
+ if (quote === character) quote = null;
312
+ }
313
+ return quote;
314
+ }
315
+
316
+ /**
317
+ * Refuse a contract whose command proof the shell cannot parse, naming the
318
+ * node, the item and the open quote. The freeze and the launch both call it:
319
+ * a command no attempt can run is the same waste as an inline script that
320
+ * cannot parse, and cheaper to refuse than to discover.
321
+ *
322
+ * @param {{id?: unknown, definitionOfDone?: {id?: unknown, proof?: {kind?: unknown, ref?: unknown}}[]}[]} nodes
323
+ * @returns {void}
324
+ */
325
+ export function assertProofCommandsParse(nodes) {
326
+ for (const node of nodes) {
327
+ for (const item of node.definitionOfDone ?? []) {
328
+ if (item.proof?.kind !== "command" || typeof item.proof.ref !== "string") continue;
329
+ const quote = unclosedQuote(item.proof.ref);
330
+ if (quote === null) continue;
331
+ const nodeName = node.id === undefined ? "a node" : `node ${node.id}`;
332
+ throw new TypeError(`${nodeName}, Definition of Done item "${item.id}": the proof command leaves a ${quote} open, so /bin/sh refuses it before it runs ("unexpected EOF while looking for matching \`${quote}'"). Quote the value, as in --test-name-pattern="<title>".`);
333
+ }
334
+ }
335
+ }
336
+
282
337
  /** @param {string} token @returns {boolean} */
283
338
  function isNodeExecutable(token) {
284
339
  const base = (token.split(/[\\/]/u).pop() ?? token).toLowerCase();
@@ -153,7 +153,7 @@ export function validateContract(raw, contractPath, options = {}) {
153
153
  throw new TypeError("contract.runtimes must be an object");
154
154
  }
155
155
  const runtimes = /** @type {Record<string, ValidatedRuntime>} */ ({});
156
- for (const [id, runtime] of Object.entries(rawRuntimes)) runtimes[id] = validateRuntime(id, runtime);
156
+ for (const [id, runtime] of Object.entries(rawRuntimes)) runtimes[id] = validateRuntime(id, runtime, { persisted });
157
157
  // A runtime's fallback is validated against sibling runtimes once every
158
158
  // runtime is known, so declaration order never matters.
159
159
  for (const [id, runtime] of Object.entries(runtimes)) {
@@ -93,9 +93,11 @@ export function routeRuntime(contract, node, role = "worker", event = {}) {
93
93
  /**
94
94
  * @param {string} id
95
95
  * @param {unknown} runtime
96
+ * @param {{persisted?: boolean}} [options] `persisted` is a replay of a contract
97
+ * already accepted at launch; see the vendor rule below.
96
98
  * @returns {ValidatedRuntime}
97
99
  */
98
- export function validateRuntime(id, runtime) {
100
+ export function validateRuntime(id, runtime, options = {}) {
99
101
  requireId(id, `runtime ${id}`);
100
102
  assertObject(runtime, `runtime ${id}`);
101
103
  rejectUnknown(runtime, RUNTIME_FIELDS, `runtime ${id}`);
@@ -103,16 +105,24 @@ export function validateRuntime(id, runtime) {
103
105
  const typedRuntime = /** @type {{harness: string, model: string, vendor?: string, config?: Record<string, unknown>}} */ (runtime);
104
106
  // The canonical provider derived from the route, the model's family, or the
105
107
  // harness default outranks a declared `vendor`: a label that contradicts it
106
- // is refused (R18), and the runtime's vendor becomes the derived provider
107
- // rather than whatever text the contract declared. `replay` and `exec-jsonl`
108
- // derive none — for those the declared vendor stands, as it always has.
108
+ // is refused (R18) at authoring, and the runtime's vendor becomes the derived
109
+ // provider rather than whatever text the contract declared. `replay` and
110
+ // `exec-jsonl` derive none — for those the declared vendor stands, as it
111
+ // always has. A persisted load takes the derived provider without refusing,
112
+ // so a run launched before R18 stays readable and cancelable.
109
113
  const derived = canonicalProvider(typedRuntime);
110
114
  const declared = typeof typedRuntime.vendor === "string" && typedRuntime.vendor.length ? typedRuntime.vendor : undefined;
111
115
  let vendor;
112
116
  if (derived) {
113
- if (declared !== undefined && declared !== derived) {
117
+ if (declared !== undefined && declared !== derived && options.persisted !== true) {
114
118
  throw new TypeError(`runtime ${id} declares vendor ${declared} but ${typedRuntime.harness} ${typedRuntime.model} is provider ${derived}`);
115
119
  }
120
+ // A replay canonicalizes a contradicting label instead of refusing it.
121
+ // Measured 2026-09-27 (AP6): three runs launched before R18 carried
122
+ // `vendor: anthropic-sonnet` against the model claude-sonnet-5, and every
123
+ // reader of their persisted contract — `cancel`, `status`, `report` — threw
124
+ // on a rule those contracts predate, leaving the runs unsuspended forever.
125
+ // The rule still refuses the label where it can be acted on: at authoring.
116
126
  vendor = derived;
117
127
  } else {
118
128
  vendor = resolveVendor(typedRuntime);
@@ -43,12 +43,24 @@ export const SANDBOX_BLOCKED_WRITE = "sandbox_blocked_write";
43
43
  * fences it never wrote, and repeated the copy.
44
44
  */
45
45
  export class WorkerResultSizeError extends TypeError {
46
- /** @param {string} field @param {number} limit */
47
- constructor(field, limit) {
48
- super(`${field} exceeds ${limit} bytes`);
46
+ /**
47
+ * `actual` is the measured byte count, never optional: measured 2026-09-28
48
+ * (AP18 of safe-to-hand-to-friend), a GLM worker returned an `artifacts[0]`
49
+ * of 19,810 bytes against the 16,384-byte ceiling and the node failed with
50
+ * "worker result.artifacts[0] exceeds 16384 bytes" -- the operator could not
51
+ * tell a 20 KiB artifact from a 1 MiB one, and the ceiling reads the same
52
+ * whether the worker overshot by 3 KiB or by ten times.
53
+ *
54
+ * @param {string} field
55
+ * @param {number} limit
56
+ * @param {number} actual
57
+ */
58
+ constructor(field, limit, actual) {
59
+ super(`${field} is ${actual} bytes, over the ${limit}-byte ceiling`);
49
60
  this.name = "WorkerResultSizeError";
50
61
  this.field = field;
51
62
  this.limit = limit;
63
+ this.actual = actual;
52
64
  }
53
65
  }
54
66
 
@@ -73,7 +85,7 @@ export class WorkerResultSizeError extends TypeError {
73
85
  */
74
86
  export function invalidResultRepair(cause) {
75
87
  if (cause instanceof WorkerResultSizeError) {
76
- return `the result breaks a size ceiling: ${cause.field} exceeds ${cause.limit} bytes. Keep every field within its ceiling; a node that delivers through \`output\` sends \`artifacts\` as [] and never copies \`output\` into \`artifacts\`.`;
88
+ return `the result breaks a size ceiling: ${cause.field} is ${cause.actual} bytes, over the ${cause.limit}-byte ceiling. Keep every field within its ceiling; a node that delivers through \`output\` sends \`artifacts\` as [] and never copies \`output\` into \`artifacts\`.`;
77
89
  }
78
90
  return "the entire final message must be exactly the required JSON object: no markdown fences, no prose before or after it. Return it as the only content of the final message.";
79
91
  }
@@ -85,8 +97,9 @@ export function invalidResultRepair(cause) {
85
97
  export function parseWorkerResult(value) {
86
98
  if (typeof value !== "string") throw new TypeError("worker result must be JSON text");
87
99
  const maxRawBytes = RESULT_LIMITS.bytes + RESULT_LIMITS.outputBytes;
88
- if (Buffer.byteLength(value, "utf8") > maxRawBytes) {
89
- throw new WorkerResultSizeError("worker result", maxRawBytes);
100
+ const rawBytes = Buffer.byteLength(value, "utf8");
101
+ if (rawBytes > maxRawBytes) {
102
+ throw new WorkerResultSizeError("worker result", maxRawBytes, rawBytes);
90
103
  }
91
104
  let parsed;
92
105
  try {
@@ -140,8 +153,9 @@ export function validateWorkerResult(value) {
140
153
  let output;
141
154
  if (Object.hasOwn(record, "output") && record.output !== undefined) {
142
155
  assertObject(record.output, "worker result.output");
143
- if (Buffer.byteLength(JSON.stringify(record.output), "utf8") > RESULT_LIMITS.outputBytes) {
144
- throw new WorkerResultSizeError("worker result.output", RESULT_LIMITS.outputBytes);
156
+ const outputBytes = Buffer.byteLength(JSON.stringify(record.output), "utf8");
157
+ if (outputBytes > RESULT_LIMITS.outputBytes) {
158
+ throw new WorkerResultSizeError("worker result.output", RESULT_LIMITS.outputBytes, outputBytes);
145
159
  }
146
160
  output = /** @type {Record<string, unknown>} */ (record.output);
147
161
  }
@@ -155,8 +169,9 @@ export function validateWorkerResult(value) {
155
169
  // `output` is bounded on its own above and kept outside this envelope cap:
156
170
  // it is a discovery node's deliverable, not incidental prose competing with
157
171
  // summary/verification for the same 32 KiB budget.
158
- if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > RESULT_LIMITS.bytes) {
159
- throw new WorkerResultSizeError("worker result", RESULT_LIMITS.bytes);
172
+ const envelopeBytes = Buffer.byteLength(JSON.stringify(envelope), "utf8");
173
+ if (envelopeBytes > RESULT_LIMITS.bytes) {
174
+ throw new WorkerResultSizeError("worker result", RESULT_LIMITS.bytes, envelopeBytes);
160
175
  }
161
176
  return output === undefined ? envelope : { ...envelope, output };
162
177
  }
@@ -206,6 +221,7 @@ function requireList(value, label, maxItems, itemBytes) {
206
221
  if (!Array.isArray(value) || value.length > maxItems) throw new TypeError(`${label} must be an array with at most ${maxItems} items`);
207
222
  for (const [index, item] of value.entries()) {
208
223
  if (typeof item !== "string") throw new TypeError(`${label}[${index}] must be a string`);
209
- if (Buffer.byteLength(item, "utf8") > itemBytes) throw new WorkerResultSizeError(`${label}[${index}]`, itemBytes);
224
+ const bytes = Buffer.byteLength(item, "utf8");
225
+ if (bytes > itemBytes) throw new WorkerResultSizeError(`${label}[${index}]`, itemBytes, bytes);
210
226
  }
211
227
  }
@@ -23,7 +23,7 @@ import { spawn } from "node:child_process";
23
23
  import { boundedGitSync } from "../repo/worktree.mjs";
24
24
  import { tmpdir } from "node:os";
25
25
  import { validateContract } from "../contract/index.mjs";
26
- import { assertInlineScriptProofsParse } from "../contract/definition-of-done.mjs";
26
+ import { assertInlineScriptProofsParse, assertProofCommandsParse } from "../contract/definition-of-done.mjs";
27
27
  import { validateNodeSnapshot } from "../contract/snapshot.mjs";
28
28
  import { compactCost as formatCost } from "../util.mjs";
29
29
  import { runsRoot } from "../run/paths.mjs";
@@ -97,6 +97,9 @@ export async function preflightContract(contractPath, options = {}) {
97
97
  // first dispatch, so a body no attempt could ever make exit 0 refuses the
98
98
  // launch here, naming its node and item, instead of after three paid tries.
99
99
  assertInlineScriptProofsParse(contract.nodes);
100
+ // AP13: a proof command /bin/sh cannot parse never runs, so it refuses here
101
+ // for the same reason an inline script that does not parse does.
102
+ assertProofCommandsParse(contract.nodes);
100
103
  return preflightRuntimes([...reachableRuntimes(contract).values()], { ...options, cwd: contract.cwd });
101
104
  }
102
105
 
@@ -267,6 +267,22 @@ export function judgePrompt(node, workerResult, context = {}) {
267
267
  "Assess every Definition of Done item by its id and cite the id you are addressing in each finding.");
268
268
  }
269
269
 
270
+ /**
271
+ * The one rule the judge has never been given, added after N5 of the
272
+ * `open-source-readiness` campaign (AP20). A requirement that reads "the
273
+ * guard refuses these paths, except the declared ones" enumerates its cases,
274
+ * and a delivery that holds the general shape without covering one of them
275
+ * passes a judge that only checks whether the shape holds. Measured
276
+ * 2026-09-27: the `guards-core` node shipped the guard without the default
277
+ * exclusions its requirement declared, the judge approved, and the operator
278
+ * had to intervene twice on the integration ref for the phase to close.
279
+ *
280
+ * The rule sits with the list it governs, so it is stated exactly when there
281
+ * are judgment items to arbitrate and never when there are none.
282
+ */
283
+ const JUDGE_ENUMERATED_ITEM_RULE =
284
+ "When an item enumerates cases, classes or exclusions (\"A, B and C\", \"every X except Y\"), it is satisfied only when the delivery covers every one of them, checked one by one against the diff: the general shape holding is not coverage. A finding that names such an item must say which of them is missing, and an approval that checked none of them is a protocol failure.";
285
+
270
286
  /**
271
287
  * The Definition of Done checklist for the judge prompt: judgment items the
272
288
  * judge must arbitrate, plus each deterministic item with the controller-run
@@ -283,7 +299,7 @@ function judgeDoDChecklist(items, deterministic) {
283
299
  const parts = [];
284
300
  const judgmentItems = items.filter((item) => item.judgment === true);
285
301
  parts.push(judgmentItems.length
286
- ? `Judgment items — arbitrate only these:\n${judgmentItems.map((item) => `- [${item.id}] ${item.text} (judgment)`).join("\n")}`
302
+ ? `Judgment items — arbitrate only these:\n${judgmentItems.map((item) => `- [${item.id}] ${item.text} (judgment)`).join("\n")}\n${JUDGE_ENUMERATED_ITEM_RULE}`
287
303
  : "No judgment items require arbitration.");
288
304
  const proofItems = items.filter((item) => item.proof !== undefined);
289
305
  if (proofItems.length) {
@@ -759,7 +759,14 @@ export async function driveRun(contract, runDir, states, campaign, lock, sourceI
759
759
  heartbeat.stop();
760
760
  lock.release();
761
761
  }
762
- renderStatusIfChanged(false, null);
762
+ // Forced, and after the lock is released: the change-detected render here
763
+ // was a no-op whenever the last tick had already written the same node
764
+ // states, which is the usual case, so the stored status.json kept the
765
+ // `controller: active` the last tick wrote while the lock was still held.
766
+ // Measured 2026-09-27 (AP12): `safe-to-hand-to-a-friend-phase-2` finished
767
+ // with `state: "active"` and pid 43002, and a watcher waiting for the
768
+ // controller to leave spun for two hours on a pid that no longer existed.
769
+ renderStatusIfChanged(true, null);
763
770
  renderCampaignHandoffSafely(campaign, runsDir, runDir);
764
771
  writeFindingsArtifact(runDir, contract, states);
765
772
  const failed = [...states.values()].filter((state) => state.status !== "done");
@@ -21,6 +21,7 @@ import { assertObject, rejectUnknown, requirePacketHash, requireString } from ".
21
21
  import { writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
22
22
  import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
23
23
  import { validatePlanPhases } from "./template.mjs";
24
+ import { assertProofCommandsParse } from "../contract/definition-of-done.mjs";
24
25
  import { NODE_VALUE_OPTIONS, assertFilteredProofsNameTheirTest } from "./proof-scope.mjs";
25
26
 
26
27
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
@@ -134,6 +135,33 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
134
135
  if (problems.size) throw new TypeError(`verification timeouts do not cover their measured durations: ${[...problems].join("; ")}`);
135
136
  }
136
137
 
138
+ /**
139
+ * Raise whichever of a command list's timeouts sit under
140
+ * `MEASURED_TIMEOUT_MARGIN` times their measured duration, recording each
141
+ * raise under `label`. Returns the list unchanged (by identity) when nothing
142
+ * needed raising, so the caller can tell whether to re-list.
143
+ *
144
+ * @param {import("../contract/index.mjs").VerificationCommand[]} commands
145
+ * @param {MeasuredFacts} facts
146
+ * @param {string} label
147
+ * @param {string[]} raised
148
+ * @returns {import("../contract/index.mjs").VerificationCommand[]}
149
+ */
150
+ function raiseCommandTimeouts(commands, facts, label, raised) {
151
+ let changed = false;
152
+ const next = commands.map((command) => {
153
+ const measuredMs = measuredMsFor(command.argv, facts);
154
+ if (measuredMs === null) return command;
155
+ const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
156
+ const timeoutSec = command.timeoutSec ?? 120;
157
+ if (timeoutSec >= requiredSec || requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) return command;
158
+ raised.push(`${label}: ${command.argv.join(" ")} ${timeoutSec}s -> ${requiredSec}s`);
159
+ changed = true;
160
+ return { ...command, timeoutSec: requiredSec };
161
+ });
162
+ return changed ? next : commands;
163
+ }
164
+
137
165
  /**
138
166
  * Raise every plan node's verification timeout that sits under
139
167
  * `MEASURED_TIMEOUT_MARGIN` times its measured duration to exactly that
@@ -144,6 +172,11 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
144
172
  * plan with no critical from review, contested only because three nodes gave
145
173
  * `node --test test/harnesses` 120s against a measured 84.4s (bound 127s).
146
174
  *
175
+ * The plan's own contract-level suites (RM-107) are raised here too, over the
176
+ * same measurement: `assertTimeoutsCoverMeasured` reads them, so a repair that
177
+ * skipped them would leave the check naming a repair this function does not
178
+ * make. Its label is the suite key, since a contract suite belongs to no node.
179
+ *
147
180
  * @param {import("./template.mjs").PlanOutput} plan
148
181
  * @param {MeasuredFacts} facts
149
182
  * @returns {{plan: import("./template.mjs").PlanOutput, raised: string[]}}
@@ -151,19 +184,22 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
151
184
  export function raiseTimeoutsToMeasured(plan, facts) {
152
185
  /** @type {string[]} */
153
186
  const raised = [];
154
- const nodes = plan.nodes.map((node) => ({
155
- ...node,
156
- verification: (node.verification ?? []).map((command) => {
157
- const measuredMs = measuredMsFor(command.argv, facts);
158
- if (measuredMs === null) return command;
159
- const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
160
- const timeoutSec = command.timeoutSec ?? 120;
161
- if (timeoutSec >= requiredSec || requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) return command;
162
- raised.push(`${node.id}: ${command.argv.join(" ")} ${timeoutSec}s -> ${requiredSec}s`);
163
- return { ...command, timeoutSec: requiredSec };
164
- }),
165
- }));
166
- return { plan: raised.length ? { ...plan, nodes } : plan, raised };
187
+ const nodes = plan.nodes.map((node) => {
188
+ const verification = raiseCommandTimeouts(node.verification ?? [], facts, node.id, raised);
189
+ return verification === node.verification ? node : { ...node, verification };
190
+ });
191
+ const sharedVerification = raiseCommandTimeouts(plan.sharedVerification ?? [], facts, "sharedVerification", raised);
192
+ const finalVerification = raiseCommandTimeouts(plan.finalVerification ?? [], facts, "finalVerification", raised);
193
+ if (raised.length === 0) return { plan, raised };
194
+ return {
195
+ plan: {
196
+ ...plan,
197
+ nodes,
198
+ ...(plan.sharedVerification === undefined ? {} : { sharedVerification }),
199
+ ...(plan.finalVerification === undefined ? {} : { finalVerification }),
200
+ },
201
+ raised,
202
+ };
167
203
  }
168
204
 
169
205
  /** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
@@ -413,6 +449,7 @@ export function freezePlan(plan, { outDir, provenance, phases, spec, humanSteps,
413
449
  try {
414
450
  const validated = validateContract(raw, contractPath);
415
451
  assertFilteredProofsNameTheirTest(validated);
452
+ assertProofCommandsParse(validated.nodes);
416
453
  if (facts) assertTimeoutsCoverMeasured(validated, facts);
417
454
  } catch (error) {
418
455
  rmSync(contractPath, { force: true });