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
@@ -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,112 @@ 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
+ /** @param {string} token @returns {boolean} */
283
+ function isNodeExecutable(token) {
284
+ const base = (token.split(/[\\/]/u).pop() ?? token).toLowerCase();
285
+ return base === "node" || base === "nodejs" || base === "node.exe";
286
+ }
287
+
288
+ /**
289
+ * @param {string} body
290
+ * @returns {string|null}
291
+ */
292
+ function scriptSyntaxError(body) {
293
+ try {
294
+ void new Script(body);
295
+ return null;
296
+ } catch (error) {
297
+ const message = errorMessage(error);
298
+ if (AWAIT_OUTSIDE_ASYNC.test(message)) {
299
+ try {
300
+ // The rest of the body still has to parse, so an unparsable tail is
301
+ // refused even when the head needed the async wrapper to parse at all.
302
+ void new Script(`(async () => {\n${body}\n})`);
303
+ return null;
304
+ } catch (wrapped) {
305
+ const wrappedMessage = errorMessage(wrapped);
306
+ return MODULE_SYNTAX.test(wrappedMessage) ? null : wrappedMessage;
307
+ }
308
+ }
309
+ // node's `-e` accepts static imports, exports and `import.meta`; no stable
310
+ // API parses a module here, so a module-only diagnostic is not a syntax
311
+ // error this check can prove and the body is allowed.
312
+ return MODULE_SYNTAX.test(message) ? null : message;
313
+ }
314
+ }
315
+
316
+ /** vm.Script's diagnostics for syntax node's `-e` module wrapper accepts. */
317
+ const AWAIT_OUTSIDE_ASYNC = /await is only valid in async functions|^Unexpected reserved word$/u;
318
+ const MODULE_SYNTAX = /Cannot use import statement outside a module|Cannot use 'import\.meta' outside a module|Unexpected token 'export'/u;
319
+ /** Shell tokens that end one command, so a later `-e` is not this node's. */
320
+ const SHELL_SEPARATORS = new Set(["&&", "||", "|", ";", "&", ">", ">>", "<"]);
321
+
213
322
  /** The node:test filter flags a `kind: "command"` proof's shell can split on an unquoted value. */
214
323
  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"|"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 : ".");
@@ -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,9 +26,9 @@ 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
- const RUNTIME_HARNESSES = new Set(["claude", "codex", "agy", "dsh", "zcode", "exec-jsonl", "replay"]);
31
+ const RUNTIME_HARNESSES = new Set(["claude", "codex", "agy", "dsh", "fx", "zcode", "exec-jsonl", "replay"]);
31
32
 
32
33
  /**
33
34
  * The strategies a routing rule may name: how a rule consumes its `prefer`
@@ -150,6 +151,9 @@ function validateRuntimeValues(runtime, label, executableRequired) {
150
151
  if (runtime.args !== undefined) requireStringArray(runtime.args, `${label}.args`);
151
152
  if (runtime.versionArgs !== undefined) requireStringArray(runtime.versionArgs, `${label}.versionArgs`);
152
153
  if (runtime.maxArgvPromptBytes !== undefined) positiveInteger(runtime.maxArgvPromptBytes, `${label}.maxArgvPromptBytes`);
154
+ // Names only, on the runtime descriptor directly or on the contract: either
155
+ // one asks the worker allowlist to carry the variable through by name.
156
+ if (runtime.envPassthrough !== undefined) validateEnvPassthrough(runtime.envPassthrough, `${label}.envPassthrough`);
153
157
  if (runtime.costRank !== undefined) nonNegativeNumber(runtime.costRank, `${label}.costRank`);
154
158
  if (runtime.stallTimeoutSec !== undefined) positiveNumber(runtime.stallTimeoutSec, `${label}.stallTimeoutSec`);
155
159
  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
  "",
@@ -25,6 +25,16 @@ const RESULT_LIMITS = Object.freeze({
25
25
  */
26
26
  export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
27
27
 
28
+ /**
29
+ * The named classification a `workspace-write` sandbox earns when a toolchain
30
+ * is refused a write outside the worktree. It is a classification of the
31
+ * failure, not a worker-result status: the controller derives it from the
32
+ * failed command's output (`src/engine/run-command.mjs`) and records it beside
33
+ * the attempt, carrying the mode and the denied path, so what the operator
34
+ * reads says "sandbox", not "filesystem".
35
+ */
36
+ export const SANDBOX_BLOCKED_WRITE = "sandbox_blocked_write";
37
+
28
38
  /**
29
39
  * A worker result that is well-formed JSON but breaks one of the byte
30
40
  * ceilings above. Typed, not a plain TypeError, so the repair prompt can name
@@ -40,6 +40,37 @@ export async function runtimeAssignments(contract) {
40
40
  (node.runtime === undefined && contract.runtimeDefaults?.worker === undefined)
41
41
  || (node.gate.enabled && node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined));
42
42
  const availability = needsComposition ? await discoverRuntimes(contract.runtimes, { cwd: contract.cwd }) : {};
43
+ return composeRuntimeAssignments(contract, availability);
44
+ }
45
+
46
+ /**
47
+ * Every declared runtime marked available, the catalogue a dry-run assignment
48
+ * reads. `validate` composes over this so a contract no live probe could
49
+ * rescue -- an omitted judge with no cross-vendor candidate, a same-vendor
50
+ * pair below the admitted tier, an exhausted judge list -- is refused at
51
+ * authoring time. Modeling unavailability here would make a contract's
52
+ * validity depend on the machine that happens to read it, when the point is
53
+ * to refuse what the launch would refuse whatever is up.
54
+ *
55
+ * @param {ValidatedContract} contract
56
+ * @returns {Record<string, RuntimeAvailability>}
57
+ */
58
+ export function allRuntimesAvailable(contract) {
59
+ return Object.fromEntries(Object.keys(contract.runtimes).map((id) => [id, { available: true, exhaustedUntil: null, reason: "ready" }]));
60
+ }
61
+
62
+ /**
63
+ * The launch-time assignment minus discovery: the same machine-config
64
+ * narrowing, judge-list selection, and `composeAssignments` call
65
+ * `runtimeAssignments` runs once the availability catalogue is in hand. Kept
66
+ * synchronous so `validate` can ask what a launch would assign without
67
+ * probing a provider.
68
+ *
69
+ * @param {ValidatedContract} contract
70
+ * @param {Record<string, RuntimeAvailability>} availability
71
+ * @returns {{assignments: Record<string, {worker: string, judge: string, composedWorker: boolean, composedJudge: boolean}>, decisions: Record<string, {worker: {strategy: string|null, reason: string}, judge: {strategy: string|null, reason: string}}>, availability: Record<string, RuntimeAvailability>, judgeListStates: Record<string, JudgeListState|undefined>}}
72
+ */
73
+ export function composeRuntimeAssignments(contract, availability) {
43
74
  const config = readUserConfig(process.env);
44
75
  const list = resolveJudgeList(contract, config);
45
76
  /** @type {Record<string, JudgeListState|undefined>} */
@@ -101,6 +132,20 @@ export async function runtimeAssignments(contract) {
101
132
  judgeListStates,
102
133
  };
103
134
  }
135
+
136
+ /**
137
+ * Throw the refusal a launch-time assignment would raise for this contract,
138
+ * with every runtime given as available. This is the check `faberun validate`
139
+ * runs (R35) so a contract the launch would refuse is refused at authoring
140
+ * time, before a run directory or a provider is touched. A persisted run's
141
+ * contract is never passed here: its assignment is already frozen.
142
+ *
143
+ * @param {ValidatedContract} contract
144
+ * @returns {void}
145
+ */
146
+ export function assertRuntimeAssignment(contract) {
147
+ composeRuntimeAssignments(contract, allRuntimesAvailable(contract));
148
+ }
104
149
  /**
105
150
  * @param {ValidatedContract} contract
106
151
  * @param {string} runDir
@@ -27,6 +27,8 @@ import { randomUUID } from "node:crypto";
27
27
  import { unlinkSync } from "node:fs";
28
28
  import { sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
29
29
  import { bootstrapAckPath, bootstrapAttemptPath, readJson } from "../run/store.mjs";
30
+ import { removeRunDirectory } from "../run/paths.mjs";
31
+ import { runNeverHeldNode } from "./run-identity.mjs";
30
32
  import { delay, errorCode } from "../util.mjs";
31
33
 
32
34
  /** How long a detached child waits for its launcher to acknowledge. */
@@ -99,3 +101,19 @@ export function cleanupBootstrapNonce(runDir, nonce) {
99
101
  }
100
102
  }
101
103
 
104
+ /**
105
+ * Remove a run directory that died before it could hold a node, so the next
106
+ * launch is not refused with `run already exists` because of evidence that
107
+ * never became a run. A directory that already holds a node is left exactly
108
+ * where it is: that evidence is what a resume reads. Returns whether the
109
+ * directory was removed.
110
+ *
111
+ * @param {string} runDir
112
+ * @returns {boolean}
113
+ */
114
+ export function discardNodeLessRunDirectory(runDir) {
115
+ if (!runNeverHeldNode(runDir)) return false;
116
+ removeRunDirectory(runDir);
117
+ return true;
118
+ }
119
+
@@ -33,6 +33,7 @@ import { dirname } from "node:path";
33
33
  import { spawn } from "node:child_process";
34
34
  import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
35
35
  import { killTarget, spawnInvocation } from "../host/platform.mjs";
36
+ import { baseEnvironment } from "./worker-env.mjs";
36
37
 
37
38
  /** @typedef {{executable: string, args: string[], cwd: string, promptTransport: "stdin"|"argv", harness: string, env: Record<string, string|null>|null, stdoutPath: string, stderrPath: string}} GateConfig */
38
39
 
@@ -164,10 +165,17 @@ function capLog(path, preservePrefix = false) {
164
165
 
165
166
  /** @returns {Record<string, string|undefined>} */
166
167
  function childEnv() {
167
- const merged = { ...process.env };
168
+ // The worker/judge allowlist. The base operating-system names come from this
169
+ // process; the runtime-specific names (adapter-declared, `*.env_key`, and
170
+ // every explicit envPassthrough) travelled here in the gate config because
171
+ // only the controller knows the runtime. A controller variable nothing named
172
+ // is absent from `config.env`, so it never reaches the provider. Never start
173
+ // from `{ ...process.env }`: that was the leak (an exported `AWS_*` or
174
+ // `GITHUB_TOKEN` handed to a model that runs arbitrary commands).
175
+ const merged = baseEnvironment(process.env);
168
176
  for (const [key, value] of Object.entries(config.env ?? {})) {
169
177
  if (value === null) delete merged[key];
170
- else merged[key] = value;
178
+ else if (value !== undefined) merged[key] = value;
171
179
  }
172
180
  // Worker providers are not a notification surface: strip every controller-only
173
181
  // transport after the harness overlay so no harness can reintroduce one. The