faberun 0.26.3 → 0.28.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 (88) hide show
  1. package/README.md +11 -4
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +6 -1
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +68 -8
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +6 -2
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +61 -13
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +54 -2
  45. package/src/harnesses/claude/index.mjs +177 -2
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +18 -116
  48. package/src/harnesses/dsh/runner.mjs +1 -17
  49. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  50. package/src/harnesses/fx/home.mjs +57 -0
  51. package/src/harnesses/fx/index.mjs +137 -0
  52. package/src/harnesses/fx/native/build.zig +20 -0
  53. package/src/harnesses/fx/native/build.zig.zon +7 -0
  54. package/src/harnesses/fx/native/home.zig +85 -0
  55. package/src/harnesses/fx/native/main.zig +503 -0
  56. package/src/harnesses/fx/native/permissions.zig +51 -0
  57. package/src/harnesses/fx/native/relay.zig +253 -0
  58. package/src/harnesses/fx/native/usage.zig +112 -0
  59. package/src/harnesses/fx/permissions.mjs +35 -0
  60. package/src/harnesses/fx/runner.mjs +350 -0
  61. package/src/harnesses/fx/usage-proxy.mjs +176 -0
  62. package/src/harnesses/index.mjs +29 -14
  63. package/src/harnesses/replay/index.mjs +12 -0
  64. package/src/harnesses/runner-io.mjs +25 -0
  65. package/src/harnesses/runner-transcript.mjs +127 -0
  66. package/src/harnesses/session-metrics.mjs +38 -13
  67. package/src/harnesses/zcode/index.mjs +19 -0
  68. package/src/host/config.mjs +8 -2
  69. package/src/host/environment-report.mjs +85 -0
  70. package/src/host/home.mjs +27 -0
  71. package/src/host/install-registry.mjs +511 -0
  72. package/src/host/preflight.mjs +34 -10
  73. package/src/host/uninstall-roots.mjs +196 -0
  74. package/src/plan/proof-check.mjs +15 -0
  75. package/src/plan/repo-facts.mjs +95 -11
  76. package/src/plan/rounds.mjs +27 -0
  77. package/src/repo/declared-paths.mjs +74 -6
  78. package/src/repo/integrate.mjs +1 -1
  79. package/src/repo/scope-closure.mjs +59 -0
  80. package/src/repo/signal-block.mjs +26 -0
  81. package/src/repo/signal.mjs +9 -24
  82. package/src/repo/workspace.mjs +189 -14
  83. package/src/report/campaign-brief.mjs +4 -0
  84. package/src/report/message.mjs +37 -6
  85. package/src/report/progress.mjs +13 -2
  86. package/src/run/paths.mjs +14 -1
  87. package/src/run/usage.mjs +66 -1
  88. package/src/util.mjs +18 -2
@@ -177,18 +177,31 @@ function tapSelectedNothing(stdout) {
177
177
  }
178
178
 
179
179
  /**
180
- * The environment a filtered proof runs under: the ambient environment with
181
- * the TAP reporter selected through `NODE_OPTIONS`, minus `NODE_TEST_CONTEXT`.
182
- * That marker belongs to whichever test runner spawned this process; a nested
183
- * `node --test` that inherits it stays a runner child and emits no TAP at all
184
- * (measured 2026-09-21: with the marker the zero-plan line never appears,
185
- * without it exactly once) -- and a proof is judged as its own top-level run,
186
- * not as the suite's child.
180
+ * The full controller environment a Definition of Done verification proof runs
181
+ * under. A worker or judge receives the allowlist of `worker-env.mjs`; a
182
+ * verification command is the operator's own check and keeps today's
183
+ * environment (R1 constraint), secrets included. Exported so the gate test can
184
+ * assert that the verification spawn really carries the whole thing.
185
+ *
186
+ * @returns {NodeJS.ProcessEnv}
187
+ */
188
+ export function verificationEnvironment() {
189
+ return { ...process.env };
190
+ }
191
+
192
+ /**
193
+ * The environment a filtered proof runs under: the full controller environment
194
+ * with the TAP reporter selected through `NODE_OPTIONS`, minus
195
+ * `NODE_TEST_CONTEXT`. That marker belongs to whichever test runner spawned
196
+ * this process; a nested `node --test` that inherits it stays a runner child
197
+ * and emits no TAP at all (measured 2026-09-21: with the marker the zero-plan
198
+ * line never appears, without it exactly once) -- and a proof is judged as its
199
+ * own top-level run, not as the suite's child.
187
200
  *
188
201
  * @returns {NodeJS.ProcessEnv}
189
202
  */
190
203
  function envForFilteredProof() {
191
- const { NODE_TEST_CONTEXT: _outer, NODE_OPTIONS: existing, ...ambient } = process.env;
204
+ const { NODE_TEST_CONTEXT: _outer, NODE_OPTIONS: existing, ...ambient } = verificationEnvironment();
192
205
  return { ...ambient, NODE_OPTIONS: existing ? `${existing} --test-reporter=tap` : "--test-reporter=tap" };
193
206
  }
194
207
 
@@ -239,7 +252,9 @@ async function proveCommand(id, proof, cwd, timeoutMs) {
239
252
  shell: true,
240
253
  detached: process.platform !== "win32",
241
254
  stdio: ["ignore", "pipe", "pipe"],
242
- ...(filters.length ? { env: envForFilteredProof() } : {}),
255
+ // A proof keeps the full controller environment, filtered or not; only
256
+ // the test-filter case swaps in the TAP reporter.
257
+ env: filters.length ? envForFilteredProof() : verificationEnvironment(),
243
258
  });
244
259
  let stdout = "";
245
260
  let stderr = "";
@@ -23,6 +23,7 @@ import { spawn } from "node:child_process";
23
23
  import { boundedGitSync } from "../repo/worktree.mjs";
24
24
  import { tmpdir } from "node:os";
25
25
  import { validateContract } from "../contract/index.mjs";
26
+ import { assertInlineScriptProofsParse } from "../contract/definition-of-done.mjs";
26
27
  import { validateNodeSnapshot } from "../contract/snapshot.mjs";
27
28
  import { compactCost as formatCost } from "../util.mjs";
28
29
  import { runsRoot } from "../run/paths.mjs";
@@ -34,6 +35,46 @@ import { runsRoot } from "../run/paths.mjs";
34
35
 
35
36
  const LIVE_PREFLIGHT_PROMPT = "Respond with exactly FABERUN_PREFLIGHT_OK and do not use tools.";
36
37
  const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
38
+ /** How much of a timed-out provider's tail reaches the detail. */
39
+ const LIVE_PREFLIGHT_EXCERPT_BYTES = 512;
40
+ /**
41
+ * The measured live-preflight budget, in seconds, and the floor under every
42
+ * runtime's default. Measured 2026-09-22: four routed runtimes asked in
43
+ * parallel took about 18s, so 60s holds for every caller -- the dispatch gate,
44
+ * `doctor`, `faberun plan` and `faberun preflight`. It lives here and only
45
+ * here; `run-identity.mjs` and `host/preflight.mjs` used to carry their own
46
+ * copies, and the CLI's preflight used to default to 15s.
47
+ * `FABERUN_PREFLIGHT_TIMEOUT_SEC` stays the operator override.
48
+ */
49
+ export const LIVE_PREFLIGHT_TIMEOUT_SEC = 60;
50
+ /**
51
+ * The lowest reasoning effort each harness accepts, applied to the liveness
52
+ * hello. The hello is one token: paying the effort a contract declares for the
53
+ * node's work -- a judge at `xhigh`, F10 -- to prove a credential works is what
54
+ * turned a deadline into a false failure. `zcode`, `exec-jsonl` and `replay`
55
+ * expose no effort transport, so there is nothing to lower for them.
56
+ *
57
+ * @type {Readonly<Record<string, string>>}
58
+ */
59
+ const LIVE_PREFLIGHT_REASONING = Object.freeze({
60
+ claude: "low",
61
+ codex: "low",
62
+ agy: "low",
63
+ dsh: "off",
64
+ });
65
+ /**
66
+ * The causes `normalizeProviderAvailability` classifies out of provider text.
67
+ * A timeout whose tail names one of these is a provider that answered and then
68
+ * stopped talking, not silence, and the code the gate reads has to say so: a
69
+ * 401 the provider re-tried used to surface as `preflight_timeout` (F9).
70
+ */
71
+ const PROVIDER_NAMED_CAUSES = new Set([
72
+ "authentication_failed",
73
+ "quota_exhausted",
74
+ "insufficient_balance",
75
+ "model_not_supported",
76
+ "credentials_missing",
77
+ ]);
37
78
  /**
38
79
  * `persisted` is for the dispatch gate, which hands over the run's own
39
80
  * serialized contract: that one was validated when it was authored, and
@@ -46,12 +87,16 @@ const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
46
87
  * whole is part of the answer.
47
88
  *
48
89
  * @param {string} contractPath
49
- * @param {{static?: boolean, liveTimeoutSec?: number, minimumTimeoutSec?: number, persisted?: boolean}} [options]
90
+ * @param {{static?: boolean, liveTimeoutSec?: number, persisted?: boolean}} [options]
50
91
  * @returns {Promise<ProbeResult[]>}
51
92
  */
52
93
  export async function preflightContract(contractPath, options = {}) {
53
94
  const absoluteContractPath = resolve(contractPath);
54
95
  const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath, options.persisted === true ? { persisted: true } : {});
96
+ // R34: every command proof that is an inline script is parsed before the
97
+ // first dispatch, so a body no attempt could ever make exit 0 refuses the
98
+ // launch here, naming its node and item, instead of after three paid tries.
99
+ assertInlineScriptProofsParse(contract.nodes);
55
100
  return preflightRuntimes([...reachableRuntimes(contract).values()], { ...options, cwd: contract.cwd });
56
101
  }
57
102
 
@@ -70,7 +115,7 @@ export async function preflightContract(contractPath, options = {}) {
70
115
  * once, so a caller cannot accidentally ask a different question.
71
116
  *
72
117
  * @param {{runtime: RuntimeSnapshot, requiredCapabilitySets?: import("../harnesses/index.mjs").CapabilityRequirements[]}[]} entries
73
- * @param {{static?: boolean, liveTimeoutSec?: number, minimumTimeoutSec?: number, cwd?: string}} [options]
118
+ * @param {{static?: boolean, liveTimeoutSec?: number, cwd?: string}} [options]
74
119
  * @returns {Promise<ProbeResult[]>}
75
120
  */
76
121
  export async function preflightRuntimes(entries, options = {}) {
@@ -95,7 +140,7 @@ export async function preflightRuntimes(entries, options = {}) {
95
140
  }
96
141
  try {
97
142
  return await Promise.all(staticChecks.map(async (check, index) => {
98
- const live = await livePreflight(runtimes[index], liveRepo, configuredTimeoutSec ?? Math.max(options.minimumTimeoutSec ?? 0, defaultLivePreflightTimeout(runtimes[index])));
143
+ const live = await livePreflight(runtimes[index], liveRepo, configuredTimeoutSec ?? defaultLivePreflightTimeout(runtimes[index]));
99
144
  const liveDetail = live.status === "done"
100
145
  ? `live done · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`
101
146
  : `live ${live.status} · ${live.error?.code ?? "provider_error"}: ${redactProviderText(live.error?.message ?? "generation failed")} · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`;
@@ -146,8 +191,7 @@ export function defaultLivePreflightTimeout(runtime) {
146
191
  // Keep LIVE_PREFLIGHT_CEILING_SEC equal to the largest value returned here.
147
192
  const reasoning = /** @type {{reasoning?: unknown}} */ (runtime).reasoning;
148
193
  if (reasoning === "xhigh" || reasoning === "max") return 180;
149
- if (reasoning === "high") return 60;
150
- return 15;
194
+ return LIVE_PREFLIGHT_TIMEOUT_SEC;
151
195
  }
152
196
  /**
153
197
  * The longest a live preflight can take without an explicit budget: what a
@@ -180,13 +224,15 @@ function createLivePreflightRepo() {
180
224
  * @returns {RuntimeSnapshot}
181
225
  */
182
226
  function safeLiveRuntime(runtime) {
183
- if (runtime.harness === "codex") return { ...runtime, sandbox: "read-only" };
184
- if (runtime.harness === "dsh") return { ...runtime, sandbox: "read-only" };
185
- if (runtime.harness === "claude") return { ...runtime, permissionMode: "plan" };
227
+ const effort = LIVE_PREFLIGHT_REASONING[runtime.harness];
228
+ const safe = effort ? { ...runtime, reasoning: effort } : { ...runtime };
229
+ if (safe.harness === "codex") return { ...safe, sandbox: "read-only" };
230
+ if (safe.harness === "dsh" || safe.harness === "fx") return { ...safe, sandbox: "read-only" };
231
+ if (safe.harness === "claude") return { ...safe, permissionMode: "plan" };
186
232
  // `plan` is the ZCode mode that reads without writing; the adapter's own
187
233
  // default is `yolo`, which a preflight prompt must never reach.
188
- if (runtime.harness === "zcode") return { ...runtime, permissionMode: "plan" };
189
- return { ...runtime };
234
+ if (safe.harness === "zcode") return { ...safe, permissionMode: "plan" };
235
+ return safe;
190
236
  }
191
237
  /**
192
238
  * @param {RuntimeSnapshot} runtime
@@ -274,14 +320,7 @@ function livePreflight(runtime, cwd, timeoutSec) {
274
320
  }));
275
321
  child.once("close", (exitCode, signalName) => {
276
322
  if (timedOut) {
277
- finish({
278
- status: "failed",
279
- result: null,
280
- continuationId: null,
281
- usage: emptyUsage(),
282
- costUsd: null,
283
- error: { code: "preflight_timeout", message: `live generation timed out after ${timeoutSec}s` },
284
- });
323
+ finish(timedOutEnvelope(safeRuntime, stdout, stderr, timeoutSec));
285
324
  return;
286
325
  }
287
326
  /** @type {ProviderEnvelope} */
@@ -314,6 +353,63 @@ function appendBounded(current, chunk, limit) {
314
353
  const combined = Buffer.concat([Buffer.from(current), Buffer.from(chunk)]);
315
354
  return (combined.length > limit ? combined.subarray(combined.length - limit) : combined).toString("utf8");
316
355
  }
356
+ /**
357
+ * A timed-out process is not automatically a silent one. A provider that
358
+ * re-tries a 401, or prints a quota refusal and then hangs, said plenty before
359
+ * the wall-clock kill; the kill signal is ours, not the provider's, so the tail
360
+ * is classified the way a closed process is classified and the redacted excerpt
361
+ * is attached. Only a tail that names no provider cause stays
362
+ * `preflight_timeout`.
363
+ *
364
+ * @param {RuntimeSnapshot} runtime
365
+ * @param {string} stdout
366
+ * @param {string} stderr
367
+ * @param {number} timeoutSec
368
+ * @returns {ProviderEnvelope}
369
+ */
370
+ function timedOutEnvelope(runtime, stdout, stderr, timeoutSec) {
371
+ const tail = appendBounded(stdout, `\n${stderr}`, LIVE_PREFLIGHT_EXCERPT_BYTES).trim();
372
+ const cause = classifiedTimeoutCause(runtime, stdout, stderr, tail);
373
+ const excerpt = redactProviderText(tail);
374
+ return {
375
+ status: "failed",
376
+ result: null,
377
+ continuationId: null,
378
+ usage: emptyUsage(),
379
+ costUsd: null,
380
+ error: {
381
+ code: cause ?? "preflight_timeout",
382
+ message: `live generation timed out after ${timeoutSec}s${excerpt ? ` · ${excerpt}` : ""}`,
383
+ },
384
+ };
385
+ }
386
+ /**
387
+ * The provider cause a timed-out tail names, or null when it names none. The
388
+ * harness adapter knows which event or line carries it, so the output is first
389
+ * normalized as if it had exited -- with the signal suppressed, because the
390
+ * signal at this point is ours -- and then classified through the same
391
+ * availability vocabulary an exited process uses. A harness that wrote the
392
+ * cause only to stderr still named it: the bounded raw tail is classified
393
+ * directly as the fallback.
394
+ *
395
+ * @param {RuntimeSnapshot} runtime
396
+ * @param {string} stdout
397
+ * @param {string} stderr
398
+ * @param {string} tail
399
+ * @returns {string|null}
400
+ */
401
+ function classifiedTimeoutCause(runtime, stdout, stderr, tail) {
402
+ try {
403
+ const envelope = normalizeProviderResult(runtime, stdout, null, null, { stderr });
404
+ const availability = normalizeProviderAvailability(runtime, envelope);
405
+ if (PROVIDER_NAMED_CAUSES.has(availability.reason)) return availability.reason;
406
+ } catch {
407
+ // An unparsable tail still gets the raw-text classification below.
408
+ }
409
+ if (!tail) return null;
410
+ const availability = normalizeProviderAvailability(runtime, { status: "failed", error: { code: "provider_error", message: tail } });
411
+ return PROVIDER_NAMED_CAUSES.has(availability.reason) ? availability.reason : null;
412
+ }
317
413
  /** @param {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}|undefined} usage */
318
414
  function formatUsage(usage) {
319
415
  if (!usage) return "in - out - cache -";
@@ -1,9 +1,10 @@
1
1
  import { validateWorkerResult } from "../contract/worker-result.mjs";
2
2
  import { scopeFindingsPromptSection } from "../contract/scope-findings.mjs";
3
3
  import { JUDGE_ENVELOPE_REASON, JUDGE_FINDING_ENVELOPE_REASON, JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
4
+ import { RESERVED_OWNER_DECISIONS, uncoveredReservedOwnerDecisions } from "../contract/articles.mjs";
4
5
  import { harnessCapabilities } from "../harnesses/index.mjs";
5
6
 
6
- /** @typedef {{id: string, definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], taskPacket: {mode?: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], writeFiles?: string[], writeRoots?: string[], verification: {argv: string[]}[]}}} JudgeNode */
7
+ /** @typedef {{id: string, definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], taskPacket: {mode?: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], decisions?: string[], writeFiles?: string[], writeRoots?: string[], verification: {argv: string[]}[]}}} JudgeNode */
7
8
  /** @typedef {{verdict: "pass"|"fail"|"invalid_judge_output", maxSeverity: "none"|"minor"|"major"|"critical", summary: string, findings: {severity: "minor"|"major"|"critical", description: string, evidence: string}[]}} JudgeVerdict */
8
9
 
9
10
  /**
@@ -178,10 +179,33 @@ function judgeVerificationEvidence(verification) {
178
179
  };
179
180
  }
180
181
 
182
+ /** Heading `renderPreviousAttemptSection` (engine/retry.mjs) writes an operator answer under. */
183
+ const OPERATOR_ANSWER_HEADING = "Operator answer:";
184
+
185
+ /**
186
+ * The latest operator answer carried by a `Previous attempt` section, or null.
187
+ * `renderPreviousAttemptSection` writes it verbatim under the heading, so the
188
+ * judge reads the same newest answer the worker read without a second copy on
189
+ * the node snapshot. A second `--answer` appends an override and the section is
190
+ * rebuilt from the newest record on the next resume.
191
+ *
192
+ * @param {unknown} previousAttempt
193
+ * @returns {string|null}
194
+ */
195
+ export function previousAttemptOperatorAnswer(previousAttempt) {
196
+ if (typeof previousAttempt !== "string") return null;
197
+ const start = previousAttempt.indexOf(OPERATOR_ANSWER_HEADING);
198
+ if (start < 0) return null;
199
+ const rest = previousAttempt.slice(start + OPERATOR_ANSWER_HEADING.length);
200
+ const stop = rest.search(/^(?:Attempt \d+ failed|Error:|Judge findings|Scope findings|Failing verification:)/mu);
201
+ const answer = (stop >= 0 ? rest.slice(0, stop) : rest).trim();
202
+ return answer || null;
203
+ }
204
+
181
205
  /**
182
206
  * @param {JudgeNode} node
183
207
  * @param {unknown} workerResult
184
- * @param {{diff?: unknown[], verification?: unknown, deterministic?: unknown, scopeFindings?: {unexpectedPaths: string[]}|null, previousAttempt?: string}} context
208
+ * @param {{diff?: unknown[], verification?: unknown, deterministic?: unknown, scopeFindings?: {unexpectedPaths: string[]}|null, previousAttempt?: string, operatorAnswer?: string}} context
185
209
  * @returns {string}
186
210
  */
187
211
  export function judgePrompt(node, workerResult, context = {}) {
@@ -211,9 +235,23 @@ export function judgePrompt(node, workerResult, context = {}) {
211
235
  const diff = Array.isArray(context.diff) ? /** @type {unknown[]} */ (context.diff).slice(0, 64) : [];
212
236
  const verificationResult = judgeVerificationEvidence(context.verification);
213
237
  const scopeSection = scopeFindingsPromptSection(context.scopeFindings);
238
+ const decisions = node.taskPacket.decisions ?? [];
239
+ const uncoveredReserved = uncoveredReservedOwnerDecisions(decisions);
240
+ const operatorAnswer = typeof context.operatorAnswer === "string" && context.operatorAnswer.trim()
241
+ ? context.operatorAnswer.trim()
242
+ : previousAttemptOperatorAnswer(context.previousAttempt);
243
+ const decisionsSection =
244
+ `Decisions already made by the operator:\n${decisions.length ? decisions.map((decision) => `- ${decision}`).join("\n") : "- (none)"}\n\n` +
245
+ `Reserved owner decisions (the repository owner alone decides these):\n${RESERVED_OWNER_DECISIONS.map((decision) => `- ${decision}`).join("\n")}\n\n` +
246
+ `Reserved owner decisions with no coverage in the decisions above:\n${uncoveredReserved.length ? uncoveredReserved.map((decision) => `- ${decision}`).join("\n") : "- (none)"}\n\n` +
247
+ "A diff that takes an owner decision with no coverage is a blocking finding: fail and name the decision. Referencing an existing owner decision does not take it.";
248
+ const operatorSection = operatorAnswer
249
+ ? `Operator answer:\n${operatorAnswer}\n\nThat operator answer is a blocking judgment item: an unmet answer is a blocking finding, so fail with a finding that cites the Definition of Done judgment item the answer bears on, and do not pass work that ignores it.`
250
+ : "";
214
251
  return `Review node ${node.id} independently. The review context is closed: inspect only the ${node.taskPacket.mode === "autonomous" ? "write roots" : "write files"} below and do not perform repository-wide discovery. Do not re-run the verification commands — the controller already executed them and attached the results; re-running suites duplicates cost without adding evidence.\n\n` +
215
252
  `${writeBoundaryLabel}:\n${writeFiles}\n\nVerification commands (already executed by the controller):\n${verification}\n\n` +
216
- `Task brief:\n${node.taskPacket.objective}\n\nInstructions:\n${taskInstructions}\n\nDefinition of Done:\n${criteria}\n\n` +
253
+ `Task brief:\n${node.taskPacket.objective}\n\nInstructions:\n${taskInstructions}\n\n${decisionsSection}\n\nDefinition of Done:\n${criteria}\n\n` +
254
+ (operatorSection ? `${operatorSection}\n\n` : "") +
217
255
  `Worker result (structured):\n${structured ? JSON.stringify(structured) : "(invalid worker result withheld)"}\n\n` +
218
256
  `Controller diff paths:\n${diff.length ? diff.map((path) => `- ${path}`).join("\n") : "- (none)"}\n\n` +
219
257
  `Controller verification:\n${JSON.stringify(verificationResult)}\n\n` +
@@ -12,13 +12,14 @@
12
12
  * contract left open, and only from runtimes that are available now: a resume
13
13
  * after an exhausted provider is exactly when that matters.
14
14
  */
15
- import { JUDGE_SCHEMA, SETTLED } from "./prompts.mjs";
15
+ import { JUDGE_SCHEMA, SETTLED, TERMINAL } from "./prompts.mjs";
16
16
  import { Buffer } from "node:buffer";
17
17
  import { acquire as acquireLock } from "../run/lock.mjs";
18
18
  import { applyInvalidWorkerResult, assertRunMutable, handleProviderExhaustion } from "./lifecycle.mjs";
19
19
  import { applyJudgeResult } from "./review.mjs";
20
20
  import { assertSourceUnchanged, captureRunIdentity, recordedBaseRef, statesFingerprint } from "./run-identity.mjs";
21
21
  import { attemptWorkspace, gitHead, removeWorktree, runRefName } from "../repo/worktree.mjs";
22
+ import { replacePacketInContract, widenTaskPacket } from "../repo/scope-closure.mjs";
22
23
  import { attemptWorktreePath } from "../run/paths.mjs";
23
24
  import { canonicalWorkerResultText, isResultMaterializationInvocation, materializeAttemptResult, recoverWorkerResult } from "./result-file.mjs";
24
25
  import { checkPersistedWorkerScope, persistedScopeBoundary, reconcileAmbiguousWorkerRestart, resolveUnknownEffect } from "./scope.mjs";
@@ -35,16 +36,17 @@ import { hasOperationIntent, hasOperationSettlement, operationNeedsRecovery, pro
35
36
  import { isUnknownEffectStop, planResumeRetry, renderPreviousAttemptSection } from "./retry.mjs";
36
37
  import { join, resolve } from "node:path";
37
38
  import { parseDiscoveryResult, parseWorkerResult } from "../contract/worker-result.mjs";
38
- import { readJson, writeJsonAtomic } from "../run/store.mjs";
39
+ import { readJson, writeJsonAtomic, appendJsonl } from "../run/store.mjs";
39
40
  import { recoverIntegrations } from "../repo/integrate.mjs";
40
41
  import { registerRun, resolveCampaign } from "../campaign/index.mjs";
41
42
  import { syncAgentSignal } from "../repo/signal.mjs";
42
43
  import { validateContract } from "../contract/index.mjs";
43
44
  import { validateRunMetadata, OPERATOR_ANSWER_MAX_BYTES } from "../contract/snapshot.mjs";
44
45
  import { verificationFailureVerdict } from "./judge-gate.mjs";
45
- import { verificationFailureWithScope } from "../contract/scope-findings.mjs";
46
+ import { verificationFailureWithScope, reauthorProposalRecord, reauthorWriteConflicts, reauthorRiskTier, reauthorApproved } from "../contract/scope-findings.mjs";
46
47
  import { applyRejection, applyVerificationFailure, raiseNodeAttention, settleDone } from "./settle.mjs";
47
48
  import { isHumanStepPending } from "../contract/human-step.mjs";
49
+ import { runBoundedRounds } from "../plan/rounds.mjs";
48
50
 
49
51
  /** @typedef {import("../repo/integrate.mjs").IntegrationResult} IntegrationResult */
50
52
  /** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
@@ -84,11 +86,14 @@ export function recoveryExhaustionEnvelope(recovery, invocation) {
84
86
 
85
87
  /**
86
88
  * @param {string} runDirPath
87
- * @param {{node?: string, reconcile?: string, answer?: {node: string, path: string}, detachedBootstrap?: boolean}} [options]
89
+ * @param {{node?: string, reconcile?: string, answer?: {node: string, path: string}, detachedBootstrap?: boolean, reauthor?: {node: string, discover: (context: {round: number, node: import("../contract/index.mjs").ValidatedNode, state: NodeSnapshot, missingContext: string[], findings: string[]}) => (import("../contract/scope-findings.mjs").ReauthorAdditions|Promise<import("../contract/scope-findings.mjs").ReauthorAdditions>), rounds?: number, approve?: boolean, approveBelow?: string}}} [options]
88
90
  * `node` limits the retry in place to one node and its dependants,
89
91
  * `reconcile` acknowledges a node stopped as `unknown_effect_reconciled`,
90
- * `answer` records an operator's answer for a node blocked on
91
- * `context_missing` and re-dispatches it, and
92
+ * `answer` records an operator's answer for any non-terminal node and
93
+ * re-dispatches it,
94
+ * `reauthor` widens one `context_missing` node's packet from a bounded
95
+ * discovery pass and re-dispatches it once the widening validates and is
96
+ * approved, and
92
97
  * `detachedBootstrap` is set only by the CLI entry when this process is its
93
98
  * own detached child
94
99
  * @returns {Promise<RunOutcome>}
@@ -100,10 +105,16 @@ export async function resumeRun(runDirPath, options = {}) {
100
105
  if (options.node && options.answer && options.node !== options.answer.node) {
101
106
  throw new Error(`--answer ${options.answer.node} conflicts with --node ${options.node}`);
102
107
  }
108
+ if (options.reauthor && options.node && options.reauthor.node !== options.node) {
109
+ throw new Error(`--reauthor ${options.reauthor.node} conflicts with --node ${options.node}`);
110
+ }
111
+ if (options.reauthor && options.answer) {
112
+ throw new Error(`--reauthor ${options.reauthor.node} cannot be combined with --answer ${options.answer.node}`);
113
+ }
103
114
  const runDir = resolve(runDirPath);
104
115
  assertRunMutable(runDir);
105
116
  const contractPath = join(runDir, "contract.json");
106
- const contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
117
+ let contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
107
118
  const lock = acquireLock(runDir);
108
119
  try {
109
120
  const storedMetadata = validateRunMetadata(readJson(join(runDir, "run.json")), { requireSourceIdentity: true });
@@ -161,15 +172,51 @@ export async function resumeRun(runDirPath, options = {}) {
161
172
  result: { status: "done", summary: boundedSummary(answerText.trim() || answerNode.humanStep.step), verification: [], artifacts: [], missingContext: [] },
162
173
  }, lock);
163
174
  } else {
164
- if (!answerState || !isBlockedContextTerminal(answerState)) {
165
- throw new Error(`node ${options.answer.node} is not blocked on missing context`);
175
+ // Any non-terminal node accepts an operator answer, not only the
176
+ // `context_missing` boundary. A node that already reached a terminal
177
+ // status has no attempt left to receive the answer, so it is refused
178
+ // here -- in the engine, where the resume decisions are made and the
179
+ // engine test can reach the gate.
180
+ if (!answerState || TERMINAL.has(answerState.status)) {
181
+ throw new Error(`node ${options.answer.node} is not blocked on missing context and is terminal, so it cannot accept an operator answer`);
166
182
  }
167
183
  const answerOverride = /** @type {import("../contract/index.mjs").ExecutionOverride} */ (/** @type {unknown} */ ({
168
184
  kind: "operator-answer",
169
- reason: `operator answered missing context for node ${options.answer.node}`,
185
+ reason: `operator answered node ${options.answer.node}`,
170
186
  text: answerText,
171
187
  }));
172
188
  recordExecutionOverride(runDir, answerState, answerOverride, lock);
189
+ // Render the answer into the bounded `Previous attempt` section before
190
+ // classification. The retry path below renders it too, but a parked
191
+ // node the recover path re-dispatches would otherwise carry the
192
+ // override without ever showing it to the worker.
193
+ answerState.previousAttempt = renderPreviousAttemptSection(answerState) ?? answerState.previousAttempt;
194
+ }
195
+ }
196
+ /** @type {string|null} */
197
+ let reauthorAppliedNode = null;
198
+ if (options.reauthor) {
199
+ const targetNode = contract.nodes.find((node) => node.id === options.reauthor?.node);
200
+ const targetState = states.get(options.reauthor.node);
201
+ if (!targetNode) throw new Error(`unknown node id: ${options.reauthor.node}`);
202
+ if (!targetState) throw new Error(`missing persisted node snapshot for ${options.reauthor.node}`);
203
+ const reauthorBudget = options.reauthor.rounds ?? DEFAULT_REAUTHOR_ROUNDS;
204
+ const outcome = await reauthorRefusedNode({
205
+ contract, contractPath, runDir, node: targetNode, state: targetState,
206
+ discover: options.reauthor.discover,
207
+ rounds: reauthorBudget,
208
+ approve: options.reauthor.approve === true,
209
+ approveBelow: options.reauthor.approveBelow,
210
+ });
211
+ process.stdout.write(`[resume] ${contract.id} · reauthor ${targetNode.id} · ${outcome.outcome} (${outcome.roundsUsed}/${reauthorBudget} round${reauthorBudget === 1 ? "" : "s"})\n`);
212
+ if (outcome.applied && outcome.contract && outcome.raw) {
213
+ contract = outcome.contract;
214
+ const replacement = contract.nodes.find((node) => node.id === targetNode.id);
215
+ if (!replacement) throw new Error(`widened contract lost node ${targetNode.id}`);
216
+ targetState.packetHash = replacement.packetHash;
217
+ targetState.sourceIdentity = replacement.sourceIdentity;
218
+ writeJsonAtomic(contractPath, outcome.raw);
219
+ reauthorAppliedNode = targetNode.id;
173
220
  }
174
221
  }
175
222
  const plan = planResumeRetry(contract, states, { node: options.node, reconcile: options.reconcile, answer: options.answer?.node });
@@ -185,9 +232,9 @@ export async function resumeRun(runDirPath, options = {}) {
185
232
  const state = states.get(node.id);
186
233
  if (!state) continue;
187
234
  if (state.status === "done") continue;
188
- if (isBlockedContextTerminal(state) && node.id !== options.answer?.node) continue;
235
+ if (isBlockedContextTerminal(state) && node.id !== options.answer?.node && node.id !== reauthorAppliedNode) continue;
189
236
  if (isHumanStepPending(state) && node.id !== options.answer?.node) continue;
190
- const action = plan.actions.get(node.id) ?? "recover";
237
+ const action = node.id === reauthorAppliedNode ? "retry" : plan.actions.get(node.id) ?? "recover";
191
238
  // Adoption before retry: an unresolved blocking review is re-judged from
192
239
  // the preserved worker result, never reset to a fresh worker attempt.
193
240
  if (action === "rejudge") {
@@ -546,6 +593,66 @@ export async function resumeRun(runDirPath, options = {}) {
546
593
  export function isBlockedContextTerminal(state) {
547
594
  return state.status === "blocked" && state.error?.code === "context_missing";
548
595
  }
596
+ /**
597
+ * The default number of discovery rounds a `resume --reauthor` spends widening
598
+ * one refused packet. The budget is hard: the round after this one never runs.
599
+ */
600
+ export const DEFAULT_REAUTHOR_ROUNDS = 1;
601
+ /** One round of the reauthor budget: its proposal, candidate, verdict, findings. */
602
+ /** @typedef {{accepted: boolean, round: number, additions: import("../contract/scope-findings.mjs").ReauthorAdditions, packet: import("../contract/index.mjs").TaskPacket, candidate: Record<string, unknown>, widened: ValidatedContract|null, findings: string[]}} ReauthorRound */
603
+ /**
604
+ * Run the bounded discovery pass for one `context_missing` node, validate the
605
+ * widened packet against the whole contract (per-node closure, the cross-node
606
+ * pair, and the write-overlap check no single packet can see), and return the
607
+ * decision. The caller owns applying it: an accepted packet below the approval
608
+ * threshold, or any packet the operator approved, is persisted and the node
609
+ * re-dispatched; everything else leaves the node refused.
610
+ *
611
+ * @param {{contract: ValidatedContract, contractPath: string, runDir: string, node: import("../contract/index.mjs").ValidatedNode, state: NodeSnapshot, discover: (context: {round: number, node: import("../contract/index.mjs").ValidatedNode, state: NodeSnapshot, missingContext: string[], findings: string[]}) => (import("../contract/scope-findings.mjs").ReauthorAdditions|Promise<import("../contract/scope-findings.mjs").ReauthorAdditions>), rounds?: number, approve?: boolean, approveBelow?: string}} options
612
+ * @returns {Promise<{accepted: boolean, roundsUsed: number, last: ReauthorRound|null, history: ReauthorRound[], applied: boolean, outcome: string, record: Record<string, unknown>, contract: ValidatedContract|null, raw: Record<string, unknown>|null}>}
613
+ */
614
+ export async function reauthorRefusedNode(options) {
615
+ const { contract, contractPath, runDir, node, state, discover, rounds = DEFAULT_REAUTHOR_ROUNDS, approve = false, approveBelow } = options;
616
+ if (!isBlockedContextTerminal(state)) throw new Error(`node ${node.id} is not blocked on missing context, so it cannot be reauthored`);
617
+ if (typeof discover !== "function") throw new TypeError("resume --reauthor requires a discovery runner");
618
+ const raw = JSON.parse(readFileSync(contractPath, "utf8"));
619
+ const refused = /** @type {{missingContext?: string[]}|null|undefined} */ (state.result);
620
+ const missingContext = Array.isArray(refused?.missingContext) ? refused.missingContext : [];
621
+ const budget = await runBoundedRounds(rounds, async (round, history) => {
622
+ const additions = (await discover({ round, node, state, missingContext, findings: history.flatMap((item) => item.findings) })) ?? {};
623
+ const packet = widenTaskPacket(node.taskPacket, additions);
624
+ const candidate = replacePacketInContract(raw, node.id, packet);
625
+ const findings = reauthorWriteConflicts(contract.nodes, node.id, additions.writeFiles).map((conflict) => conflict.reason);
626
+ /** @type {ValidatedContract|null} */
627
+ let widened = null;
628
+ try {
629
+ widened = validateContract(candidate, contractPath);
630
+ } catch (error) {
631
+ findings.push(errorMessage(error));
632
+ }
633
+ const accepted = widened !== null && findings.length === 0;
634
+ if (accepted && widened) {
635
+ const replacement = widened.nodes.find((item) => item.id === node.id);
636
+ if (replacement) /** @type {any} */ (candidate).nodes.find((/** @type {any} */ item) => item.id === node.id).packetHash = replacement.packetHash;
637
+ }
638
+ return { accepted, round, additions, packet, candidate, widened, findings };
639
+ });
640
+ const last = budget.last;
641
+ const outcome = budget.accepted ? (reauthorApproved(node, { approve, approveBelow }) ? "applied" : "approval_required") : "rounds_exhausted";
642
+ const record = reauthorProposalRecord({
643
+ nodeId: node.id, outcome, riskTier: reauthorRiskTier(node), budget: rounds, roundsUsed: budget.roundsUsed,
644
+ additions: last?.additions, packet: last?.packet, findings: last?.findings, history: budget.history,
645
+ });
646
+ appendJsonl(join(runDir, "reauthor.jsonl"), record);
647
+ return {
648
+ ...budget,
649
+ applied: outcome === "applied",
650
+ outcome,
651
+ record,
652
+ contract: budget.accepted ? last?.widened ?? null : null,
653
+ raw: budget.accepted ? last?.candidate ?? null : null,
654
+ };
655
+ }
549
656
  /**
550
657
  * The operator-answer result summary for a human-step node's `done`
551
658
  * transition, bounded to the worker-result protocol's own summary ceiling
@@ -15,12 +15,11 @@
15
15
  * exhausted run budget needs `--max-input-tokens <n>`.
16
16
  */
17
17
  import { Buffer } from "node:buffer";
18
+ import { PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES } from "../contract/snapshot.mjs";
18
19
 
19
20
  /** Heading of the section appended to a retried attempt's worker and judge prompt. */
20
21
  const PREVIOUS_ATTEMPT_HEADING = "Previous attempt";
21
22
 
22
- /** Hard ceiling for the whole `Previous attempt` section, in bytes. */
23
- const PREVIOUS_ATTEMPT_MAX_BYTES = 8 * 1024;
24
23
 
25
24
  /** Heading under which an operator's answer is rendered inside the section. */
26
25
  const OPERATOR_ANSWER_HEADING = "Operator answer";
@@ -267,11 +266,6 @@ export function renderPreviousAttemptSection(state) {
267
266
  if (state.error?.code || state.error?.message) {
268
267
  parts.push(`Error: ${state.error?.code ?? "unknown"}${state.error?.message ? ` — ${bounded(state.error.message, 1024)}` : ""}`);
269
268
  }
270
- const answer = mostRecentOperatorAnswer(state);
271
- if (answer !== null) {
272
- parts.push(`${OPERATOR_ANSWER_HEADING}:`);
273
- parts.push(bounded(answer, 1024));
274
- }
275
269
  const findings = state.gate?.findings ?? [];
276
270
  if (findings.length) {
277
271
  parts.push(`Judge findings (verdict ${state.gate?.verdict ?? "unknown"}, maxSeverity ${state.gate?.maxSeverity ?? "none"}):`);
@@ -293,8 +287,14 @@ export function renderPreviousAttemptSection(state) {
293
287
  if (typeof last?.exitCode === "number" && last.exitCode !== 0) parts.push(` exit code: ${last.exitCode}`);
294
288
  }
295
289
  }
296
- if (parts.length === 1) return null;
297
- return boundSection([`## ${PREVIOUS_ATTEMPT_HEADING}`, "", ...parts].join("\n"));
290
+ const answer = mostRecentOperatorAnswer(state);
291
+ if (parts.length === 1 && answer === null) return null;
292
+ const evidence = boundSection([`## ${PREVIOUS_ATTEMPT_HEADING}`, "", ...parts].join("\n"));
293
+ // The answer was bounded where it was accepted (OPERATOR_ANSWER_MAX_BYTES),
294
+ // so it is carried whole, after the evidence and outside its budget.
295
+ // Measured 2026-09-28: bounded here at 1 KiB inside an 8 KiB section, an
296
+ // answer the engine had accepted and recorded reached the worker cut short.
297
+ return answer === null ? evidence : `${evidence}\n${OPERATOR_ANSWER_HEADING}:\n${answer}`;
298
298
  }
299
299
 
300
300
  /**
@@ -315,9 +315,9 @@ export function appendPreviousAttempt(prompt, section) {
315
315
  * @returns {string} the text bounded to the section ceiling, with a marker
316
316
  */
317
317
  function boundSection(text) {
318
- if (Buffer.byteLength(text, "utf8") <= PREVIOUS_ATTEMPT_MAX_BYTES) return text;
318
+ if (Buffer.byteLength(text, "utf8") <= PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES) return text;
319
319
  const marker = "\n… (previous attempt section truncated)";
320
- const room = PREVIOUS_ATTEMPT_MAX_BYTES - Buffer.byteLength(marker, "utf8") - 1;
320
+ const room = PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES - Buffer.byteLength(marker, "utf8") - 1;
321
321
  const cut = Buffer.from(text, "utf8").subarray(0, room).toString("utf8");
322
322
  return `${cut}${marker}`;
323
323
  }