faberun 0.23.1 → 0.24.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.23.1",
3
+ "version": "0.24.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": {
@@ -24,7 +24,7 @@
24
24
  "node": ">=22"
25
25
  },
26
26
  "scripts": {
27
- "check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
27
+ "check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')&&!p.includes('.runs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
28
28
  "typecheck": "tsc",
29
29
  "check:campaign-brief-render": "node src/report/campaign-brief-html.mjs",
30
30
  "test": "node --test --import ./test/scoped-home.mjs --import ./test/git-env.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
@@ -51,6 +51,7 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
51
51
  /** @typedef {"down"|"up"|"informative"} Direction */
52
52
  /** @typedef {{value: number|null, direction: Direction, count: number, numerator?: number, denominator?: number, excludedRunIds?: string[], missingSources?: string[]}} Indicator */
53
53
  /** @typedef {Indicator & {unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}} CostIndicator */
54
+ /** @typedef {Indicator & {unknownCount: number}} JudgeCostShareIndicator */
54
55
  /** @typedef {{value: Record<string, number>|null, direction: Direction, count: number, numerator?: Record<string, number>, denominator?: Record<string, number>, excludedRunIds?: string[], missingSources?: string[]}} GroupedIndicator */
55
56
  /** @typedef {{atMs: number, index: number, event: JsonObject}} RunEvent */
56
57
  /** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null}} RunNode */
@@ -79,6 +80,8 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
79
80
  * usageTokensByKind: GroupedIndicator,
80
81
  * usageTokensByKindByRuntime: GroupedIndicator,
81
82
  * usageCostUsd: CostIndicator,
83
+ * judgeFindingRate: Indicator,
84
+ * judgeCostShare: JudgeCostShareIndicator,
82
85
  * blockingJudgeFirstPassRate: GroupedIndicator,
83
86
  * notifyReceiptRate: Indicator,
84
87
  * silentStallRate: Indicator,
@@ -101,6 +104,10 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
101
104
  const excluded = excludedRunIdsFor(campaign, excludedRunIds);
102
105
  const eligibleNodes = nodes.filter((node) => !excluded.includes(node.runId));
103
106
  const eligibleEvents = eventList.filter((event) => typeof event.runId !== "string" || !excluded.includes(event.runId));
107
+ const eligibleUsageRecords = usageRecords.filter((record) => {
108
+ const object = jsonObjectOf(record);
109
+ return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
110
+ });
104
111
  const eligibleNotifications = notifications.filter((record) => {
105
112
  const object = jsonObjectOf(record);
106
113
  return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
@@ -110,6 +117,7 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
110
117
  const closedCheckpoints = closedCheckpointsOf(nodes);
111
118
  const span = eventSpanOf(eventList);
112
119
  const usage = usageTotalsOf(usageRecords);
120
+ const judgeYield = judgeYieldOf(eligibleEvents, eligibleUsageRecords);
113
121
  const gates = blockingJudgeFirstPassRateOf(eligibleNodes, eligibleEvents);
114
122
  const notify = notifyReceiptRateOf(eligibleNotifications);
115
123
  const stalls = silentStallRateOf(eligibleNodes, eligibleEvents);
@@ -129,6 +137,11 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
129
137
  unknownCountByReason: usage.unknownCountByReason,
130
138
  unknownFractionByReason: usage.unknownFractionByReason,
131
139
  },
140
+ judgeFindingRate: rate("up", judgeYield.findings, judgeYield.judged, excluded),
141
+ judgeCostShare: {
142
+ ...measured("down", usage.costCount, usage.costUsd === null ? null : usage.judgeCostUsd / usage.costUsd),
143
+ unknownCount: usage.unknownJudgeCount,
144
+ },
132
145
  blockingJudgeFirstPassRate: groupedRate("up", gates, excluded),
133
146
  notifyReceiptRate: rate("up", notify.satisfied, notify.count, excluded),
134
147
  silentStallRate: rate("down", stalls.stalled, stalls.activeHours, excluded, stalls.activeIntervals),
@@ -162,6 +175,8 @@ function applyMissingSources(metrics, missingSources) {
162
175
  usageTokensByKind: withMissing(metrics.usageTokensByKind, ["usage.jsonl"]),
163
176
  usageTokensByKindByRuntime: withMissing(metrics.usageTokensByKindByRuntime, ["usage.jsonl"]),
164
177
  usageCostUsd: withMissing(metrics.usageCostUsd, ["usage.jsonl"]),
178
+ judgeFindingRate: withMissing(metrics.judgeFindingRate, ["events.jsonl", "usage.jsonl"]),
179
+ judgeCostShare: withMissing(metrics.judgeCostShare, ["usage.jsonl"]),
165
180
  blockingJudgeFirstPassRate: withMissing(metrics.blockingJudgeFirstPassRate, ["nodes.json", "events.jsonl"]),
166
181
  notifyReceiptRate: withMissing(metrics.notifyReceiptRate, ["notify.jsonl"]),
167
182
  silentStallRate: withMissing(metrics.silentStallRate, ["nodes.json", "events.jsonl"]),
@@ -397,7 +412,7 @@ function eventSpanOf(events) {
397
412
  * invocation is counted separately rather than folded into a measured zero.
398
413
  *
399
414
  * @param {unknown[]} usageRecords
400
- * @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, costCount: number, unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}}
415
+ * @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, judgeCostUsd: number, costCount: number, unknownCount: number, unknownJudgeCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}}
401
416
  */
402
417
  function usageTotalsOf(usageRecords) {
403
418
  const tokensByKind = { inputTokens: 0, cacheReadInputTokens: 0, outputTokens: 0 };
@@ -405,8 +420,10 @@ function usageTotalsOf(usageRecords) {
405
420
  const tokensByKindByRuntime = {};
406
421
  let tokenCount = 0;
407
422
  let costUsd = 0;
423
+ let judgeCostUsd = 0;
408
424
  let costCount = 0;
409
425
  let unknownCount = 0;
426
+ let unknownJudgeCount = 0;
410
427
  /** @type {Record<string, number>} */
411
428
  const unknownCountByReason = {};
412
429
  const kinds = /** @type {("inputTokens"|"cacheReadInputTokens"|"outputTokens")[]} */ (["inputTokens", "cacheReadInputTokens", "outputTokens"]);
@@ -425,9 +442,11 @@ function usageTotalsOf(usageRecords) {
425
442
  if (measuredAny) tokenCount += 1;
426
443
  if (typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costProvenance !== UNKNOWN_COST_PROVENANCE) {
427
444
  costUsd += record.costUsd;
445
+ if (record.role === "judge") judgeCostUsd += record.costUsd;
428
446
  costCount += 1;
429
447
  } else {
430
448
  unknownCount += 1;
449
+ if (record.role === "judge") unknownJudgeCount += 1;
431
450
  const reason = typeof record.unknownReason === "string" && UNKNOWN_COST_REASONS.includes(record.unknownReason)
432
451
  ? record.unknownReason
433
452
  : "legacy";
@@ -443,13 +462,51 @@ function usageTotalsOf(usageRecords) {
443
462
  tokensByKindByRuntime,
444
463
  tokenCount,
445
464
  costUsd: costCount === 0 ? null : costUsd,
465
+ judgeCostUsd,
446
466
  costCount,
447
467
  unknownCount,
468
+ unknownJudgeCount,
448
469
  unknownCountByReason,
449
470
  unknownFractionByReason,
450
471
  };
451
472
  }
452
473
 
474
+ /**
475
+ * Count logical nodes that reached a judge and those whose judge recorded at
476
+ * least one finding. A failed judge verdict is the durable ledger projection
477
+ * of a non-empty findings array; a complete-phase fail is included for
478
+ * advisory reviews that settle the node after reporting their finding.
479
+ *
480
+ * @param {JsonObject[]} events
481
+ * @param {unknown[]} usageRecords
482
+ * @returns {{judged: number, findings: number}}
483
+ */
484
+ function judgeYieldOf(events, usageRecords) {
485
+ const judged = new Set();
486
+ const findings = new Set();
487
+ for (const raw of usageRecords) {
488
+ const record = jsonObjectOf(raw);
489
+ if (record === null || record.role !== "judge" || typeof record.nodeId !== "string") continue;
490
+ judged.add(logicalNodeKey(record.runId, record.nodeId));
491
+ }
492
+ for (const event of events) {
493
+ if (typeof event.node !== "string") continue;
494
+ const key = logicalNodeKey(event.runId, event.node);
495
+ if (event.phase === "judge") judged.add(key);
496
+ const hasFindingArray = Array.isArray(event.findings) && event.findings.length > 0;
497
+ if ((event.phase === "judge" || event.phase === "complete") && (event.verdict === "fail" || hasFindingArray)) {
498
+ judged.add(key);
499
+ findings.add(key);
500
+ }
501
+ }
502
+ return { judged: judged.size, findings: findings.size };
503
+ }
504
+
505
+ /** @param {unknown} runId @param {string} nodeId @returns {string} */
506
+ function logicalNodeKey(runId, nodeId) {
507
+ return `${typeof runId === "string" ? runId : ""}:${nodeId}`;
508
+ }
509
+
453
510
  /**
454
511
  * Fraction of blocking-gated checkpoints whose first recorded verdict passed,
455
512
  * per lane (the judge runtime that produced it). A node under `advisory` or
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { dirname, join, resolve } from "node:path";
9
+ import { parseArgs as parseFlags } from "node:util";
9
10
  import { loadPersistedContract, validateContract } from "../contract/index.mjs";
10
11
 
11
12
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
@@ -13,7 +14,7 @@ import { loadPersistedContract, validateContract } from "../contract/index.mjs";
13
14
  /** Flags are scoped to the operation that declares them; all others are rejected. */
14
15
  /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
15
16
  const OPERATION_OPTIONS = {
16
- validate: {},
17
+ validate: { "strict-traceability": { type: "boolean" } },
17
18
  };
18
19
 
19
20
  /**
@@ -23,9 +24,17 @@ const OPERATION_OPTIONS = {
23
24
  export function contractCli(args) {
24
25
  const operation = args[0];
25
26
  if (!operation || !Object.hasOwn(OPERATION_OPTIONS, operation)) return usage();
26
- const target = args[1];
27
- if (!target || args.length > 2) return usage();
28
- if (operation === "validate") validateContractFile(resolve(target));
27
+ let parsed;
28
+ try {
29
+ parsed = parseFlags({ args: args.slice(1), options: OPERATION_OPTIONS[operation], allowPositionals: true, strict: true });
30
+ } catch (error) {
31
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
32
+ return usage();
33
+ }
34
+ const target = parsed.positionals[0];
35
+ if (!target || parsed.positionals.length > 1) return usage();
36
+ const values = /** @type {{"strict-traceability"?: boolean}} */ (parsed.values);
37
+ if (operation === "validate") validateContractFile(resolve(target), { strictTraceability: values["strict-traceability"] === true });
29
38
  }
30
39
 
31
40
  /**
@@ -38,19 +47,29 @@ export function contractCli(args) {
38
47
  * authoring validation, tree reads included.
39
48
  *
40
49
  * @param {string} path
50
+ * @param {{strictTraceability?: boolean}} [options]
41
51
  * @returns {ValidatedContract}
42
52
  */
43
- export function validateContractFile(path) {
53
+ export function validateContractFile(path, { strictTraceability = false } = {}) {
44
54
  const runJsonPath = join(dirname(path), "run.json");
45
55
  const contract = existsSync(runJsonPath)
46
56
  ? loadPersistedContract(path, readRunDigest(runJsonPath))
47
57
  : validateContract(JSON.parse(readFileSync(path, "utf8")), path);
48
58
  const count = contract.warnings.length;
49
- process.stdout.write(`valid${count ? ` (${count} warning${count === 1 ? "" : "s"})` : ""}\n`);
59
+ const blockingTraceability = strictTraceability
60
+ ? contract.warnings.filter((warning) => isTraceabilityFinding(warning))
61
+ : [];
62
+ process.stdout.write(`${blockingTraceability.length > 0 ? "invalid" : "valid"}${count ? ` (${count} warning${count === 1 ? "" : "s"})` : ""}\n`);
50
63
  for (const warning of contract.warnings) process.stdout.write(`[warn] ${warning}\n`);
64
+ if (blockingTraceability.length > 0) process.exitCode = 1;
51
65
  return contract;
52
66
  }
53
67
 
68
+ /** @param {string} warning @returns {boolean} */
69
+ function isTraceabilityFinding(warning) {
70
+ return warning.includes("judgment_without_reason") || warning.includes("judgment_beside_mechanical_proof");
71
+ }
72
+
54
73
  /**
55
74
  * @param {string} runJsonPath
56
75
  * @returns {string|undefined}
@@ -62,7 +81,7 @@ function readRunDigest(runJsonPath) {
62
81
 
63
82
  /** @returns {void} */
64
83
  function usage() {
65
- process.stderr.write("usage: faberun contract validate <contract.json>\n");
84
+ process.stderr.write("usage: faberun contract validate <contract.json> [--strict-traceability]\n");
66
85
  process.exitCode = 2;
67
86
  }
68
87
 
@@ -9,7 +9,7 @@ import { rejectUnknown, requireId, requireString } from "./assert.mjs";
9
9
 
10
10
  /** @typedef {{kind: "command"|"path"|"verification", ref: string}} DefinitionOfDoneProof */
11
11
 
12
- /** @typedef {{id: string, text: string, proof?: DefinitionOfDoneProof, judgment?: true}} DefinitionOfDoneItem */
12
+ /** @typedef {{id: string, text: string, proof?: DefinitionOfDoneProof, judgment?: true, reason?: string}} DefinitionOfDoneItem */
13
13
 
14
14
  /**
15
15
  * @param {unknown} value
@@ -28,12 +28,16 @@ export function validateDefinitionOfDone(value, label, options = {}) {
28
28
  throw new TypeError(`${itemLabel} must be an object with id, text, and proof or judgment: true`);
29
29
  }
30
30
  const record = /** @type {Record<string, unknown>} */ (item);
31
- rejectUnknown(record, new Set(["id", "text", "proof", "judgment"]), itemLabel);
31
+ rejectUnknown(record, new Set(["id", "text", "proof", "judgment", "reason"]), itemLabel);
32
32
  requireId(record.id, `${itemLabel}.id`);
33
33
  requireString(record.text, `${itemLabel}.text`);
34
34
  if (record.judgment !== undefined && record.judgment !== true) {
35
35
  throw new TypeError(`${itemLabel}.judgment must be true when present`);
36
36
  }
37
+ if (record.reason !== undefined) {
38
+ if (record.judgment !== true) throw new TypeError(`${itemLabel}.reason requires judgment: true`);
39
+ requireString(record.reason, `${itemLabel}.reason`);
40
+ }
37
41
  const proof = record.proof === undefined
38
42
  ? undefined
39
43
  : validateProof(record.proof, `${itemLabel}.proof`, options);
@@ -45,10 +49,34 @@ export function validateDefinitionOfDone(value, label, options = {}) {
45
49
  text: /** @type {string} */ (record.text),
46
50
  ...(proof === undefined ? {} : { proof }),
47
51
  ...(record.judgment === true ? { judgment: true } : {}),
52
+ ...(record.reason === undefined ? {} : { reason: /** @type {string} */ (record.reason) }),
48
53
  };
49
54
  });
50
55
  }
51
56
 
57
+ /**
58
+ * Report judgment items that do not explain what mechanical verification cannot
59
+ * observe. These remain advisory because `contract validate` has no strict
60
+ * traceability flag; a later strict contract command can promote these codes.
61
+ *
62
+ * @param {DefinitionOfDoneItem[]} items
63
+ * @param {number} index
64
+ * @returns {string[]}
65
+ */
66
+ export function judgmentReasonWarnings(items, index) {
67
+ const judgmentItems = items.filter((item) => item.judgment === true);
68
+ const warnings = items.flatMap((item, itemIndex) => item.judgment === true && item.reason === undefined
69
+ ? [`nodes[${index}] (definitionOfDone[${itemIndex}]): judgment_without_reason: judgment item must say what no command verifies`]
70
+ : []);
71
+ const hasMechanicalProof = items.some((item) => item.proof !== undefined);
72
+ if (judgmentItems.length > 0 && hasMechanicalProof && judgmentItems.every((item) => item.reason === undefined)) {
73
+ warnings.push(
74
+ `nodes[${index}]: judgment_beside_mechanical_proof: every judgment item shares this node with mechanical-proof items but declares no reason`,
75
+ );
76
+ }
77
+ return warnings;
78
+ }
79
+
52
80
  /**
53
81
  * @param {unknown} value
54
82
  * @param {string} label
@@ -144,4 +172,3 @@ export function unquotedFilterValueWarnings(items, index) {
144
172
 
145
173
  /** The node:test filter flags a `kind: "command"` proof's shell can split on an unquoted value. */
146
174
  const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
147
-
@@ -3,7 +3,7 @@ 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 { unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
6
+ import { judgmentReasonWarnings, unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
7
7
  import { validateFinalVerification, validateSharedVerification } from "./final-verification.mjs";
8
8
  import { VERIFICATION_LIMITS, requirementProofWarnings } from "./verification.mjs";
9
9
  import {
@@ -378,6 +378,7 @@ export function validateContract(raw, contractPath, options = {}) {
378
378
  ...nodes.flatMap((node, index) => [
379
379
  ...commandCoverageWarnings(node, index),
380
380
  ...unquotedFilterValueWarnings(node.definitionOfDone ?? [], index),
381
+ ...judgmentReasonWarnings(node.definitionOfDone ?? [], index),
381
382
  ...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
382
383
  ...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
383
384
  ...(persisted ? [] : ignoreSourceWriteWarnings(node, index)),
@@ -25,6 +25,13 @@ import { NOTIFY_SESSION_ENV } from "../notify/session.mjs";
25
25
  /** @typedef {{requirementId: string|null, kind: "command"|"path", ref: string, pass: boolean, detail: string}} RequirementProofResult */
26
26
 
27
27
  export const MEASURE_TIMEOUT_MS = 30_000;
28
+ /**
29
+ * A proof is a test run, not a fact probe. Measured 2026-09-24: R6 of
30
+ * `evals-with-a-budget` takes 98 s pinned to its file, and under the 30 s fact
31
+ * bound the runner died on SIGTERM with exit 1, read as a failing proof. Ten
32
+ * minutes has no measurement behind it beyond covering the slowest proof seen.
33
+ */
34
+ const PROOF_TIMEOUT_MS = 600_000;
28
35
  export const MEASURE_OUTPUT_CAP_BYTES = 4096;
29
36
 
30
37
  /** How much of a failing proof's own output a finding carries: enough to name the failure, never the whole log. */
@@ -82,9 +89,10 @@ export function runShellCapture(cwd, command, probes = {}) {
82
89
  * @param {string} cwd
83
90
  * @param {string} command
84
91
  * @param {MeasureProbes} [probes]
85
- * @returns {{output: string, exitCode: number|null, truncated: boolean}}
92
+ * @param {number} [timeoutMs]
93
+ * @returns {{output: string, exitCode: number|null, truncated: boolean, timedOut: boolean}}
86
94
  */
87
- function runShellFull(cwd, command, probes = {}) {
95
+ function runShellFull(cwd, command, probes = {}, timeoutMs = MEASURE_TIMEOUT_MS) {
88
96
  const run = probes.run ?? spawnSync;
89
97
  const env = { ...process.env };
90
98
  for (const key of MEASURE_SIDE_EFFECT_ENV_KEYS) delete env[key];
@@ -92,9 +100,10 @@ function runShellFull(cwd, command, probes = {}) {
92
100
  // child and prints no result at all (measured 2026-09-21, AGENTS.md), so a
93
101
  // proof run from inside a test runner would have nothing to show.
94
102
  delete env.NODE_TEST_CONTEXT;
95
- const result = run(command, { shell: true, cwd, timeout: MEASURE_TIMEOUT_MS, encoding: "utf8", env });
103
+ const result = run(command, { shell: true, cwd, timeout: timeoutMs, encoding: "utf8", env });
96
104
  const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`;
97
- return { output: combined, exitCode: result.status, truncated: Buffer.byteLength(combined, "utf8") > MEASURE_OUTPUT_CAP_BYTES };
105
+ const timedOut = /** @type {NodeJS.ErrnoException|undefined} */ (result.error)?.code === "ETIMEDOUT";
106
+ return { output: combined, exitCode: result.status, truncated: Buffer.byteLength(combined, "utf8") > MEASURE_OUTPUT_CAP_BYTES, timedOut };
98
107
  }
99
108
 
100
109
  /** `--test-name-pattern` in a shell command, quoted or bare, `=` or space. */
@@ -146,9 +155,11 @@ export function proveRequirements(cwd, requirements, probes = {}) {
146
155
  const proof = requirement.proof;
147
156
  if (!proof?.ref) continue;
148
157
  if (proof.kind === "command") {
149
- const { output, exitCode } = runShellFull(cwd, proof.ref, probes);
158
+ const { output, exitCode, timedOut } = runShellFull(cwd, proof.ref, probes, PROOF_TIMEOUT_MS);
150
159
  const matched = exitCode === 0 ? patternMatchedATest(proof.ref, output) : null;
151
- const detail = exitCode !== 0
160
+ const detail = timedOut
161
+ ? `timed out after ${PROOF_TIMEOUT_MS / 1000} s`
162
+ : exitCode !== 0
152
163
  ? `exit ${exitCode ?? "no exit code (killed or never started)"}${output.trim() ? `: ${output.trim().slice(0, PROOF_DETAIL_CAP)}` : ""}`
153
164
  : matched === false ? "exit 0, but its --test-name-pattern matched no test: the proof ran nothing" : "exit 0";
154
165
  results.push({ requirementId: requirement.id, kind: "command", ref: proof.ref, pass: exitCode === 0 && matched !== false, detail });
@@ -111,7 +111,7 @@ const REQUIRED_INPUTS = Object.freeze({
111
111
  // run had already succeeded. The id charset is requireId's
112
112
  // (contract/assert.mjs) verbatim, because an id that is present but invalid
113
113
  // fails that same validator just as late.
114
- const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
114
+ const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true, reason?: string}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
115
115
  /**
116
116
  * The size guidance every draft and revise carries. measured 2026-09-20 over
117
117
  * stored runs: median 49 provider requests per worker turn; cost per turn