faberun 0.27.0 → 0.29.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 (83) hide show
  1. package/README.md +9 -3
  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 +31 -4
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/plan.mjs +56 -5
  18. package/src/cli/setup.mjs +5 -0
  19. package/src/cli/skills.mjs +18 -7
  20. package/src/cli/uninstall.mjs +542 -0
  21. package/src/cli/walkthrough.mjs +462 -0
  22. package/src/cli.mjs +72 -8
  23. package/src/contract/articles.mjs +30 -0
  24. package/src/contract/definition-of-done.mjs +164 -0
  25. package/src/contract/index.mjs +8 -5
  26. package/src/contract/judge-independence.mjs +18 -0
  27. package/src/contract/runtime.mjs +20 -6
  28. package/src/contract/scope-findings.mjs +120 -0
  29. package/src/contract/snapshot.mjs +19 -5
  30. package/src/contract/task-packet.mjs +49 -1
  31. package/src/contract/worker-result.mjs +37 -11
  32. package/src/engine/assignment.mjs +45 -0
  33. package/src/engine/detach.mjs +18 -0
  34. package/src/engine/gate.mjs +10 -2
  35. package/src/engine/judge-gate.mjs +24 -9
  36. package/src/engine/live-preflight.mjs +117 -18
  37. package/src/engine/prompts.mjs +58 -4
  38. package/src/engine/resume.mjs +119 -12
  39. package/src/engine/retry.mjs +11 -11
  40. package/src/engine/run-command.mjs +80 -4
  41. package/src/engine/run-identity.mjs +27 -14
  42. package/src/engine/runtime-discovery.mjs +15 -9
  43. package/src/engine/scheduler.mjs +8 -1
  44. package/src/engine/worker-env.mjs +179 -0
  45. package/src/harnesses/agy/index.mjs +11 -0
  46. package/src/harnesses/catalogue.mjs +29 -0
  47. package/src/harnesses/claude/index.mjs +98 -1
  48. package/src/harnesses/codex/index.mjs +15 -0
  49. package/src/harnesses/dsh/index.mjs +14 -0
  50. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  51. package/src/harnesses/fx/index.mjs +13 -0
  52. package/src/harnesses/index.mjs +21 -10
  53. package/src/harnesses/replay/index.mjs +12 -0
  54. package/src/harnesses/zcode/index.mjs +19 -0
  55. package/src/host/config.mjs +8 -2
  56. package/src/host/environment-report.mjs +85 -0
  57. package/src/host/home.mjs +27 -0
  58. package/src/host/install-registry.mjs +511 -0
  59. package/src/host/preflight.mjs +32 -9
  60. package/src/host/uninstall-roots.mjs +196 -0
  61. package/src/plan/freeze.mjs +2 -0
  62. package/src/plan/pipeline.mjs +56 -4
  63. package/src/plan/progress.mjs +101 -0
  64. package/src/plan/proof-check.mjs +27 -0
  65. package/src/plan/proof-scope.mjs +1 -1
  66. package/src/plan/repo-facts.mjs +129 -14
  67. package/src/plan/rounds.mjs +27 -0
  68. package/src/plan/spec.mjs +82 -6
  69. package/src/plan/template.mjs +9 -0
  70. package/src/repo/declared-paths.mjs +74 -6
  71. package/src/repo/integrate.mjs +1 -1
  72. package/src/repo/scope-closure.mjs +59 -0
  73. package/src/repo/signal-block.mjs +26 -0
  74. package/src/repo/signal.mjs +9 -24
  75. package/src/repo/workspace.mjs +189 -14
  76. package/src/repo/worktree-prune.mjs +13 -5
  77. package/src/repo/worktree.mjs +54 -0
  78. package/src/report/campaign-brief.mjs +4 -0
  79. package/src/report/message.mjs +37 -6
  80. package/src/report/progress.mjs +13 -2
  81. package/src/run/paths.mjs +14 -1
  82. package/src/run/usage.mjs +66 -1
  83. package/src/util.mjs +18 -2
@@ -1,4 +1,6 @@
1
1
  import { rejectUnknown, requireId, requireString } from "./assert.mjs";
2
+ import { errorMessage, shellWords } from "../util.mjs";
3
+ import { Script } from "node:vm";
2
4
  /**
3
5
  * Schema 2 Definition of Done items. Each item is an object that names an
4
6
  * observable outcome and declares how it is proven: mechanically through a
@@ -210,5 +212,167 @@ export function unquotedFilterValueWarnings(items, index) {
210
212
  return warnings;
211
213
  }
212
214
 
215
+ /**
216
+ * The body of an inline `node -e`/`node --eval` script named by a command
217
+ * proof's shell command, or null when the command carries none. The proof runs
218
+ * through a shell, so the body is the next shell word with its quotes removed:
219
+ * `shellWords` expands and escapes nothing, which is also how the shell hands
220
+ * the body to node. `-e` belongs to other commands too (`grep -e` the common
221
+ * one), so a scan only starts at a token that names the node binary.
222
+ *
223
+ * @param {string} commandText
224
+ * @returns {string|null}
225
+ */
226
+ export function inlineScriptBody(commandText) {
227
+ const tokens = shellWords(commandText);
228
+ for (let index = 0; index < tokens.length; index += 1) {
229
+ if (!isNodeExecutable(tokens[index])) continue;
230
+ for (let position = index + 1; position < tokens.length; position += 1) {
231
+ const token = tokens[position];
232
+ if (SHELL_SEPARATORS.has(token)) break;
233
+ if (token === "-e" || token === "--eval") {
234
+ const body = tokens[position + 1];
235
+ return body === undefined || SHELL_SEPARATORS.has(body) ? null : body;
236
+ }
237
+ if (token.startsWith("--eval=")) return token.slice("--eval=".length);
238
+ if (token.startsWith("-e=")) return token.slice("-e=".length);
239
+ }
240
+ }
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * The syntax error a `node -e`/`node --eval` body names, or null when the
246
+ * command is not an inline script or its body parses. `vm.Script` is node's
247
+ * own grammar for `-e`; the two shapes node's module wrapper accepts but a
248
+ * classic script does not -- top-level `await` and static ESM syntax -- are
249
+ * re-tried or allowed rather than refused, so a valid proof is never refused
250
+ * for a body this check cannot parse. A genuine error in the tail of an
251
+ * awaited body still surfaces through the async re-try.
252
+ *
253
+ * @param {string} commandText
254
+ * @returns {string|null}
255
+ */
256
+ export function inlineScriptParseError(commandText) {
257
+ const body = inlineScriptBody(commandText);
258
+ return body === null ? null : scriptSyntaxError(body);
259
+ }
260
+
261
+ /**
262
+ * Refuse a contract whose command proof is an inline script that does not
263
+ * parse, naming the node and the Definition of Done item. The launch calls
264
+ * this before the first dispatch: a body the parser rejects can never exit 0,
265
+ * so every attempt would be spent on work no delivery could satisfy.
266
+ *
267
+ * @param {{id?: unknown, definitionOfDone?: {id?: unknown, proof?: {kind?: unknown, ref?: unknown}}[]}[]} nodes
268
+ * @returns {void}
269
+ */
270
+ export function assertInlineScriptProofsParse(nodes) {
271
+ for (const node of nodes) {
272
+ for (const item of node.definitionOfDone ?? []) {
273
+ if (item.proof?.kind !== "command" || typeof item.proof.ref !== "string") continue;
274
+ const parseError = inlineScriptParseError(item.proof.ref);
275
+ if (parseError === null) continue;
276
+ const nodeName = node.id === undefined ? "a node" : `node ${node.id}`;
277
+ throw new TypeError(`${nodeName}, Definition of Done item "${item.id}": the proof command's inline script does not parse: ${parseError}`);
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * The quote a command proof's text leaves open, or null when every quote it
284
+ * opens is closed. `kind: "command"` goes through `/bin/sh -c`, so a text with
285
+ * an odd number of `'` or `"` is refused by the shell before the command runs,
286
+ * with `unexpected EOF while looking for matching`. Measured 2026-09-27 (AP13
287
+ * of safe-to-hand-to-friend): the freeze accepted
288
+ * `node --test --test-name-pattern=a stranger's first campaign completes
289
+ * offline test/…` — an apostrophe inside an unquoted test title — the node did
290
+ * the work and passed its own test in 15 s, and both attempts were spent on a
291
+ * command `/bin/sh` never ran.
292
+ *
293
+ * This is a quote scanner, not a shell parser: it reports the two failures a
294
+ * proof can carry without expanding anything (an open quote, or nothing at
295
+ * all), and leaves every other shell construct to the shell. A `$'…'` string
296
+ * and a quote inside a here-document are the constructs it does not model.
297
+ *
298
+ * @param {string} commandText
299
+ * @returns {string|null}
300
+ */
301
+ export function unclosedQuote(commandText) {
302
+ /** @type {string|null} */
303
+ let quote = null;
304
+ let escaped = false;
305
+ for (const character of commandText) {
306
+ if (escaped) { escaped = false; continue; }
307
+ // Outside double quotes a backslash escapes the next character; a
308
+ // backslash inside single quotes is literal, which POSIX also gives it.
309
+ if (quote !== "'" && character === "\\") { escaped = true; continue; }
310
+ if (quote === null && (character === "'" || character === '"')) { quote = character; continue; }
311
+ if (quote === character) quote = null;
312
+ }
313
+ return quote;
314
+ }
315
+
316
+ /**
317
+ * Refuse a contract whose command proof the shell cannot parse, naming the
318
+ * node, the item and the open quote. The freeze and the launch both call it:
319
+ * a command no attempt can run is the same waste as an inline script that
320
+ * cannot parse, and cheaper to refuse than to discover.
321
+ *
322
+ * @param {{id?: unknown, definitionOfDone?: {id?: unknown, proof?: {kind?: unknown, ref?: unknown}}[]}[]} nodes
323
+ * @returns {void}
324
+ */
325
+ export function assertProofCommandsParse(nodes) {
326
+ for (const node of nodes) {
327
+ for (const item of node.definitionOfDone ?? []) {
328
+ if (item.proof?.kind !== "command" || typeof item.proof.ref !== "string") continue;
329
+ const quote = unclosedQuote(item.proof.ref);
330
+ if (quote === null) continue;
331
+ const nodeName = node.id === undefined ? "a node" : `node ${node.id}`;
332
+ throw new TypeError(`${nodeName}, Definition of Done item "${item.id}": the proof command leaves a ${quote} open, so /bin/sh refuses it before it runs ("unexpected EOF while looking for matching \`${quote}'"). Quote the value, as in --test-name-pattern="<title>".`);
333
+ }
334
+ }
335
+ }
336
+
337
+ /** @param {string} token @returns {boolean} */
338
+ function isNodeExecutable(token) {
339
+ const base = (token.split(/[\\/]/u).pop() ?? token).toLowerCase();
340
+ return base === "node" || base === "nodejs" || base === "node.exe";
341
+ }
342
+
343
+ /**
344
+ * @param {string} body
345
+ * @returns {string|null}
346
+ */
347
+ function scriptSyntaxError(body) {
348
+ try {
349
+ void new Script(body);
350
+ return null;
351
+ } catch (error) {
352
+ const message = errorMessage(error);
353
+ if (AWAIT_OUTSIDE_ASYNC.test(message)) {
354
+ try {
355
+ // The rest of the body still has to parse, so an unparsable tail is
356
+ // refused even when the head needed the async wrapper to parse at all.
357
+ void new Script(`(async () => {\n${body}\n})`);
358
+ return null;
359
+ } catch (wrapped) {
360
+ const wrappedMessage = errorMessage(wrapped);
361
+ return MODULE_SYNTAX.test(wrappedMessage) ? null : wrappedMessage;
362
+ }
363
+ }
364
+ // node's `-e` accepts static imports, exports and `import.meta`; no stable
365
+ // API parses a module here, so a module-only diagnostic is not a syntax
366
+ // error this check can prove and the body is allowed.
367
+ return MODULE_SYNTAX.test(message) ? null : message;
368
+ }
369
+ }
370
+
371
+ /** vm.Script's diagnostics for syntax node's `-e` module wrapper accepts. */
372
+ const AWAIT_OUTSIDE_ASYNC = /await is only valid in async functions|^Unexpected reserved word$/u;
373
+ const MODULE_SYNTAX = /Cannot use import statement outside a module|Cannot use 'import\.meta' outside a module|Unexpected token 'export'/u;
374
+ /** Shell tokens that end one command, so a later `-e` is not this node's. */
375
+ const SHELL_SEPARATORS = new Set(["&&", "||", "|", ";", "&", ">", ">>", "<"]);
376
+
213
377
  /** The node:test filter flags a `kind: "command"` proof's shell can split on an unquoted value. */
214
378
  const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync, statSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
- import { loadTaskPacket, renderWorkerPrompt } from "./task-packet.mjs";
4
+ import { loadTaskPacket, renderWorkerPrompt, validateEnvPassthrough } from "./task-packet.mjs";
5
5
  import { RESERVED_ARTICLES } from "./articles.mjs";
6
6
  import { judgmentReasonWarnings, unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
7
7
  import { validateFinalVerification, validateSharedVerification } from "./final-verification.mjs";
@@ -37,7 +37,7 @@ const CONTRACT_FIELDS = new Set([
37
37
  "schemaVersion", "contractVersion", "id", "campaignId", "goal", "cwd", "sourceIdentity",
38
38
  "maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec", "maxTurns", "phaseSessionReuse",
39
39
  "runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "sharedVerification", "nodeAdvisory", "judges",
40
- "judgeIndependence",
40
+ "judgeIndependence", "envPassthrough",
41
41
  ]);
42
42
  const DEFAULTS_FIELDS = new Set(["worker", "judge"]);
43
43
  const NODE_FIELDS = new Set([
@@ -68,13 +68,13 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
68
68
 
69
69
  /** @typedef {{mode: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], readFiles: string[], writeFiles?: string[], writeRoots?: string[], symbols: string[], scopeAcknowledged?: string[], decisions: string[], nonGoals: string[], verification: VerificationCommand[]}} TaskPacket */
70
70
 
71
- /** @typedef {{harness: "claude"|"codex"|"agy"|"dsh"|"fx"|"zcode"|"exec-jsonl"|"replay", model: string, reasoning?: string, sandbox?: "read-only"|"workspace-write"|"danger-full-access", permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, costRank?: number, fallback?: string, vendor: string, tier?: number|string, stallTimeoutSec?: number, maxConcurrent?: number}} ValidatedRuntime */
71
+ /** @typedef {{harness: "claude"|"codex"|"agy"|"dsh"|"fx"|"zcode"|"exec-jsonl"|"replay", model: string, reasoning?: string, sandbox?: "read-only"|"workspace-write"|"danger-full-access", permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, costRank?: number, fallback?: string, vendor: string, tier?: number|string, stallTimeoutSec?: number, maxConcurrent?: number, envPassthrough?: string[]}} ValidatedRuntime */
72
72
 
73
73
  /** @typedef {{enabled: boolean, review?: ("none"|"advisory"|"blocking"), runtime?: string, failOn?: ("minor"|"major"|"critical")[], maxRevisions?: number, requiredCapabilities?: CapabilityRequirements, skipWhen?: {verificationGreen: true, maxChangedPaths: number}}} ValidatedGate */
74
74
 
75
75
  /** @typedef {{id: string, type: string, phase: string, requirementIds?: string[], runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, maxTurns?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never", sameProviderReview: boolean, humanStep?: {step: string, command: string}}} ValidatedNode */
76
76
 
77
- /** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, judges?: string[], judgeIndependence?: "same-vendor", nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
77
+ /** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, judges?: string[], judgeIndependence?: "same-vendor", nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, envPassthrough?: string[], warnings: string[]}} ValidatedContract */
78
78
  /** @typedef {{costUsd?: number, durationSec?: number}} NodeAdvisoryPolicy */
79
79
 
80
80
  /** @typedef {"pending"|"running"|"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"} NodeStatus */
@@ -134,6 +134,9 @@ export function validateContract(raw, contractPath, options = {}) {
134
134
  requireId(raw.id, "contract.id");
135
135
  requireId(raw.campaignId, "contract.campaignId");
136
136
  requireString(raw.goal, "contract.goal");
137
+ // The contract-wide passthrough, unioned with each runtime's own at spawn
138
+ // time. Absent keeps every contract authored before the field valid.
139
+ if (raw.envPassthrough !== undefined) validateEnvPassthrough(raw.envPassthrough, "contract.envPassthrough");
137
140
 
138
141
  const contractDir = dirname(resolve(contractPath));
139
142
  const cwd = resolve(contractDir, typeof raw.cwd === "string" ? raw.cwd : ".");
@@ -150,7 +153,7 @@ export function validateContract(raw, contractPath, options = {}) {
150
153
  throw new TypeError("contract.runtimes must be an object");
151
154
  }
152
155
  const runtimes = /** @type {Record<string, ValidatedRuntime>} */ ({});
153
- for (const [id, runtime] of Object.entries(rawRuntimes)) runtimes[id] = validateRuntime(id, runtime);
156
+ for (const [id, runtime] of Object.entries(rawRuntimes)) runtimes[id] = validateRuntime(id, runtime, { persisted });
154
157
  // A runtime's fallback is validated against sibling runtimes once every
155
158
  // runtime is known, so declaration order never matters.
156
159
  for (const [id, runtime] of Object.entries(runtimes)) {
@@ -25,6 +25,24 @@ export function validateJudgeIndependence(value, label) {
25
25
  return value;
26
26
  }
27
27
 
28
+ /**
29
+ * The `judgeIndependence` in force for a launch (R36): the contract's own
30
+ * declaration wins, and a contract that declares none inherits the machine
31
+ * config's opt-in. Both sources are already validated to the single accepted
32
+ * value before they reach here -- the contract by `validateJudgeIndependence`,
33
+ * the config by `isUserConfig` -- so this is a plain precedence read, not a
34
+ * second validation. A value neither source names (or a malformed raw config a
35
+ * test hands in) reads as no opt-in, exactly as an absent field would.
36
+ *
37
+ * @param {{judgeIndependence?: string}|null|undefined} contract
38
+ * @param {{judgeIndependence?: string}|null|undefined} config
39
+ * @returns {"same-vendor"|undefined}
40
+ */
41
+ export function effectiveJudgeIndependence(contract, config) {
42
+ const mode = contract?.judgeIndependence ?? config?.judgeIndependence;
43
+ return mode === SAME_VENDOR_REVIEW_MODE ? SAME_VENDOR_REVIEW_MODE : undefined;
44
+ }
45
+
28
46
  /**
29
47
  * Same-vendor mode's own admissibility rule for a worker/judge pair that
30
48
  * share a vendor: a judge model absent from the declared tier table can never
@@ -10,6 +10,7 @@
10
10
  * validator to reach it.
11
11
  */
12
12
  import { assertObject, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString, requireStringArray, requireTimestamp } from "./assert.mjs";
13
+ import { validateEnvPassthrough } from "./task-packet.mjs";
13
14
  import { composeAssignments } from "../engine/runtime-discovery.mjs";
14
15
  import { harnessCapabilities, readOnlyModeOf, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
15
16
  import { canonicalProvider } from "./provider.mjs";
@@ -25,7 +26,7 @@ import { stableJson } from "../util.mjs";
25
26
  const RUNTIME_FIELDS = new Set([
26
27
  "harness", "model", "reasoning", "sandbox", "permissionMode", "config", "printTimeout", "tools",
27
28
  "executable", "args", "versionArgs", "maxArgvPromptBytes", "requiredCapabilities", "costRank",
28
- "fallback", "vendor", "tier", "pricing", "stallTimeoutSec", "maxConcurrent",
29
+ "fallback", "vendor", "tier", "pricing", "stallTimeoutSec", "maxConcurrent", "envPassthrough",
29
30
  ]);
30
31
  const RUNTIME_HARNESSES = new Set(["claude", "codex", "agy", "dsh", "fx", "zcode", "exec-jsonl", "replay"]);
31
32
 
@@ -92,9 +93,11 @@ export function routeRuntime(contract, node, role = "worker", event = {}) {
92
93
  /**
93
94
  * @param {string} id
94
95
  * @param {unknown} runtime
96
+ * @param {{persisted?: boolean}} [options] `persisted` is a replay of a contract
97
+ * already accepted at launch; see the vendor rule below.
95
98
  * @returns {ValidatedRuntime}
96
99
  */
97
- export function validateRuntime(id, runtime) {
100
+ export function validateRuntime(id, runtime, options = {}) {
98
101
  requireId(id, `runtime ${id}`);
99
102
  assertObject(runtime, `runtime ${id}`);
100
103
  rejectUnknown(runtime, RUNTIME_FIELDS, `runtime ${id}`);
@@ -102,16 +105,24 @@ export function validateRuntime(id, runtime) {
102
105
  const typedRuntime = /** @type {{harness: string, model: string, vendor?: string, config?: Record<string, unknown>}} */ (runtime);
103
106
  // The canonical provider derived from the route, the model's family, or the
104
107
  // harness default outranks a declared `vendor`: a label that contradicts it
105
- // is refused (R18), and the runtime's vendor becomes the derived provider
106
- // rather than whatever text the contract declared. `replay` and `exec-jsonl`
107
- // derive none — for those the declared vendor stands, as it always has.
108
+ // is refused (R18) at authoring, and the runtime's vendor becomes the derived
109
+ // provider rather than whatever text the contract declared. `replay` and
110
+ // `exec-jsonl` derive none — for those the declared vendor stands, as it
111
+ // always has. A persisted load takes the derived provider without refusing,
112
+ // so a run launched before R18 stays readable and cancelable.
108
113
  const derived = canonicalProvider(typedRuntime);
109
114
  const declared = typeof typedRuntime.vendor === "string" && typedRuntime.vendor.length ? typedRuntime.vendor : undefined;
110
115
  let vendor;
111
116
  if (derived) {
112
- if (declared !== undefined && declared !== derived) {
117
+ if (declared !== undefined && declared !== derived && options.persisted !== true) {
113
118
  throw new TypeError(`runtime ${id} declares vendor ${declared} but ${typedRuntime.harness} ${typedRuntime.model} is provider ${derived}`);
114
119
  }
120
+ // A replay canonicalizes a contradicting label instead of refusing it.
121
+ // Measured 2026-09-27 (AP6): three runs launched before R18 carried
122
+ // `vendor: anthropic-sonnet` against the model claude-sonnet-5, and every
123
+ // reader of their persisted contract — `cancel`, `status`, `report` — threw
124
+ // on a rule those contracts predate, leaving the runs unsuspended forever.
125
+ // The rule still refuses the label where it can be acted on: at authoring.
115
126
  vendor = derived;
116
127
  } else {
117
128
  vendor = resolveVendor(typedRuntime);
@@ -150,6 +161,9 @@ function validateRuntimeValues(runtime, label, executableRequired) {
150
161
  if (runtime.args !== undefined) requireStringArray(runtime.args, `${label}.args`);
151
162
  if (runtime.versionArgs !== undefined) requireStringArray(runtime.versionArgs, `${label}.versionArgs`);
152
163
  if (runtime.maxArgvPromptBytes !== undefined) positiveInteger(runtime.maxArgvPromptBytes, `${label}.maxArgvPromptBytes`);
164
+ // Names only, on the runtime descriptor directly or on the contract: either
165
+ // one asks the worker allowlist to carry the variable through by name.
166
+ if (runtime.envPassthrough !== undefined) validateEnvPassthrough(runtime.envPassthrough, `${label}.envPassthrough`);
153
167
  if (runtime.costRank !== undefined) nonNegativeNumber(runtime.costRank, `${label}.costRank`);
154
168
  if (runtime.stallTimeoutSec !== undefined) positiveNumber(runtime.stallTimeoutSec, `${label}.stallTimeoutSec`);
155
169
  if (runtime.pricing !== undefined) validatePricing(runtime.pricing, `${label}.pricing`);
@@ -87,3 +87,123 @@ export function verificationFailureWithScope(verdict, scope) {
87
87
  }],
88
88
  };
89
89
  }
90
+
91
+ /** The additions a reauthor discovery pass may propose to a refused packet. */
92
+ /** @typedef {{readFiles?: string[], writeFiles?: string[], scopeAcknowledged?: string[], symbols?: string[]}} ReauthorAdditions */
93
+
94
+ /** The bounded journal record a reauthor attempt leaves behind. */
95
+ export const MAX_REAUTHOR_PATHS = 64;
96
+
97
+ /**
98
+ * A widened packet may not take over a write another node's packet already
99
+ * owns. `validateContract` catches a packet that does not close its own scope
100
+ * and a pair whose test one node cannot touch, but two nodes declaring the
101
+ * same write are each individually closed, so no per-packet check sees the
102
+ * collision. This names the owner the discovery pass must leave alone.
103
+ *
104
+ * @param {import("../contract/index.mjs").ValidatedNode[]} nodes every node of the contract
105
+ * @param {string} nodeId the node being widened
106
+ * @param {string[]|undefined} addedWriteFiles the writes the discovery pass proposes to add
107
+ * @returns {{path: string, nodeId: string, reason: string}[]}
108
+ */
109
+ export function reauthorWriteConflicts(nodes, nodeId, addedWriteFiles) {
110
+ const added = new Set(addedWriteFiles ?? []);
111
+ if (added.size === 0) return [];
112
+ /** @type {{path: string, nodeId: string, reason: string}[]} */
113
+ const conflicts = [];
114
+ for (const node of nodes) {
115
+ if (node.id === nodeId) continue;
116
+ for (const path of node.taskPacket.writeFiles ?? []) {
117
+ if (!added.has(path)) continue;
118
+ conflicts.push({
119
+ path,
120
+ nodeId: node.id,
121
+ reason: `${path} is already declared in writeFiles by node ${node.id}; a widened packet may not take over another node's write`,
122
+ });
123
+ }
124
+ }
125
+ return conflicts;
126
+ }
127
+
128
+ /**
129
+ * The bounded journal record a reauthor attempt leaves: the widened packet the
130
+ * discovery pass proposed (`reauthorProposal`) and the hard budget it spent
131
+ * (`reauthorRounds`). It lives in `reauthor.jsonl` beside the run's events
132
+ * rather than on the node snapshot, so a proposal never moves the authored
133
+ * packet hash or asks the snapshot schema to carry a transient shape.
134
+ *
135
+ * @param {{
136
+ * nodeId: string,
137
+ * outcome: "applied"|"approval_required"|"rounds_exhausted",
138
+ * riskTier: string,
139
+ * budget: number,
140
+ * roundsUsed: number,
141
+ * additions: ReauthorAdditions|null|undefined,
142
+ * packet: import("./task-packet.mjs").TaskPacket|null|undefined,
143
+ * findings: string[]|null|undefined,
144
+ * history: {round: number, accepted: boolean, additions?: ReauthorAdditions, findings?: string[]}[],
145
+ * }} entry
146
+ * @returns {Record<string, unknown>}
147
+ */
148
+ export function reauthorProposalRecord({ nodeId, outcome, riskTier, budget, roundsUsed, additions, packet, findings, history }) {
149
+ /** @param {string[]|undefined} paths @returns {string[]} */
150
+ const bounded = (paths) => [...new Set(paths ?? [])].slice(0, MAX_REAUTHOR_PATHS);
151
+ return {
152
+ at: new Date().toISOString(),
153
+ node: nodeId,
154
+ outcome,
155
+ riskTier,
156
+ reauthorProposal: {
157
+ packet: packet ?? null,
158
+ addedReadFiles: bounded(additions?.readFiles),
159
+ addedWriteFiles: bounded(additions?.writeFiles),
160
+ addedScopeAcknowledged: bounded(additions?.scopeAcknowledged),
161
+ findings: (findings ?? []).slice(0, 8),
162
+ },
163
+ reauthorRounds: {
164
+ budget,
165
+ used: roundsUsed,
166
+ exhausted: outcome === "rounds_exhausted",
167
+ history: history.map((item) => ({
168
+ round: item.round,
169
+ accepted: item.accepted,
170
+ addedReadFiles: bounded(item.additions?.readFiles),
171
+ addedWriteFiles: bounded(item.additions?.writeFiles),
172
+ findings: (item.findings ?? []).slice(0, 8),
173
+ })),
174
+ },
175
+ };
176
+ }
177
+
178
+ /**
179
+ * The risk tier a frozen node's gate encodes: a disabled gate is low risk, a
180
+ * blocking review is high, an advisory review is standard. A contract does not
181
+ * carry the plan's own riskTier, so the gate it froze into is the durable
182
+ * statement of how much a widening of its packet is trusted.
183
+ *
184
+ * @param {import("../contract/index.mjs").ValidatedNode} node
185
+ * @returns {"low"|"standard"|"high"}
186
+ */
187
+ export function reauthorRiskTier(node) {
188
+ if (!node.gate.enabled) return "low";
189
+ return node.gate.review === "blocking" ? "high" : "standard";
190
+ }
191
+
192
+ /**
193
+ * Whether a validated widening may be applied without an explicit operator
194
+ * yes. Mirrors the planning pipeline's `--approve-below`: `high` approves
195
+ * everything, `none` approves nothing, and the default `standard` approves
196
+ * everything but a high-risk node. `approve` overrides the threshold.
197
+ *
198
+ * @param {import("../contract/index.mjs").ValidatedNode} node
199
+ * @param {{approve?: boolean, approveBelow?: string}} [options]
200
+ * @returns {boolean}
201
+ */
202
+ export function reauthorApproved(node, options = {}) {
203
+ if (options.approve === true) return true;
204
+ const approveBelow = options.approveBelow ?? "standard";
205
+ if (approveBelow === "high") return true;
206
+ if (approveBelow === "none") return false;
207
+ if (approveBelow !== "standard") throw new TypeError(`approveBelow must be one of standard, high, none: ${approveBelow}`);
208
+ return reauthorRiskTier(node) !== "high";
209
+ }
@@ -11,6 +11,7 @@
11
11
  import { Buffer } from "node:buffer";
12
12
  import { MAX_SCOPE_FINDING_PATHS } from "./scope-findings.mjs";
13
13
  import { REVIEW_MODES } from "./review-modes.mjs";
14
+ import { SAME_VENDOR_REVIEW_MODE } from "./judge-independence.mjs";
14
15
  import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireInteger, requirePacketHash, requireString, requireTimestamp } from "./assert.mjs";
15
16
  import { stableJson } from "../util.mjs";
16
17
  import { isAbsolute } from "node:path";
@@ -26,11 +27,17 @@ import { validateWorkerResult } from "./worker-result.mjs";
26
27
  /** @typedef {import("./index.mjs").RunMetadata} RunMetadata */
27
28
  /** @typedef {import("./index.mjs").ValidatedNode} ValidatedNode */
28
29
 
30
+ /** Hard byte ceiling for an operator's answer text, enforced where it is accepted. */
31
+ export const OPERATOR_ANSWER_MAX_BYTES = 8 * 1024;
32
+
33
+ /** Hard ceiling for the failure evidence of a `Previous attempt` section, in bytes. */
34
+ export const PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES = 8 * 1024;
35
+
29
36
  /**
30
- * Hard byte ceiling for an operator's answer text, matching the bounded
31
- * `Previous attempt` section a retry in place already enforces (retry.mjs).
37
+ * The whole persisted `Previous attempt` section: the bounded evidence, then
38
+ * the operator's answer carried whole under its heading (engine/retry.mjs).
32
39
  */
33
- export const OPERATOR_ANSWER_MAX_BYTES = 8 * 1024;
40
+ const PREVIOUS_ATTEMPT_MAX_BYTES = PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES + OPERATOR_ANSWER_MAX_BYTES + 64;
34
41
 
35
42
  const GATE_VERDICTS = new Set(["pass", "fail", "invalid_judge_output"]);
36
43
  const NODE_STATUSES = new Set(["pending", "running", "done", "no-op", "blocked", "failed", "exhausted", "stalled", "canceled"]);
@@ -203,7 +210,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
203
210
  if (typeof value.previousAttempt !== "string" || !value.previousAttempt.trim()) {
204
211
  throw new TypeError("node snapshot.previousAttempt must be a non-empty string");
205
212
  }
206
- if (Buffer.byteLength(value.previousAttempt, "utf8") > 8 * 1024) throw new TypeError("node snapshot.previousAttempt exceeds 8192 bytes");
213
+ if (Buffer.byteLength(value.previousAttempt, "utf8") > PREVIOUS_ATTEMPT_MAX_BYTES) throw new TypeError(`node snapshot.previousAttempt exceeds ${PREVIOUS_ATTEMPT_MAX_BYTES} bytes`);
207
214
  }
208
215
  if (expectedNode) validateSnapshotBinding(value, expectedNode);
209
216
  if (Buffer.byteLength(JSON.stringify(value), "utf8") > 128 * 1024) throw new TypeError("node snapshot exceeds 131072 bytes");
@@ -508,12 +515,19 @@ function validateRoutingState(value, label) {
508
515
  if (value.judgeList !== undefined) validateJudgeListState(value.judgeList, `${label}.judgeList`);
509
516
  if (value.assignments !== undefined) {
510
517
  assertObject(value.assignments, `${label}.assignments`);
511
- rejectUnknown(value.assignments, new Set(["worker", "judge", "composedWorker", "composedJudge"]), `${label}.assignments`);
518
+ rejectUnknown(value.assignments, new Set(["worker", "judge", "composedWorker", "composedJudge", "judgeIndependence"]), `${label}.assignments`);
512
519
  requireId(value.assignments.worker, `${label}.assignments.worker`);
513
520
  requireId(value.assignments.judge, `${label}.assignments.judge`);
514
521
  for (const key of ["composedWorker", "composedJudge"]) {
515
522
  if (value.assignments[key] !== undefined && typeof value.assignments[key] !== "boolean") throw new TypeError(`${label}.assignments.${key} must be boolean`);
516
523
  }
524
+ // R36: the effective judge-independence mode the launch composed under,
525
+ // recorded so a resume, brief or report reads the decision back. Only the
526
+ // one accepted mode may be persisted; its absence is the cross-vendor
527
+ // default.
528
+ if (value.assignments.judgeIndependence !== undefined && value.assignments.judgeIndependence !== SAME_VENDOR_REVIEW_MODE) {
529
+ throw new TypeError(`${label}.assignments.judgeIndependence must be "${SAME_VENDOR_REVIEW_MODE}"`);
530
+ }
517
531
  }
518
532
  if (value.availability !== undefined) {
519
533
  assertObject(value.availability, `${label}.availability`);
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "nod
2
2
  import { Buffer } from "node:buffer";
3
3
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { validateVerificationCommands } from "./verification.mjs";
5
+ import { RESERVED_OWNER_DECISIONS, uncoveredReservedOwnerDecisions } from "./articles.mjs";
5
6
  import { errorCode } from "../util.mjs";
6
7
  import { requireString, requireStringArray } from "./assert.mjs";
7
8
 
@@ -101,6 +102,25 @@ export function loadTaskPacket(node, contractDir, cwd, index, options = {}) {
101
102
  return validateTaskPacket(packet, index, cwd, options);
102
103
  }
103
104
 
105
+ /**
106
+ * The `## Reserved owner decisions` section every worker prompt carries: the
107
+ * same list the judge reads, with the packet's own coverage spelled out, so an
108
+ * uncovered owner decision is refused with `blocked_context` instead of being
109
+ * chosen by a worker.
110
+ *
111
+ * @param {string[]} decisions
112
+ * @returns {string[]}
113
+ */
114
+ function reservedOwnerDecisionsLines(decisions) {
115
+ const uncovered = uncoveredReservedOwnerDecisions(decisions);
116
+ return [
117
+ "## Reserved owner decisions",
118
+ `These decisions belong to the repository owner, never to this node: ${RESERVED_OWNER_DECISIONS.join(", ")}.`,
119
+ `Reserved decisions with no coverage in "Decisions already made": ${uncovered.length ? uncovered.join(", ") : "(none)"}.`,
120
+ "If the task requires taking an uncovered decision, do not choose it: return the blocked_context worker-result object naming the decision.",
121
+ ];
122
+ }
123
+
104
124
  /**
105
125
  * @param {TaskPacket} packet
106
126
  * @param {string} nodeId
@@ -133,6 +153,8 @@ export function renderWorkerPrompt(packet, nodeId) {
133
153
  "## Decisions already made",
134
154
  ...bulletOrNone(packet.decisions),
135
155
  "",
156
+ ...reservedOwnerDecisionsLines(packet.decisions),
157
+ "",
136
158
  "## Non-goals",
137
159
  ...bulletOrNone(packet.nonGoals),
138
160
  "",
@@ -266,6 +288,28 @@ export function validateTaskPacket(packet, index, cwd, options = {}) {
266
288
  };
267
289
  }
268
290
 
291
+ /**
292
+ * The environment-variable names an operator allows through to a worker, the
293
+ * one validator both the runtime descriptor and the contract share. The list
294
+ * is names only -- never values -- so it selects from the controller's own
295
+ * environment without carrying a secret of its own. A name is the portable
296
+ * shell shape: a letter or underscore, then letters, digits, or underscores.
297
+ *
298
+ * @param {unknown} value
299
+ * @param {string} label
300
+ * @returns {string[]}
301
+ */
302
+ export function validateEnvPassthrough(value, label) {
303
+ if (!Array.isArray(value)) throw new TypeError(`${label} must be an array of environment-variable names`);
304
+ const names = /** @type {unknown[]} */ (value);
305
+ names.forEach((name, index) => {
306
+ if (typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) {
307
+ throw new TypeError(`${label}[${index}] must be an environment-variable name`);
308
+ }
309
+ });
310
+ return /** @type {string[]} */ (names);
311
+ }
312
+
269
313
  /**
270
314
  * @param {string} path
271
315
  * @param {string} label
@@ -456,6 +500,8 @@ function renderDiscoveryPrompt(packet, nodeId) {
456
500
  "## Decisions already made",
457
501
  ...bulletOrNone(packet.decisions),
458
502
  "",
503
+ ...reservedOwnerDecisionsLines(packet.decisions),
504
+ "",
459
505
  "## Non-goals",
460
506
  ...bulletOrNone(packet.nonGoals),
461
507
  "",
@@ -484,7 +530,7 @@ function renderAutonomousPrompt(packet, nodeId) {
484
530
  packet.objective,
485
531
  "",
486
532
  "## Closed context",
487
- "This autonomous context may inspect the repository read-only as needed. Edit only files beneath the listed write roots, and do not write outside those directory boundaries. If required context is unavailable, return the blocked_context worker-result object below.",
533
+ "This autonomous context may inspect the repository read-only as needed. The write roots below are the expected boundary, not a hard wall: a write outside them is allowed when the task requires it, and the result must name the path and the reason. Return the blocked_context worker-result object below only for context that is genuinely missing.",
488
534
  "",
489
535
  "## Instructions",
490
536
  ...numbered(packet.instructions),
@@ -501,6 +547,8 @@ function renderAutonomousPrompt(packet, nodeId) {
501
547
  "## Decisions already made",
502
548
  ...bulletOrNone(packet.decisions),
503
549
  "",
550
+ ...reservedOwnerDecisionsLines(packet.decisions),
551
+ "",
504
552
  "## Non-goals",
505
553
  ...bulletOrNone(packet.nonGoals),
506
554
  "",