faberun 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Conditional judge gate for schema-2 Definition of Done items.
3
+ *
4
+ * Deterministic items carry a mechanical `proof` (a verification command, a
5
+ * workspace path, or a `verification` entry reused by reference) and gate
6
+ * first: the controller settles them and no judge invocation is spent until
7
+ * they pass. The judge arbitrates only `judgment` items, and a gate-failing
8
+ * rejection whose findings cite no judgment item id is a judge protocol
9
+ * failure — one bounded re-ask, then blocked attention — that never consumes a
10
+ * worker revision.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { stat } from "node:fs/promises";
14
+ import { isAbsolute, resolve } from "node:path";
15
+ import { reviewMode, UNCITED_REJECTION_REASON } from "../contract/review-modes.mjs";
16
+ import { JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
17
+
18
+ /** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
19
+ /** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneProof} DefinitionOfDoneProof */
20
+ /** @typedef {import("../contract/index.mjs").ExecutionOverride} ExecutionOverride */
21
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
22
+ /** @typedef {import("../contract/index.mjs").VerificationState} VerificationState */
23
+
24
+ const MAX_PROOF_OUTPUT_BYTES = 4 * 1024;
25
+
26
+ /**
27
+ * @param {unknown} value
28
+ * @param {number} maxBytes
29
+ * @returns {string}
30
+ */
31
+ function boundedText(value, maxBytes = MAX_PROOF_OUTPUT_BYTES) {
32
+ const text = String(value ?? "");
33
+ const bytes = Buffer.from(text, "utf8");
34
+ if (bytes.length <= maxBytes) return text;
35
+ // The marker costs 3 bytes in UTF-8 and a byte-aligned cut can land inside a
36
+ // multibyte character, whose replacement costs 3 more. Reserve the marker and
37
+ // then shrink until the encoded result actually fits: a finding that exceeds
38
+ // the validator's evidence ceiling is not truncated downstream, it throws, and
39
+ // the throw kills the controller mid-gate.
40
+ const marker = "…";
41
+ const markerBytes = Buffer.byteLength(marker, "utf8");
42
+ if (maxBytes <= markerBytes) return "";
43
+ let room = maxBytes - markerBytes;
44
+ let out = `${bytes.subarray(0, room).toString("utf8")}${marker}`;
45
+ while (room > 0 && Buffer.byteLength(out, "utf8") > maxBytes) {
46
+ room -= 1;
47
+ out = `${bytes.subarray(0, room).toString("utf8")}${marker}`;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node @returns {DefinitionOfDoneItem[]} */
53
+ function mechanicalItems(node) {
54
+ return (node.definitionOfDone ?? []).filter((item) => item.proof !== undefined);
55
+ }
56
+
57
+ /** @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node @returns {DefinitionOfDoneItem[]} */
58
+ export function judgmentItems(node) {
59
+ return (node.definitionOfDone ?? []).filter((item) => item.judgment === true);
60
+ }
61
+
62
+ /** A gated node runs the judge only when its review mode is not `none` and a Definition of Done item carries judgment:true; an empty, purely deterministic, or review-free checklist settles mechanically without spending a judge invocation. @param {{definitionOfDone?: DefinitionOfDoneItem[], gate?: {enabled?: boolean, review?: unknown}}} node @returns {boolean} */
63
+ export function judgeRequired(node) {
64
+ return reviewMode(node.gate) !== "none"
65
+ && (node.definitionOfDone ?? []).some((item) => item.judgment === true);
66
+ }
67
+
68
+ /**
69
+ * The green-and-small escape hatch: the contract declared `gate.skipWhen`, the
70
+ * controller verification passed, and the persisted workspace scope recorded no
71
+ * more changed paths than the declared ceiling. Both conditions must hold, and
72
+ * this predicate overrides `judgment: true` — that is its whole purpose. It
73
+ * composes with `reviewMode` rather than replacing it: `judgeRequired` still
74
+ * owns the `none` case, and a gate with no `skipWhen` behaves exactly as before.
75
+ *
76
+ * @param {{gate?: {skipWhen?: {verificationGreen: true, maxChangedPaths: number}}}} node
77
+ * @param {{verification?: {passed?: boolean}|null, scope?: {changedPathCount?: number, changedPaths?: string[]}|null}} state
78
+ * @returns {boolean}
79
+ */
80
+ export function judgeSkippedByScope(node, state) {
81
+ const skipWhen = node.gate?.skipWhen;
82
+ if (!skipWhen) return false;
83
+ if (state.verification?.passed !== true) return false;
84
+ const changed = typeof state.scope?.changedPathCount === "number"
85
+ ? state.scope.changedPathCount
86
+ : Array.isArray(state.scope?.changedPaths) ? state.scope.changedPaths.length : null;
87
+ return typeof changed === "number" && changed <= skipWhen.maxChangedPaths;
88
+ }
89
+
90
+ /**
91
+ * @param {DefinitionOfDoneItem[]} items
92
+ * @param {string} cwd
93
+ * @param {{timeoutMs?: number, verification?: VerificationState|null}} [options]
94
+ * @returns {Promise<Array<{id: string, kind: "command"|"path"|"verification", ref: string, pass: boolean, detail: string}>>}
95
+ */
96
+ async function runMechanicalProofs(items, cwd, options = {}) {
97
+ const timeoutMs = options.timeoutMs ?? 60_000;
98
+ const recorded = options.verification?.commands ?? [];
99
+ const results = [];
100
+ for (const item of items) {
101
+ const proof = item.proof;
102
+ if (proof === undefined) continue;
103
+ results.push(proof.kind === "path"
104
+ ? await provePath(item.id, proof, cwd)
105
+ : proof.kind === "verification"
106
+ ? proveVerification(item.id, proof, recorded)
107
+ : await proveCommand(item.id, proof, cwd, timeoutMs));
108
+ }
109
+ return results;
110
+ }
111
+
112
+ /**
113
+ * Reuse the recorded result of one controller verification entry: pass or fail
114
+ * and its bounded output, with nothing executed. The reference is positional,
115
+ * so the entry this attempt recorded is exactly the entry the packet named.
116
+ *
117
+ * @param {string} id
118
+ * @param {DefinitionOfDoneProof} proof
119
+ * @param {Array<{argv: string[], passed: boolean, attempts?: Array<{exitCode?: number|null, stdout?: string, stderr?: string}>}>} recorded
120
+ * @returns {{id: string, kind: "verification", ref: string, pass: boolean, detail: string}}
121
+ */
122
+ function proveVerification(id, proof, recorded) {
123
+ const index = Number.parseInt(proof.ref, 10);
124
+ const entry = Number.isInteger(index) ? recorded[index] : undefined;
125
+ if (!entry) {
126
+ return { id, kind: "verification", ref: proof.ref, pass: false, detail: `verification command ${proof.ref} has no recorded result for this attempt` };
127
+ }
128
+ const attempt = entry.attempts?.at(-1);
129
+ const output = [attempt?.stderr, attempt?.stdout].find((text) => typeof text === "string" && text.trim()) ?? "";
130
+ const detail = entry.passed
131
+ ? `reused recorded verification result: ${entry.argv.join(" ")} passed`
132
+ : boundedText(`reused recorded verification result: ${entry.argv.join(" ")} failed: ${output.trim()}`);
133
+ return { id, kind: "verification", ref: proof.ref, pass: entry.passed === true, detail };
134
+ }
135
+
136
+ /**
137
+ * @param {string} id
138
+ * @param {DefinitionOfDoneProof} proof
139
+ * @param {string} cwd
140
+ * @param {number} timeoutMs
141
+ * @returns {Promise<{id: string, kind: "command"|"path", ref: string, pass: boolean, detail: string}>}
142
+ */
143
+ async function proveCommand(id, proof, cwd, timeoutMs) {
144
+ const ref = proof.ref;
145
+ return new Promise((settle) => {
146
+ // Detached on POSIX so the shell leads its own process group: `shell: true`
147
+ // means the timeout must kill the group, not the shell, or the command the
148
+ // shell started keeps running and keeps the result pending forever.
149
+ const child = spawn(ref, { cwd, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
150
+ let stdout = "";
151
+ let stderr = "";
152
+ let settled = false;
153
+ const finish = (/** @type {{id: string, kind: "command", ref: string, pass: boolean, detail: string}} */ result) => {
154
+ if (settled) return;
155
+ settled = true;
156
+ clearTimeout(timer);
157
+ try { child.stdout?.destroy(); } catch {
158
+ // The stream already closed; destroying it again is a no-op.
159
+ }
160
+ try { child.stderr?.destroy(); } catch {
161
+ // The stream already closed; destroying it again is a no-op.
162
+ }
163
+ settle(result);
164
+ };
165
+ const timer = setTimeout(() => {
166
+ terminateProofGroup(child);
167
+ finish({ id, kind: "command", ref, pass: false, detail: boundedText(`timed out after ${timeoutMs}ms`) });
168
+ }, timeoutMs);
169
+ child.stdout.on("data", (chunk) => {
170
+ if (stdout.length < MAX_PROOF_OUTPUT_BYTES) stdout += chunk;
171
+ });
172
+ child.stderr.on("data", (chunk) => {
173
+ if (stderr.length < MAX_PROOF_OUTPUT_BYTES) stderr += chunk;
174
+ });
175
+ child.on("error", (error) => {
176
+ finish({ id, kind: "command", ref, pass: false, detail: boundedText(error.message) });
177
+ });
178
+ child.on("close", (code, signal) => {
179
+ const detail = signal !== null ? `killed by ${signal}` : `exit ${code ?? "?"}`;
180
+ const pass = code === 0 && signal === null;
181
+ finish({ id, kind: "command", ref, pass, detail: pass ? detail : boundedText(`${detail}: ${(stderr || stdout).trim()}`) });
182
+ });
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Kill the proof command's process group, then make sure it is gone. The group
188
+ * is the shell and everything it started; killing only the shell was the bug
189
+ * that let a timed-out proof hold the pipe and never settle.
190
+ *
191
+ * @param {import("node:child_process").ChildProcess} child
192
+ */
193
+ function terminateProofGroup(child) {
194
+ const pid = child.pid;
195
+ if (!pid) return;
196
+ const signal = (/** @type {NodeJS.Signals} */ name) => {
197
+ try {
198
+ if (process.platform !== "win32") process.kill(-pid, name);
199
+ else child.kill(name);
200
+ } catch {
201
+ try { child.kill(name); } catch {
202
+ // ESRCH: the group and the leader are already gone.
203
+ }
204
+ }
205
+ };
206
+ signal("SIGTERM");
207
+ setTimeout(() => signal("SIGKILL"), 100).unref();
208
+ }
209
+
210
+ /**
211
+ * @param {string} id
212
+ * @param {DefinitionOfDoneProof} proof
213
+ * @param {string} cwd
214
+ * @returns {Promise<{id: string, kind: "command"|"path", ref: string, pass: boolean, detail: string}>}
215
+ */
216
+ async function provePath(id, proof, cwd) {
217
+ const ref = proof.ref;
218
+ try {
219
+ const target = isAbsolute(ref) ? ref : resolve(cwd, ref);
220
+ const info = await stat(target);
221
+ return { id, kind: "path", ref, pass: true, detail: `${info.isDirectory() ? "directory" : "file"} exists` };
222
+ } catch (error) {
223
+ const message = error instanceof Error ? error.message : String(error);
224
+ return { id, kind: "path", ref, pass: false, detail: boundedText(message) };
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Verdict a deterministic gate from its per-item mechanical results: pass only
230
+ * when every proof passed.
231
+ *
232
+ * @param {Array<{id: string, kind: "command"|"path"|"verification", ref: string, pass: boolean, detail: string}>} results
233
+ * @returns {import("./prompts.mjs").JudgeVerdict}
234
+ */
235
+ export function mechanicalVerdict(results) {
236
+ const failed = results.filter((result) => !result.pass);
237
+ if (!failed.length) {
238
+ return {
239
+ verdict: "pass",
240
+ maxSeverity: "none",
241
+ summary: "every deterministic Definition of Done item passed",
242
+ findings: [],
243
+ };
244
+ }
245
+ return {
246
+ verdict: "fail",
247
+ maxSeverity: "critical",
248
+ summary: "deterministic Definition of Done item failed",
249
+ findings: failed.map((result) => ({
250
+ severity: "critical",
251
+ description: `Definition of Done item [${result.id}] failed its ${result.kind} proof`,
252
+ evidence: boundedText(`${result.ref}: ${result.detail}`),
253
+ })),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The deterministic evidence a judge protocol re-ask must reuse: the round's
259
+ * mechanical proofs already passed before the first ask, so the re-ask prompt
260
+ * reports every deterministic item as proven without re-running any proof.
261
+ *
262
+ * @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node
263
+ * @returns {Array<{id: string, kind: "command"|"path"|"verification", ref: string, pass: boolean, detail: string}>}
264
+ */
265
+ function provenDeterministicResults(node) {
266
+ return mechanicalItems(node).map((item) => {
267
+ const proof = /** @type {DefinitionOfDoneProof} */ (item.proof);
268
+ return { id: item.id, kind: proof.kind, ref: proof.ref, pass: true, detail: "" };
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Deterministic evidence of one gate round plus its mechanical verdict. The
274
+ * first ask settles every mechanical proof (a command proof executes; a
275
+ * verification proof reuses its recorded result); a judge protocol re-ask
276
+ * reuses the round's proven items and never re-runs a proof, so a flaky second
277
+ * execution cannot consume a worker revision on the protocol failure path.
278
+ *
279
+ * @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node
280
+ * @param {string} cwd
281
+ * @param {boolean} reask
282
+ * @param {number} timeoutMs
283
+ * @param {VerificationState|null} [verification] this attempt's recorded verification results
284
+ * @returns {Promise<{verdict: import("./prompts.mjs").JudgeVerdict, results: Array<{id: string, pass: boolean, detail: string}>}>}
285
+ */
286
+ export async function deterministicGate(node, cwd, reask, timeoutMs, verification = null) {
287
+ const results = reask
288
+ ? provenDeterministicResults(node)
289
+ : await runMechanicalProofs(mechanicalItems(node), cwd, { timeoutMs, verification });
290
+ return { verdict: mechanicalVerdict(results), results };
291
+ }
292
+
293
+ /**
294
+ * Node's test runner prints `test at <path>:<line>:<column>` immediately before
295
+ * each failing test. That is the only place the file a suite accused is written
296
+ * down, so it is read exactly as the runner wrote it -- inventing a format of
297
+ * our own would lie the first time the runner changed.
298
+ */
299
+ const NODE_TEST_LOCATION = /^test at (.+?):\d+(?::\d+)?\s*$/gmu;
300
+
301
+ /** @param {unknown} value @returns {string} */
302
+ function stripAnsi(value) {
303
+ return String(value ?? "").replace(/\u001b\[[0-9;]*m/gu, "");
304
+ }
305
+
306
+ /**
307
+ * Test files named by the captured output of failing verification commands.
308
+ *
309
+ * @param {Array<{attempts?: Array<{stdout?: string, stderr?: string}>}>} commands
310
+ * @returns {string[]}
311
+ */
312
+ function namedTestFiles(commands) {
313
+ /** @type {string[]} */
314
+ const paths = [];
315
+ const seen = new Set();
316
+ for (const command of commands) {
317
+ for (const attempt of command.attempts ?? []) {
318
+ for (const stream of [attempt.stdout, attempt.stderr]) {
319
+ for (const match of stripAnsi(stream).matchAll(NODE_TEST_LOCATION)) {
320
+ const path = match[1].trim();
321
+ if (!path || seen.has(path)) continue;
322
+ seen.add(path);
323
+ paths.push(path);
324
+ }
325
+ }
326
+ }
327
+ }
328
+ return paths;
329
+ }
330
+
331
+ /**
332
+ * Whether a path named by verification output sits inside the write scope the
333
+ * node persisted. `state.scope.boundary` is the captured form of the packet's
334
+ * `writeFiles` plus `writeRoots`, so it is the only copy of the declared scope
335
+ * available at settlement time; `fileRoots` are exact paths, the rest of
336
+ * `roots` cover their subtree.
337
+ *
338
+ * @param {{scope?: {boundary?: {files?: string[], roots?: string[], fileRoots?: string[]}}|null}|null|undefined} state
339
+ * @returns {(path: string) => boolean}
340
+ */
341
+ function declaredWriteCoverage(state) {
342
+ const boundary = state?.scope?.boundary;
343
+ const files = new Set(boundary?.files ?? []);
344
+ const fileRoots = new Set(boundary?.fileRoots ?? []);
345
+ const directoryRoots = (boundary?.roots ?? []).filter((root) => !fileRoots.has(root));
346
+ return (path) =>
347
+ files.has(path) ||
348
+ fileRoots.has(path) ||
349
+ directoryRoots.some((root) => path === root || path.startsWith(`${root}/`));
350
+ }
351
+
352
+ /**
353
+ * The operator-facing description for a failure that named a test outside the
354
+ * declared write scope. The defect is not in the worker's code: the worker is
355
+ * forbidden from touching the test, so the contract that withheld it is what
356
+ * has to change. The message names the file and says to fix the contract.
357
+ *
358
+ * @param {string[]} paths
359
+ * @returns {string}
360
+ */
361
+ function undeclaredTestDescription(paths) {
362
+ const files = paths.join(", ");
363
+ const noun = paths.length === 1 ? "file" : "files";
364
+ const pronoun = paths.length === 1 ? "it" : "them";
365
+ return boundedText(
366
+ `deterministic verification failed in undeclared test ${noun} ${files}; the node's writeFiles does not include ${pronoun}, so the worker cannot fix the failing test. This is a contract defect, not a worker defect: add ${files} to writeFiles or scopeAcknowledged and re-dispatch`,
367
+ JUDGE_LIMITS.descriptionBytes,
368
+ );
369
+ }
370
+
371
+ /**
372
+ * The deterministic controller-verification failure verdict, kept next to the
373
+ * Definition of Done gate so every deterministic failure settles identically.
374
+ *
375
+ * @param {{verification?: {commands?: Array<{argv: string[], passed?: boolean, attempts?: Array<{stdout?: string, stderr?: string, exitCode?: number|null, timedOut?: boolean}>}>, error?: unknown}|null, scope?: {boundary?: {files?: string[], roots?: string[], fileRoots?: string[]}}|null}} state
376
+ * @returns {import("./prompts.mjs").JudgeVerdict}
377
+ */
378
+ export function verificationFailureVerdict(state) {
379
+ const failedCommands = (state.verification?.commands ?? []).filter((command) => !command.passed);
380
+ const evidence = failedCommands.length
381
+ ? failedCommands.map((command) => `${command.argv.join(" ")}: ${(command.attempts ?? []).map((attempt) => `exit=${attempt.exitCode ?? "-"}${attempt.timedOut ? " timeout" : ""}`).join(", ")}`).join("; ")
382
+ : state.verification?.error ?? "verification controller failed to execute a command";
383
+ const undeclared = namedTestFiles(failedCommands).filter((path) => !declaredWriteCoverage(state)(path));
384
+ return {
385
+ verdict: "fail",
386
+ maxSeverity: "critical",
387
+ summary: "deterministic verification failed",
388
+ findings: [{
389
+ severity: "critical",
390
+ description: undeclared.length ? undeclaredTestDescription(undeclared) : "deterministic verification failed",
391
+ evidence: boundedText(evidence),
392
+ }],
393
+ };
394
+ }
395
+
396
+ /**
397
+ * The commands the integration candidate failed that the attempt had passed.
398
+ *
399
+ * A non-empty list means the two worktrees disagree about the environment
400
+ * rather than about the work: the same commit ran the same command twice with
401
+ * different outcomes. Without naming that, the failure reads as a defect in
402
+ * the node's own changes — which is how a missing `node_modules` link in the
403
+ * candidate once cost a campaign four attempts on already-correct work.
404
+ * Commands are matched by position, never by comparing joined argv, since a
405
+ * joined argv loses argument boundaries.
406
+ *
407
+ * @param {unknown} attempt the attempt's recorded verification
408
+ * @param {unknown} candidate the candidate's recorded verification
409
+ * @returns {string[]}
410
+ */
411
+ export function candidateOnlyFailures(attempt, candidate) {
412
+ const attemptCommands = verificationCommands(attempt);
413
+ return verificationCommands(candidate)
414
+ .map((command, index) => ({ command, counterpart: attemptCommands[index] }))
415
+ .filter((pair) => pair.command.passed === false && pair.counterpart?.passed === true)
416
+ .map((pair) => (pair.command.argv ?? []).join(" "));
417
+ }
418
+
419
+ /** @param {unknown} evidence @returns {Array<{argv?: string[], passed?: boolean}>} */
420
+ function verificationCommands(evidence) {
421
+ const commands = /** @type {{commands?: unknown}} */ (evidence ?? {}).commands;
422
+ return Array.isArray(commands) ? commands : [];
423
+ }
424
+
425
+ /**
426
+ * A gate-failing judge verdict on a node with judgment items is a protocol
427
+ * failure when none of its findings cites any judgment item id.
428
+ *
429
+ * @param {{verdict: string, findings: Array<{description: string, evidence: string}>}} verdict
430
+ * @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node
431
+ * @returns {boolean}
432
+ */
433
+ export function uncitedRejection(verdict, node) {
434
+ const items = judgmentItems(node);
435
+ if (verdict.verdict !== "fail" || items.length === 0) return false;
436
+ const ids = new Set(items.map((item) => item.id));
437
+ return !verdict.findings.some((finding) => citesItem(finding.description, ids) || citesItem(finding.evidence, ids));
438
+ }
439
+
440
+ /** @param {string} text @param {Set<string>} ids @returns {boolean} */
441
+ function citesItem(text, ids) {
442
+ return text.split(/[^A-Za-z0-9._-]+/u).some((token) => ids.has(token));
443
+ }
444
+
445
+ /**
446
+ * The execution-override kind that records a spent judge protocol re-ask. The
447
+ * snapshot validator takes any override kind; only the kinds the recovery path
448
+ * interprets are named in the contract's typedef, so this one is read and
449
+ * written through the same record cast the recovery scan uses.
450
+ */
451
+ const JUDGE_REASK_KIND = "judge-reask";
452
+
453
+ /** @param {ExecutionOverride} override @returns {boolean} */
454
+ function isJudgeReask(override) {
455
+ return /** @type {Record<string, unknown>} */ (override).kind === JUDGE_REASK_KIND;
456
+ }
457
+
458
+ /** @param {NodeSnapshot} state @returns {Record<string, unknown>|null} */
459
+ function judgeReaskRecord(state) {
460
+ const record = [...(state.executionOverrides ?? [])].reverse().find(isJudgeReask);
461
+ return record ? /** @type {Record<string, unknown>} */ (record) : null;
462
+ }
463
+
464
+ /**
465
+ * Spend the one bounded re-ask of the current judge round on the node state
466
+ * itself. The record is only mutated in memory: the caller's transition — the
467
+ * same atomic node write that persists the re-ask dispatch, the recovered
468
+ * pending judge, or the blocked attention — carries it to disk. Bound and node
469
+ * therefore move together, so no crash window can skip the permitted re-ask or
470
+ * grant a second one.
471
+ *
472
+ * @param {NodeSnapshot} state
473
+ * @param {string} [reason] what the re-ask answers, which selects its instruction
474
+ */
475
+ export function markJudgeReask(state, reason = UNCITED_REJECTION_REASON) {
476
+ if (judgeReaskOutstanding(state)) return;
477
+ const record = /** @type {ExecutionOverride} */ (/** @type {unknown} */ ({
478
+ kind: JUDGE_REASK_KIND,
479
+ at: new Date().toISOString(),
480
+ phase: "judge",
481
+ reason,
482
+ }));
483
+ state.executionOverrides = [...(state.executionOverrides ?? []), record];
484
+ }
485
+
486
+ /** Whether the current judge round already spent its one bounded re-ask. @param {NodeSnapshot} state @returns {boolean} */
487
+ export function judgeReaskOutstanding(state) {
488
+ return judgeReaskRecord(state) !== null;
489
+ }
490
+
491
+ /**
492
+ * What the outstanding re-ask answers, so the re-dispatched prompt carries the
493
+ * instruction the defect calls for.
494
+ *
495
+ * @param {NodeSnapshot} state
496
+ * @returns {string|undefined}
497
+ */
498
+ export function judgeReaskReason(state) {
499
+ const reason = judgeReaskRecord(state)?.reason;
500
+ return typeof reason === "string" ? reason : undefined;
501
+ }
502
+
503
+ /** Release the bound when a judge round settles on a verdict that is not a protocol failure, so the next round is asked afresh. @param {NodeSnapshot} state */
504
+ export function clearJudgeReask(state) {
505
+ if (!judgeReaskOutstanding(state)) return;
506
+ state.executionOverrides = (state.executionOverrides ?? []).filter((item) => !isJudgeReask(item));
507
+ }
508
+
509
+ /**
510
+ * Drop the routing override and progress snapshot so a fresh worker attempt
511
+ * routes and meters from scratch.
512
+ * @param {{routing?: {currentOverride?: unknown}|null, progress?: unknown}} state
513
+ */
514
+ export function resetPhaseRouting(state) {
515
+ if (state.routing) state.routing.currentOverride = null;
516
+ state.progress = null;
517
+ }