faberun 0.19.1 → 0.19.2

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.19.1",
3
+ "version": "0.19.2",
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": {
@@ -46,8 +46,10 @@ typecheck`). Schema version is `3`.
46
46
  ```
47
47
 
48
48
  Every `definitionOfDone` item declares `id`, `text`, and how it is proven:
49
- `proof.kind` `command` (re-runs the command, capped at `min(timeoutSec, 120s)`)
50
- or `path` (a file must exist), or `judgment: true` for the judge. `proof: {
49
+ `proof.kind` `command` (re-runs the command through a shell, bounded by the
50
+ node's own `timeoutSec`; quote a flag value containing spaces, which
51
+ `verification`'s argv does not need and a shell splits) or `path` (a file must
52
+ exist), or `judgment: true` for the judge. `proof: {
51
53
  kind: "verification", ref: <index> }` reuses a `verification` entry's already
52
54
  recorded result by position instead of re-running it — never by comparing argv
53
55
  strings, since a joined argv loses shell semantics. A schema-1 string item is
@@ -95,8 +97,11 @@ discovery packet has empty `writeFiles`; with an empty `readFiles` it may
95
97
  read the repository read-only to produce an execution packet — the one
96
98
  exception to closed scope — otherwise it is closed to the listed files. Each
97
99
  `verification` entry is `{argv, cwd?, timeoutSec? (default 120, max 600),
98
- repeat? (default 1, max 8), env?}` — at most 32 commands, 64 argv items, 32
99
- KiB argv bytes per command. `env` declares variable *names* only; values
100
+ repeat? (default 1, max 8), env?, requirementId?}` — at most 32 commands, 64
101
+ argv items, 32 KiB argv bytes per command. `requirementId` names the spec
102
+ requirement this command proves, changing nothing about how it runs: it is
103
+ what lets `contract validate` report two copies of one proof that have stopped
104
+ agreeing. `env` declares variable *names* only; values
100
105
  never travel in the packet. `prompt`/`promptFile` are
101
106
  rejected; a node has `taskPacket` or `taskPacketFile`, never both. Measure a
102
107
  candidate command's real duration before naming it in `verification` or a
@@ -289,7 +294,14 @@ only for a harness declaring `streamsOutput` (true for `codex`, `claude`,
289
294
  others fall back to `timeoutSec` alone.
290
295
  `timeoutSec` (default 2400s) caps one invocation and may be overridden per
291
296
  node; a node is bounded by `(1 + maxRevisions) × 2 × timeoutSec`. Both clocks
292
- are monotonic and pause with host suspend. `maxParallel` above 1 dispatches
297
+ are monotonic and pause with host suspend.
298
+ `maxTurns` (default 150) bounds something else: the *provider requests* one
299
+ attempt may make, overridable per node. Reaching it ends the attempt with
300
+ `errorCode: turn_limit`, sealed then retried once, the spend already spent. It
301
+ bites the nodes that read much and write little — review, synthesis — so raise
302
+ it there; raising `timeoutSec` does not help. The controller says so once at
303
+ 80%, and `usage.jsonl` records each invocation's `session.requests`.
304
+ `maxParallel` above 1 dispatches
293
305
  every dependency-ready node concurrently, each into its own attempt
294
306
  worktree; integration stays serialized. Nodes of one phase need no edge
295
307
  between them: a continuation a live invocation already claims is never
@@ -132,9 +132,14 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
132
132
  requireTimestamp(at, "at");
133
133
  const campaign = readCampaign(campaignPath);
134
134
  if (campaign.status === "closed") throw new Error(`campaign already closed: ${campaign.id}`);
135
- if (!readJournalForDedupe(campaignPath).some((entry) => entry.type === "retrospective")) {
135
+ const journal = readJournalForDedupe(campaignPath);
136
+ if (!journal.some((entry) => entry.type === "retrospective")) {
136
137
  throw new Error(`campaign ${campaign.id} has no recorded retrospective; record one with note --kind retrospective before close`);
137
138
  }
139
+ const unacknowledged = unacknowledgedAdvisories(campaignPath, campaign, journal);
140
+ if (unacknowledged.length) {
141
+ throw new Error(`campaign ${campaign.id} has judge findings no note has answered: ${unacknowledged.join("; ")}. Read them with \`faberun findings <run-dir>\`, then name the node in a note (\`campaign note ${campaign.id} --kind outcome --run-id <run-id> --text "...<node>..."\`) before close`);
142
+ }
138
143
  const repoRoot = campaignRepoRoot(campaignPath);
139
144
  const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
140
145
  // The closure travels on the record itself, computed in one deterministic
@@ -147,6 +152,56 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
147
152
  return { path: campaignPath, campaign: closed, ledgerFiles };
148
153
  }
149
154
 
155
+ /**
156
+ * Judge findings on nodes the gate accepted, which no journal note names.
157
+ *
158
+ * A gate that accepts a node whose findings sit below `failOn` is correct and
159
+ * documented. What was wrong is that the campaign could then be closed with
160
+ * the finding never read by anyone: measured 2026-09-21, a synthesis node's
161
+ * `gate.verdict` was `fail` with a real finding, `STATUS` said `passed`, and
162
+ * the campaign's own retrospective recorded that every gate passed first
163
+ * time. A close is the last moment the claim can still be corrected.
164
+ *
165
+ * Acknowledgement is a note that names the node id. The node id is the
166
+ * identifier the run, the contract and the findings output all already use,
167
+ * so matching on it asks the operator for nothing new; matching on a
168
+ * finding's prose would be matching on text the judge wrote, which is not a
169
+ * stable name.
170
+ *
171
+ * @param {string} campaignPath
172
+ * @param {Campaign} campaign
173
+ * @param {{type: string, text?: unknown}[]} journal
174
+ * @returns {string[]} one `runId/nodeId (N findings, maxSeverity)` per unanswered node, sorted
175
+ */
176
+ function unacknowledgedAdvisories(campaignPath, campaign, journal) {
177
+ const runsDir = resolve(campaignPath, "..", "..");
178
+ const noteText = journal
179
+ .filter((entry) => typeof entry.text === "string")
180
+ .map((entry) => /** @type {string} */ (entry.text))
181
+ .join("\n");
182
+ /** @type {string[]} */
183
+ const pending = [];
184
+ for (const runId of [...campaign.linkedRunIds].sort()) {
185
+ const contract = readRunJson(join(runsDir, runId, "contract.json"));
186
+ const nodes = contract !== null && Array.isArray(contract.nodes) ? /** @type {JsonObject[]} */ (contract.nodes) : [];
187
+ for (const node of nodes) {
188
+ const nodeId = typeof node.id === "string" ? node.id : "";
189
+ if (!nodeId) continue;
190
+ const snapshot = readRunJson(join(runsDir, runId, "nodes", `${nodeId}.json`));
191
+ if (snapshot === null) continue;
192
+ // Only an accepted node: on a rejected one the findings are the
193
+ // rejection itself, and the run already refuses to read as finished.
194
+ if (snapshot.status !== "done" && snapshot.status !== "no-op") continue;
195
+ const gate = /** @type {JsonObject|null|undefined} */ (snapshot.gate);
196
+ const findings = gate && Array.isArray(gate.findings) ? gate.findings : [];
197
+ if (findings.length === 0) continue;
198
+ if (noteText.includes(nodeId)) continue;
199
+ pending.push(`${runId}/${nodeId} (${findings.length} ${findings.length === 1 ? "finding" : "findings"}, ${typeof gate?.maxSeverity === "string" ? gate.maxSeverity : "unknown"})`);
200
+ }
201
+ }
202
+ return pending.sort();
203
+ }
204
+
150
205
  /**
151
206
  * The requirement closure a close records: one entry per requirement id the
152
207
  * linked runs' contracts declared, correlated only by the identifiers the runs
package/src/cli/plan.mjs CHANGED
@@ -4,8 +4,8 @@
4
4
  * contested. This file only owns the wire — `src/plan/pipeline.mjs` owns the
5
5
  * sequencing and every decision the pipeline makes.
6
6
  */
7
- import { readFileSync } from "node:fs";
8
- import { resolve } from "node:path";
7
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
9
  import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
10
10
  import { classifyRunProgress } from "../campaign/chain.mjs";
11
11
  import { runProgress } from "../engine/supervise.mjs";
@@ -15,11 +15,22 @@ import { validateFinalVerification, validateSharedVerification } from "../contra
15
15
  import { colorLevel, statusToken } from "./brand.mjs";
16
16
  import { delay } from "../util.mjs";
17
17
  import { runPlanningPipeline } from "../plan/pipeline.mjs";
18
- import { runDirectory } from "../run/paths.mjs";
18
+ import { campaignTree, runDirectory } from "../run/paths.mjs";
19
19
 
20
20
  /** How often a foreground `plan` polls a launched stage's run directory. */
21
21
  const DEFAULT_POLL_MS = 1_000;
22
22
 
23
+ /**
24
+ * How long a `--detach` launcher stays to see whether the planning process it
25
+ * started is actually up. Long enough to cover the bootstrap work that fails
26
+ * synchronously -- spec read, strict validation, catalogue load, the runtime
27
+ * ask -- and short enough that a launcher is not a supervisor: past this
28
+ * window, a planning run that dies is a running campaign's problem and leaves
29
+ * its evidence in the run directory, not here.
30
+ */
31
+ const PLAN_BOOTSTRAP_WINDOW_MS = 5_000;
32
+ const PLAN_BOOTSTRAP_POLL_MS = 100;
33
+
23
34
  /**
24
35
  * `--runtime-defaults worker=<id>,judge=<id>`, either key optional, comma
25
36
  * separated. Absent entirely, the pipeline falls through to plain
@@ -114,7 +125,7 @@ export function loadVerificationSuites(path) {
114
125
 
115
126
  /**
116
127
  * @param {string} target
117
- * @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, runtimes?: string, verification?: string, detach?: boolean, json?: boolean}} values
128
+ * @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, runtimes?: string, verification?: string, package?: string, detach?: boolean, json?: boolean}} values
118
129
  * @returns {Promise<void>}
119
130
  */
120
131
  export async function planCli(target, values) {
@@ -131,6 +142,7 @@ export async function planCli(target, values) {
131
142
  const verification = typeof values.verification === "string" && values.verification
132
143
  ? loadVerificationSuites(values.verification)
133
144
  : {};
145
+ const packageMode = packageModeOf(values.package);
134
146
 
135
147
  if (values.detach === true) {
136
148
  const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
@@ -138,35 +150,63 @@ export async function planCli(target, values) {
138
150
  if (values["runtime-defaults"] !== undefined) argv.push("--runtime-defaults", values["runtime-defaults"]);
139
151
  if (typeof values.runtimes === "string" && values.runtimes) argv.push("--runtimes", resolve(values.runtimes));
140
152
  if (typeof values.verification === "string" && values.verification) argv.push("--verification", resolve(values.verification));
153
+ if (packageMode !== "implementation") argv.push("--package", packageMode);
154
+ const failurePath = planBootstrapFailurePath(process.cwd(), campaignId, phase);
155
+ mkdirSync(dirname(failurePath), { recursive: true });
156
+ rmSync(failurePath, { force: true });
141
157
  const child = detachArgv(argv);
142
158
  if (child.pid === undefined) throw new Error("detached plan has no pid");
159
+ // The child's stdio is discarded (detachArgv), so a planning run that dies
160
+ // during bootstrap used to take its own reason with it while the launcher
161
+ // had already printed a pid and exited 0. Measured 2026-09-21 on macOS and
162
+ // Linux the same day: a run died after collecting repo facts and the stderr
163
+ // went with the closed connection. Waiting out a bounded window is the
164
+ // whole check -- a controller still alive past it is up, and one that is
165
+ // not has written why.
166
+ const failure = await watchPlanBootstrap(child, failurePath);
167
+ if (failure) {
168
+ process.stderr.write(`[plan] bootstrap failed · ${failure.error}\n[plan] recorded at ${failurePath}\n`);
169
+ process.exitCode = 1;
170
+ return;
171
+ }
143
172
  process.stdout.write(`[plan] detached · pid ${child.pid} · ${specPath}\n`);
144
173
  return;
145
174
  }
146
175
 
147
- const result = await runPlanningPipeline({
148
- specPath,
149
- campaignId,
150
- phase,
151
- reviewRounds,
152
- approveBelow,
153
- runtimeDefaults,
154
- runtimes,
155
- verification,
156
- launch: async (contractPath, contract) => {
157
- const child = detachSelf("run", contractPath);
158
- if (child.pid === undefined) throw new Error("detached planning run has no pid");
159
- await waitForBootstrap(runDirectory(contract.cwd, contract.id), child.pid, child);
160
- },
161
- wait: async (runDir) => {
162
- for (;;) {
163
- const progress = runProgress(runDir);
164
- const classification = classifyRunProgress(progress);
165
- if (classification !== "unfinished" && classification !== "waiting") return progress;
166
- await delay(DEFAULT_POLL_MS);
167
- }
168
- },
169
- });
176
+ // A detached child arrives here with its stdio already discarded, so what
177
+ // it throws reaches nobody unless it is written down first. The record is
178
+ // written on every path, detached or not: a foreground failure that also
179
+ // leaves the file costs nothing and reads the same.
180
+ let result;
181
+ try {
182
+ result = await runPlanningPipeline({
183
+ specPath,
184
+ campaignId,
185
+ phase,
186
+ reviewRounds,
187
+ approveBelow,
188
+ runtimeDefaults,
189
+ runtimes,
190
+ verification,
191
+ packageMode,
192
+ launch: async (contractPath, contract) => {
193
+ const child = detachSelf("run", contractPath);
194
+ if (child.pid === undefined) throw new Error("detached planning run has no pid");
195
+ await waitForBootstrap(runDirectory(contract.cwd, contract.id), child.pid, child);
196
+ },
197
+ wait: async (runDir) => {
198
+ for (;;) {
199
+ const progress = runProgress(runDir);
200
+ const classification = classifyRunProgress(progress);
201
+ if (classification !== "unfinished" && classification !== "waiting") return progress;
202
+ await delay(DEFAULT_POLL_MS);
203
+ }
204
+ },
205
+ });
206
+ } catch (error) {
207
+ writePlanBootstrapFailure(process.cwd(), campaignId, phase, error instanceof Error ? error : new Error(String(error)));
208
+ throw error;
209
+ }
170
210
 
171
211
  if (values.json === true) {
172
212
  process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -180,3 +220,100 @@ export async function planCli(target, values) {
180
220
  for (const warning of result.warnings) process.stdout.write(`${statusToken("warn", colorLevel(process.env, process.stdout.isTTY))} ${warning}\n`);
181
221
  process.stdout.write(`[plan] ${campaignId} phase ${phase} frozen · approved ${result.approved} · ${result.contractPath}\n`);
182
222
  }
223
+
224
+ /**
225
+ * Where a detached planning run records why it never came up. It sits beside
226
+ * the phase's durable plan artifacts rather than in the disposable scratch
227
+ * tree, and it is derived from campaign and phase alone -- the launcher and
228
+ * the child compute the same path without either having to parse the spec.
229
+ *
230
+ * @param {string} cwd
231
+ * @param {string} campaignId
232
+ * @param {string} phase
233
+ * @returns {string}
234
+ */
235
+ export function planBootstrapFailurePath(cwd, campaignId, phase) {
236
+ return join(campaignTree(cwd, campaignId), "plans", phase, "bootstrap-failure.json");
237
+ }
238
+
239
+ /**
240
+ * Record a detached planning run's bootstrap failure where its launcher can
241
+ * read it. Best effort: a failure to write this must never replace the
242
+ * failure it was describing.
243
+ *
244
+ * @param {string} cwd
245
+ * @param {string} campaignId
246
+ * @param {string} phase
247
+ * @param {Error} error
248
+ * @returns {void}
249
+ */
250
+ export function writePlanBootstrapFailure(cwd, campaignId, phase, error) {
251
+ try {
252
+ const path = planBootstrapFailurePath(cwd, campaignId, phase);
253
+ mkdirSync(dirname(path), { recursive: true });
254
+ writeFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), pid: process.pid, campaignId, phase, error: error.message }, null, 2)}\n`);
255
+ } catch {
256
+ // Nothing left to do: the caller is already reporting the real failure.
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Watch a freshly detached planning child through its bootstrap window.
262
+ *
263
+ * Returns the recorded failure when the child died inside the window, and
264
+ * null when it is still running at the end of it. A child that exits zero
265
+ * inside the window also reads as no failure: a planning run can legitimately
266
+ * be that fast only by refusing early, and it will have written its own
267
+ * record if it refused.
268
+ *
269
+ * @param {import("node:child_process").ChildProcess} child
270
+ * @param {string} failurePath
271
+ * @param {{windowMs?: number, pollMs?: number}} [options]
272
+ * @returns {Promise<{error: string}|null>}
273
+ */
274
+ export async function watchPlanBootstrap(child, failurePath, { windowMs = PLAN_BOOTSTRAP_WINDOW_MS, pollMs = PLAN_BOOTSTRAP_POLL_MS } = {}) {
275
+ let exitCode = /** @type {number|null|undefined} */ (undefined);
276
+ let exited = false;
277
+ child.once("exit", (code) => { exited = true; exitCode = code; });
278
+ const deadline = Date.now() + windowMs;
279
+ while (Date.now() < deadline) {
280
+ // Typed checks, not `!== null`: a child object that never carried the
281
+ // property at all would otherwise read as one that has already exited.
282
+ if (exited || typeof child.exitCode === "number" || typeof child.signalCode === "string") {
283
+ const recorded = readPlanBootstrapFailure(failurePath);
284
+ if (recorded) return recorded;
285
+ const code = typeof exitCode === "number" ? exitCode : child.exitCode;
286
+ if (typeof code === "number" && code !== 0) return { error: `the detached planning process exited ${code} without recording a reason` };
287
+ const signal = child.signalCode;
288
+ if (typeof signal === "string") return { error: `the detached planning process was killed by ${signal} without recording a reason` };
289
+ return null;
290
+ }
291
+ await delay(pollMs);
292
+ }
293
+ return null;
294
+ }
295
+
296
+ /** @param {string} failurePath @returns {{error: string}|null} */
297
+ function readPlanBootstrapFailure(failurePath) {
298
+ try {
299
+ const record = JSON.parse(readFileSync(failurePath, "utf8"));
300
+ return typeof record?.error === "string" ? { error: record.error } : null;
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * `--package implementation|exploratory`. Implementation is the default and
308
+ * the only mode there was: nodes sized by their write set. Exploratory sizes
309
+ * by what a node reads, accepts a one-file write set as the normal shape of a
310
+ * finding, and reports a node whose read surface dwarfs its siblings'.
311
+ *
312
+ * @param {unknown} value
313
+ * @returns {import("../plan/sizing.mjs").PackageMode}
314
+ */
315
+ export function packageModeOf(value) {
316
+ if (value === undefined) return "implementation";
317
+ if (value === "implementation" || value === "exploratory") return value;
318
+ throw new Error(`--package must be implementation or exploratory: ${String(value)}`);
319
+ }
package/src/cli/spec.mjs CHANGED
@@ -14,7 +14,7 @@ import { validateSpec } from "../plan/spec.mjs";
14
14
  /** Flags are scoped to the operation that declares them; all others are rejected. */
15
15
  /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
16
16
  const OPERATION_OPTIONS = {
17
- validate: { "strict-traceability": { type: "boolean" }, json: { type: "boolean" } },
17
+ validate: { "strict-traceability": { type: "boolean" }, "run-proofs": { type: "boolean" }, json: { type: "boolean" } },
18
18
  scaffold: { id: { type: "string" } },
19
19
  };
20
20
 
@@ -63,9 +63,13 @@ export function specCli(args) {
63
63
  }
64
64
  const target = parsed.positionals[0];
65
65
  if (!target || parsed.positionals.length > 1) return usage();
66
- const values = /** @type {{"strict-traceability"?: boolean, json?: boolean, id?: string}} */ (parsed.values);
66
+ const values = /** @type {{"strict-traceability"?: boolean, "run-proofs"?: boolean, json?: boolean, id?: string}} */ (parsed.values);
67
67
  if (operation === "validate") {
68
- validateSpecFile(resolve(target), { strict: values["strict-traceability"] === true, json: values.json === true });
68
+ validateSpecFile(resolve(target), {
69
+ strict: values["strict-traceability"] === true,
70
+ runProofs: values["run-proofs"] === true,
71
+ json: values.json === true,
72
+ });
69
73
  return;
70
74
  }
71
75
  try {
@@ -80,12 +84,18 @@ export function specCli(args) {
80
84
  * Validate a spec file and print its class, its overall verdict, and one
81
85
  * line per finding. Exits `1` when the verdict is not `ok`.
82
86
  *
87
+ * `runProofs` is the only operation here that spawns anything: it runs each
88
+ * requirement's declared proof instead of only checking that one is written
89
+ * down. It is opt-in because running a repository's proofs costs real time,
90
+ * and default-off keeps `spec validate` the deterministic, side-effect-free
91
+ * read it has always been.
92
+ *
83
93
  * @param {string} path
84
- * @param {{strict: boolean, json: boolean}} options
94
+ * @param {{strict: boolean, json: boolean, runProofs?: boolean}} options
85
95
  * @returns {SpecValidation}
86
96
  */
87
- export function validateSpecFile(path, { strict, json }) {
88
- const result = validateSpec(readFileSync(path, "utf8"), { cwd: process.cwd(), strict });
97
+ export function validateSpecFile(path, { strict, json, runProofs = false }) {
98
+ const result = validateSpec(readFileSync(path, "utf8"), { cwd: process.cwd(), strict, runProofs });
89
99
  if (json) {
90
100
  process.stdout.write(`${JSON.stringify(result)}\n`);
91
101
  } else {
@@ -112,7 +122,7 @@ export function scaffoldSpec(path, id) {
112
122
 
113
123
  /** @returns {void} */
114
124
  function usage() {
115
- process.stderr.write("usage: faberun spec <validate|scaffold> <path> [--strict-traceability] [--json] [--id <value>]\n");
125
+ process.stderr.write("usage: faberun spec <validate|scaffold> <path> [--strict-traceability] [--run-proofs] [--json] [--id <value>]\n");
116
126
  process.exitCode = 2;
117
127
  }
118
128
 
package/src/cli.mjs CHANGED
@@ -123,6 +123,7 @@ export const COMMAND_OPTIONS = {
123
123
  "runtime-defaults": { type: "string" },
124
124
  runtimes: { type: "string" },
125
125
  verification: { type: "string" },
126
+ package: { type: "string" },
126
127
  detach: { type: "boolean" },
127
128
  json: { type: "boolean" },
128
129
  },
@@ -95,3 +95,53 @@ function validateVerificationRef(value, label, options) {
95
95
  return String(index);
96
96
  }
97
97
 
98
+ /**
99
+ * A `kind: "command"` proof's `ref` runs through a shell (`judge-gate.mjs`'s
100
+ * `proveCommand`), while a task packet's `verification` entries run as argv
101
+ * arrays -- the opposite quoting convention for the same author intent.
102
+ * Measured 2026-09-22: six DoD refs on one campaign wrote
103
+ * `--test-name-pattern=a b c` the way an argv array would take it, the shell
104
+ * split it into three words, and the gate rejected work whose identical
105
+ * command had just passed as verification. Nothing warned the author, because
106
+ * a shell that receives extra bare words after an unquoted flag value does not
107
+ * itself know they were meant to be one argument.
108
+ *
109
+ * This flags the same shape rather than every proof: a node:test filter flag
110
+ * (the flags `declaredTestFilters` in judge-gate.mjs recognizes) whose
111
+ * unquoted value is immediately followed by bare words is the pattern the
112
+ * incident measured, and it is precise enough that a legitimate `ref` rarely
113
+ * has trailing bare words right after such a flag by accident.
114
+ *
115
+ * @param {DefinitionOfDoneItem[]} items
116
+ * @param {number} index
117
+ * @returns {string[]}
118
+ */
119
+ export function unquotedFilterValueWarnings(items, index) {
120
+ /** @type {string[]} */
121
+ const warnings = [];
122
+ items.forEach((item, itemIndex) => {
123
+ if (item.proof?.kind !== "command") return;
124
+ const tokens = item.proof.ref.split(/\s+/u).filter(Boolean);
125
+ for (const flag of TEST_FILTER_FLAGS) {
126
+ for (let position = 0; position < tokens.length; position++) {
127
+ const prefix = `${flag}=`;
128
+ if (!tokens[position].startsWith(prefix)) continue;
129
+ const value = tokens[position].slice(prefix.length);
130
+ if (/^['"]/u.test(value)) continue;
131
+ let end = position + 1;
132
+ while (end < tokens.length && !tokens[end].startsWith("-")) end++;
133
+ if (end === position + 1) continue;
134
+ const spilled = [value, ...tokens.slice(position + 1, end)].join(" ");
135
+ warnings.push(
136
+ `nodes[${index}] (definitionOfDone[${itemIndex}]): proof.ref's ${flag} value "${spilled}" is unquoted; ` +
137
+ `kind: "command" runs through a shell, unlike taskPacket.verification's argv, so the space splits it into extra words -- quote it as ${flag}="${spilled}"`,
138
+ );
139
+ }
140
+ }
141
+ });
142
+ return warnings;
143
+ }
144
+
145
+ /** The node:test filter flags a `kind: "command"` proof's shell can split on an unquoted value. */
146
+ const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
147
+
@@ -60,6 +60,27 @@ export function sharedVerificationCommands(contract) {
60
60
  return contract.sharedVerification ?? [];
61
61
  }
62
62
 
63
+ /**
64
+ * The timeout a Definition of Done `command` proof is spawned with. It used
65
+ * to be capped at a hardcoded 120s regardless of what the node's own attempt
66
+ * was given, so a contract that raised `timeoutSec` to run a slow command as
67
+ * `taskPacket.verification` still had the identical command fail its DoD
68
+ * proof on the same run: measured 2026-09-22, six proofs rejected work whose
69
+ * argv had just passed with a `timeoutSec` well past 120s. A command proof
70
+ * gets the same budget the node's own attempt has, because that is the
71
+ * budget the author already reasoned about; nothing here invents a second
72
+ * number for the gate to disagree with. Shared by `dispatch.mjs` (the actual
73
+ * spawn) and `scheduler.mjs` (the freeze-detection budget that must not judge
74
+ * a node frozen before its own gate's timeout has had a chance to fire).
75
+ *
76
+ * @param {{timeoutSec?: number}} node
77
+ * @param {{timeoutSec?: number}} contract
78
+ * @returns {number}
79
+ */
80
+ export function gateProofTimeoutMs(node, contract) {
81
+ return Math.max(1_000, (node.timeoutSec ?? contract.timeoutSec ?? 60) * 1_000);
82
+ }
83
+
63
84
  /**
64
85
  * Whether no other node in the contract depends on this one. `finalVerification`
65
86
  * is a candidate to run on a phase-terminal node only; a node with a dependant
@@ -3,9 +3,9 @@ import { readFileSync, statSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { loadTaskPacket, renderWorkerPrompt } from "./task-packet.mjs";
5
5
  import { RESERVED_ARTICLES } from "./articles.mjs";
6
- import { validateDefinitionOfDone } from "./definition-of-done.mjs";
6
+ import { unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
7
7
  import { validateFinalVerification, validateSharedVerification } from "./final-verification.mjs";
8
- import { VERIFICATION_LIMITS } from "./verification.mjs";
8
+ import { VERIFICATION_LIMITS, requirementProofWarnings } from "./verification.mjs";
9
9
  import {
10
10
  validateCapabilityRequirements,
11
11
  } from "../harnesses/index.mjs";
@@ -374,12 +374,24 @@ export function validateContract(raw, contractPath, options = {}) {
374
374
  const sharedVerification = validateSharedVerification(raw.sharedVerification, "contract.sharedVerification");
375
375
  const contractCommands = [...finalVerification ?? [], ...sharedVerification ?? []].map((command) => command.argv.join(" "));
376
376
  const contractWrites = new Set(nodes.flatMap((node) => node.taskPacket.writeFiles ?? []));
377
- const warnings = nodes.flatMap((node, index) => [
378
- ...commandCoverageWarnings(node, index),
379
- ...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
380
- ...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
381
- ...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
382
- ]);
377
+ const warnings = [
378
+ ...nodes.flatMap((node, index) => [
379
+ ...commandCoverageWarnings(node, index),
380
+ ...unquotedFilterValueWarnings(node.definitionOfDone ?? [], index),
381
+ ...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
382
+ ...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
383
+ ...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
384
+ ]),
385
+ // Cross-node by construction: a requirement proven in two nodes is only
386
+ // visible when every node's commands are read together, which is the
387
+ // whole point -- one copy repaired and six left behind is what a per-node
388
+ // read cannot see.
389
+ ...requirementProofWarnings([
390
+ ...nodes.map((node, index) => ({ id: `nodes[${index}] (${node.id})`, requirementIds: node.requirementIds, commands: node.taskPacket.verification ?? [] })),
391
+ { id: "contract.sharedVerification", commands: sharedVerification ?? [] },
392
+ { id: "contract.finalVerification", commands: finalVerification ?? [] },
393
+ ]),
394
+ ];
383
395
  const contract = /** @type {ValidatedContract} */ ({
384
396
  ...raw,
385
397
  schemaVersion: /** @type {number} */ (raw.schemaVersion),
@@ -42,7 +42,14 @@ export const MUTATION_TIERS = Object.freeze({
42
42
  /**
43
43
  * One declared deterministic check: an argv command run by the controller.
44
44
  *
45
- * @typedef {{argv: string[], cwd?: string, timeoutSec?: number, repeat?: number, env?: string[], mutation?: {tier: MutationTier}}} VerificationCommand
45
+ * `requirementId` names the spec requirement this command is the proof of. It
46
+ * changes nothing about how the command runs; it is what makes a duplicated
47
+ * proof visible. Measured 2026-09-22: one broken command lived in a spec's R3,
48
+ * in its R4 and in seven nodes' verification, and the repair reached one of
49
+ * them — nothing could tell that the other copies had stopped agreeing,
50
+ * because nothing recorded that they were copies of one claim.
51
+ *
52
+ * @typedef {{argv: string[], cwd?: string, timeoutSec?: number, repeat?: number, env?: string[], mutation?: {tier: MutationTier}, requirementId?: string}} VerificationCommand
46
53
  */
47
54
 
48
55
  /**
@@ -122,7 +129,7 @@ export function validateVerificationCommands(commands, label = "verification") {
122
129
  function validateVerificationCommand(command, label = "verification command") {
123
130
  if (!command || typeof command !== "object" || Array.isArray(command)) throw new TypeError(`${label} must be an argv command object`);
124
131
  const record = /** @type {Record<string, unknown>} */ (command);
125
- const allowed = new Set(["argv", "cwd", "timeoutSec", "repeat", "env", "mutation"]);
132
+ const allowed = new Set(["argv", "cwd", "timeoutSec", "repeat", "env", "mutation", "requirementId"]);
126
133
  for (const key of Object.keys(record)) if (!allowed.has(key)) throw new TypeError(`${label} has unexpected field ${key}`);
127
134
  if (!Array.isArray(record.argv) || record.argv.length === 0 || record.argv.length > 64 || record.argv.some((item) => typeof item !== "string" || !item.trim() || Buffer.byteLength(item, "utf8") > 8 * 1024)) {
128
135
  throw new TypeError(`${label}.argv must be a non-empty array of strings`);
@@ -157,10 +164,18 @@ function validateVerificationCommand(command, label = "verification command") {
157
164
  }
158
165
  mutation = { tier: /** @type {MutationTier} */ (mutationRecord.tier) };
159
166
  }
167
+ // Bounded exactly like the node-level `requirementIds` it must match against
168
+ // (at most 128 bytes), so the two sides of the claim cannot accept different
169
+ // ids.
170
+ if (record.requirementId !== undefined
171
+ && (typeof record.requirementId !== "string" || !record.requirementId.trim() || Buffer.byteLength(record.requirementId, "utf8") > 128)) {
172
+ throw new TypeError(`${label}.requirementId must be a requirement id of at most 128 bytes`);
173
+ }
160
174
  /** @type {VerificationCommand} */
161
175
  const normalized = { argv: [.../** @type {string[]} */ (record.argv)], timeoutSec, repeat, env: [.../** @type {string[]} */ (env)] };
162
176
  if (record.cwd !== undefined) normalized.cwd = /** @type {string} */ (record.cwd);
163
177
  if (mutation !== undefined) normalized.mutation = mutation;
178
+ if (record.requirementId !== undefined) normalized.requirementId = /** @type {string} */ (record.requirementId);
164
179
  return normalized;
165
180
  }
166
181
 
@@ -199,3 +214,49 @@ export function compactVerification(result) {
199
214
  return { passed: Boolean(result?.passed), commands };
200
215
  }
201
216
 
217
+
218
+ /**
219
+ * One proof, one source. A command that declares `requirementId` says it is
220
+ * the proof of that requirement; this reports the two ways such a claim can
221
+ * be false.
222
+ *
223
+ * A claim the node does not carry is a mislabel: the node's own
224
+ * `requirementIds` are what the phase assigned it, and a command proving
225
+ * something outside them is either the wrong id or the wrong node.
226
+ *
227
+ * Copies that stopped agreeing are the measured one. 2026-09-22: a broken
228
+ * command lived in a spec's R3, its R4, and seven nodes' verification, and
229
+ * the repair reached one copy. Nothing could see that the others had drifted,
230
+ * because nothing recorded that they were copies of a single claim. Argv is
231
+ * compared against argv, never a joined string against a shell command: a
232
+ * joined argv loses argument boundaries, which is the same reason a
233
+ * `verification` proof references an index instead of comparing text.
234
+ *
235
+ * @param {Array<{id: string, requirementIds?: string[], commands: VerificationCommand[]}>} owners
236
+ * @returns {string[]}
237
+ */
238
+ export function requirementProofWarnings(owners) {
239
+ /** @type {string[]} */
240
+ const warnings = [];
241
+ /** @type {Map<string, Array<{owner: string, position: number, argv: string[]}>>} */
242
+ const claims = new Map();
243
+ for (const owner of owners) {
244
+ owner.commands.forEach((command, position) => {
245
+ const requirementId = command.requirementId;
246
+ if (requirementId === undefined) return;
247
+ if (owner.requirementIds !== undefined && !owner.requirementIds.includes(requirementId)) {
248
+ warnings.push(`${owner.id}: verification[${position}] declares requirementId "${requirementId}", which this node does not carry in requirementIds`);
249
+ }
250
+ const claimed = claims.get(requirementId) ?? [];
251
+ claimed.push({ owner: owner.id, position, argv: command.argv });
252
+ claims.set(requirementId, claimed);
253
+ });
254
+ }
255
+ for (const [requirementId, claimed] of claims) {
256
+ const distinct = new Map(claimed.map((claim) => [JSON.stringify(claim.argv), claim]));
257
+ if (distinct.size < 2) continue;
258
+ const listed = [...distinct.values()].map((claim) => `${claim.owner}: verification[${claim.position}] runs ${JSON.stringify(claim.argv)}`).join("; ");
259
+ warnings.push(`requirementId "${requirementId}" is proven by commands that no longer agree: ${listed}`);
260
+ }
261
+ return warnings;
262
+ }