faberun 0.18.0 → 0.19.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 (40) hide show
  1. package/integrations/claude-code/statusline.sh +12 -2
  2. package/package.json +2 -2
  3. package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
  4. package/src/campaign/chain.mjs +5 -4
  5. package/src/cli/launch.mjs +10 -1
  6. package/src/cli.mjs +10 -4
  7. package/src/contract/index.mjs +16 -3
  8. package/src/contract/task-packet.mjs +13 -1
  9. package/src/engine/bulk-read.mjs +7 -1
  10. package/src/engine/gate.mjs +17 -3
  11. package/src/engine/judge-gate.mjs +2 -2
  12. package/src/engine/live-preflight.mjs +43 -10
  13. package/src/engine/live-silence.mjs +49 -0
  14. package/src/engine/process-identity.mjs +12 -1
  15. package/src/engine/process.mjs +2 -1
  16. package/src/engine/run-command.mjs +8 -5
  17. package/src/engine/run-identity.mjs +213 -14
  18. package/src/engine/runtime-discovery.mjs +26 -5
  19. package/src/engine/scheduler.mjs +1 -1
  20. package/src/engine/supervise.mjs +2 -1
  21. package/src/harnesses/catalogue.mjs +8 -1
  22. package/src/harnesses/dsh/runner.mjs +6 -1
  23. package/src/harnesses/index.mjs +35 -7
  24. package/src/host/platform.mjs +204 -1
  25. package/src/host/preflight.mjs +137 -5
  26. package/src/notify/index.mjs +7 -1
  27. package/src/notify/session.mjs +6 -1
  28. package/src/plan/pipeline.mjs +5 -1
  29. package/src/plan/preflight.mjs +77 -0
  30. package/src/repo/declared-paths.mjs +87 -6
  31. package/src/repo/signal.mjs +56 -21
  32. package/src/repo/workspace.mjs +3 -2
  33. package/src/repo/worktree.mjs +2 -1
  34. package/src/report/next.mjs +15 -1
  35. package/src/run/availability.mjs +138 -0
  36. package/src/run/disk-gc.mjs +16 -2
  37. package/src/run/lock.mjs +8 -0
  38. package/src/run/paths.mjs +13 -0
  39. package/src/seat/allowance.mjs +4 -1
  40. package/src/seat/tmux.mjs +9 -1
@@ -31,6 +31,15 @@ ALLOWANCE_WARN_PCT=85
31
31
 
32
32
  session=$(cat)
33
33
  repo=$(printf '%s' "$session" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p;s/.*"current_dir":"\([^"]*\)".*/\1/p' | head -n 1)
34
+ # A Windows session names its cwd with backslashes, and JSON doubles them.
35
+ # Three forms of one path, because three readers want different ones:
36
+ # repo_json as it appears in the file -- what a fixed-string grep matches
37
+ # repo the path itself -- what jq compares a parsed key to
38
+ # repo_path with forward slashes -- what a POSIX shell can stat
39
+ # On a host with no backslashes in its paths all three are the same string.
40
+ repo_json=$repo
41
+ repo=$(printf '%s' "$repo_json" | sed 's|\\\\|\\|g')
42
+ repo_path=$(printf '%s' "$repo" | tr '\\' '/')
34
43
  # The pointer follows the state (R2): the project registry under the faberun
35
44
  # home maps the repository's resolved path to an opaque id, and the pointer
36
45
  # sits in that project's runs directory. A repository whose runs never moved
@@ -40,6 +49,7 @@ repo=$(printf '%s' "$session" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p;s/.*"curren
40
49
  # no glob, no newest-by-mtime. That is the half of R6 already true and to
41
50
  # keep; the one machine-wide pointer is a later phase, not this lookup.
42
51
  home=${FABERUN_HOME:-$HOME/.faberun}
52
+ home=$(printf '%s' "$home" | tr '\\' '/')
43
53
  index="$home/projects/index.json"
44
54
  id=
45
55
  if [ -n "$repo" ] && [ -f "$index" ]; then
@@ -50,10 +60,10 @@ if [ -n "$repo" ] && [ -f "$index" ]; then
50
60
  # fixed-string grep for the quoted key picks exactly that one line and
51
61
  # the id follows its colon. Fixed-string on purpose: a repo path is
52
62
  # data, not a regex.
53
- id=$(grep -F "\"$repo\"" "$index" 2>/dev/null | sed -n 's/^.*:[[:space:]]*"\([^"]*\)".*/\1/p')
63
+ id=$(grep -F "\"$repo_json\"" "$index" 2>/dev/null | sed -n 's/^.*:[[:space:]]*"\([^"]*\)".*/\1/p')
54
64
  fi
55
65
  fi
56
- pointer="$repo/.runs/status.json"
66
+ pointer="$repo_path/.runs/status.json"
57
67
  if [ -n "$id" ] && [ -d "$home/projects/$id/runs" ]; then
58
68
  pointer="$home/projects/$id/runs/status.json"
59
69
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
28
28
  "typecheck": "tsc",
29
- "test": "node --test --import ./test/scoped-home.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
29
+ "test": "node --test --import ./test/scoped-home.mjs --import ./test/git-env.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
30
30
  "docs": "node src/cli/manual.mjs --write",
31
31
  "docs:check": "node src/cli/manual.mjs --check",
32
32
  "prepare": "husky"
@@ -95,9 +95,17 @@ for f in CLAUDE.md GEMINI.md CURSOR.md AGENT.md; do
95
95
  fi
96
96
  if [ "$DRY_RUN" = 0 ]; then
97
97
  mkdir -p "$(dirname "$link")"
98
- ( cd "$(dirname "$link")" && rm -f "$(basename "$f")" && ln -s "$dest" "$(basename "$f")" )
98
+ # MSYS's `ln -s` copies the file instead of linking it unless told
99
+ # otherwise, which is exactly how a pointer becomes the real file this
100
+ # script exists to prevent. `winsymlinks:nativestrict` asks for a real
101
+ # symlink and fails rather than copying when the host cannot make one
102
+ # (no Developer Mode, no privilege); the plain `ln -s` after it is that
103
+ # host's honest fallback, and a no-op variable everywhere else.
104
+ ( cd "$(dirname "$link")" && rm -f "$(basename "$f")" \
105
+ && { MSYS=winsymlinks:nativestrict ln -s "$dest" "$(basename "$f")" 2>/dev/null \
106
+ || ln -s "$dest" "$(basename "$f")"; } )
99
107
  fi
100
- say " + $f → $dest" "$G"
108
+ if [ -L "$link" ]; then say " + $f → $dest" "$G"; else say " + $f (copy of $dest; this host makes no symlink)" "$Y"; fi
101
109
  done
102
110
 
103
111
  # --- git hook via core.hooksPath --------------------------------------------
@@ -42,6 +42,7 @@ import { pidAlive, processStartToken } from "../run/lock.mjs";
42
42
  import { delay, errorCode, errorMessage } from "../util.mjs";
43
43
  import { writeJsonAtomic } from "../run/store.mjs";
44
44
  import { runDirectory } from "../run/paths.mjs";
45
+ import { gitArguments, killTarget } from "../host/platform.mjs";
45
46
 
46
47
  /** @typedef {import("../contract/index.mjs").ControllerIdentity} ControllerIdentity */
47
48
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
@@ -140,7 +141,7 @@ function groupKill(pid, signal) {
140
141
  if (errorCode(error) !== "ESRCH") throw error;
141
142
  }
142
143
  }
143
- process.kill(pid, signal);
144
+ killTarget(pid, signal);
144
145
  } catch (error) {
145
146
  if (errorCode(error) !== "ESRCH") throw error;
146
147
  }
@@ -235,7 +236,7 @@ function landBranchRef(repo, landBranch) {
235
236
  */
236
237
  function gitHead(repo, ref) {
237
238
  try {
238
- return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
239
+ return execFileSync("git", gitArguments(["-C", repo, "rev-parse", ref]), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
239
240
  } catch {
240
241
  return null;
241
242
  }
@@ -269,7 +270,7 @@ export function validateContractAgainstRef(raw, contractPath, context = {}) {
269
270
  const originalCwd = resolve(dirname(contractPath), typeof raw.cwd === "string" ? raw.cwd : ".");
270
271
  const worktree = mkdtempSync(join(tmpdir(), "runner-chain-ref-"));
271
272
  try {
272
- execFileSync("git", ["-C", repo, "worktree", "add", "--detach", worktree, baseRef], { stdio: ["ignore", "pipe", "pipe"] });
273
+ execFileSync("git", gitArguments(["-C", repo, "worktree", "add", "--detach", worktree, baseRef]), { stdio: ["ignore", "pipe", "pipe"] });
273
274
  const relativedCwd = relative(repo, originalCwd);
274
275
  const mappedCwd = relativedCwd && !relativedCwd.startsWith("..") ? join(worktree, relativedCwd) : worktree;
275
276
  const relativeContract = relative(repo, contractPath);
@@ -283,7 +284,7 @@ export function validateContractAgainstRef(raw, contractPath, context = {}) {
283
284
  return contract;
284
285
  } finally {
285
286
  try {
286
- execFileSync("git", ["-C", repo, "worktree", "remove", "--force", worktree], { stdio: ["ignore", "pipe", "ignore"] });
287
+ execFileSync("git", gitArguments(["-C", repo, "worktree", "remove", "--force", worktree]), { stdio: ["ignore", "pipe", "ignore"] });
287
288
  } catch {
288
289
  // The ref-based validation is done; a cleanup failure must not mask it.
289
290
  }
@@ -64,7 +64,16 @@ export function detachArgv(argv, options = {}) {
64
64
  const child = /** @type {DetachedChild} */ (spawn(process.execPath, [CLI_ENTRY, ...argv], {
65
65
  cwd: process.cwd(),
66
66
  env: { ...process.env, ...options.env, FABERUN_BOOTSTRAP_NONCE: nonce },
67
- detached: process.platform !== "win32",
67
+ // Detached on every platform, for a different reason on each. POSIX: a
68
+ // new session, so the controller survives the launcher's terminal and
69
+ // owns a process group its own children can be killed by. Windows: libuv
70
+ // puts every non-detached child in a job object that is killed when the
71
+ // parent exits, so without this the controller dies the instant the
72
+ // launcher returns -- measured 2026-09-21: `run --detach` bootstrapped to
73
+ // `ready` and then died with its launcher, every time, leaving the node
74
+ // pending forever. There it also means DETACHED_PROCESS: no console
75
+ // window, which is what "ignore" stdio already implies.
76
+ detached: true,
68
77
  stdio: "ignore",
69
78
  }));
70
79
  child.unref();
package/src/cli.mjs CHANGED
@@ -46,7 +46,7 @@ import { cancelRun } from "./engine/cancel.mjs";
46
46
 
47
47
  import { errorMessage } from "./util.mjs";
48
48
  import { validateContract } from "./contract/index.mjs";
49
- import { setLaunchBaseRef } from "./engine/run-identity.mjs";
49
+ import { setLaunchBaseRef, setFreshPreflight } from "./engine/run-identity.mjs";
50
50
  import { assertLaunchBaseClean } from "./repo/source-identity.mjs";
51
51
  import { detachSelf, waitForBootstrap, writeBootstrapFailure } from "./cli/launch.mjs";
52
52
  import { DEFAULT_SUPERVISE_INTERVAL_SEC, superviseRun } from "./engine/supervise.mjs";
@@ -96,8 +96,8 @@ export function hasDetachedBootstrapNonce() {
96
96
 
97
97
  /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
98
98
  export const COMMAND_OPTIONS = {
99
- run: { detach: { type: "boolean" }, "base-ref": { type: "string" } },
100
- resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" } },
99
+ run: { detach: { type: "boolean" }, "base-ref": { type: "string" }, "fresh-preflight": { type: "boolean" } },
100
+ resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" }, "fresh-preflight": { type: "boolean" } },
101
101
  supervise: { detach: { type: "boolean" }, interval: { type: "string" } },
102
102
  cancel: {},
103
103
  preflight: { static: { type: "boolean" }, json: { type: "boolean" }, "time-verification": { type: "boolean" } },
@@ -323,6 +323,7 @@ async function main(argv) {
323
323
  const contract = validateContractForLaunch(JSON.parse(readFileSync(absolute, "utf8")), absolute, { baseRef });
324
324
  const runDir = runDirectory(contract.cwd, contract.id);
325
325
  setLaunchBaseRef(baseRef);
326
+ setFreshPreflight(values["fresh-preflight"] === true);
326
327
  // The base is what every worktree is cut from; a dirty tree only blocks
327
328
  // when the cwd HEAD *is* that base. A `--base-ref` elsewhere leaves the
328
329
  // operator's checkout out of the run entirely. The contract file being
@@ -334,7 +335,10 @@ async function main(argv) {
334
335
  if (values.detach === true) {
335
336
  if (existsSync(runDir)) throw new Error(`run already exists: ${runDir}`);
336
337
  for (const warning of [...contract.warnings, ...reusedDoneWarnings(contract)]) process.stdout.write(`${advisoryToken()} ${warning}\n`);
337
- const child = detachSelf("run", target, baseRef ? ["--base-ref", baseRef] : []);
338
+ const child = detachSelf("run", target, [
339
+ ...(baseRef ? ["--base-ref", baseRef] : []),
340
+ ...(values["fresh-preflight"] === true ? ["--fresh-preflight"] : []),
341
+ ]);
338
342
  const pid = child.pid;
339
343
  if (pid === undefined) throw new Error("detached child has no pid");
340
344
  await waitForBootstrap(runDir, pid, child);
@@ -348,6 +352,7 @@ async function main(argv) {
348
352
  }
349
353
  if (command === "resume") {
350
354
  const resumeOptions = resumeOptionsOf(values);
355
+ setFreshPreflight(values["fresh-preflight"] === true);
351
356
  if (values.detach === true) {
352
357
  const runDir = resolve(target);
353
358
  if (!existsSync(join(runDir, "contract.json"))) throw new Error(`not a run directory: ${runDir}`);
@@ -355,6 +360,7 @@ async function main(argv) {
355
360
  ...(resumeOptions.node ? ["--node", resumeOptions.node] : []),
356
361
  ...(resumeOptions.reconcile ? ["--reconcile", resumeOptions.reconcile] : []),
357
362
  ...(resumeOptions.answer ? ["--answer", `${resumeOptions.answer.node}=${resumeOptions.answer.path}`] : []),
363
+ ...(values["fresh-preflight"] === true ? ["--fresh-preflight"] : []),
358
364
  ];
359
365
  const child = detachSelf("resume", target, extraArgs);
360
366
  const pid = child.pid;
@@ -15,7 +15,7 @@ import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, pos
15
15
  import { validateMetadata } from "./schema-version.mjs";
16
16
  import { assertRuntimeExecutesCommands, requireRuntime, validateRuntime } from "./runtime.mjs";
17
17
  import { validateSourceIdentity } from "../repo/source-identity.mjs";
18
- import { commandCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
18
+ import { commandCoverageWarnings, mirrorCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
19
19
  import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-closure.mjs";
20
20
 
21
21
  export { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
@@ -368,8 +368,15 @@ export function validateContract(raw, contractPath, options = {}) {
368
368
  throw new TypeError(`task packet scope does not close; declare in readFiles or writeFiles, or acknowledge in scopeAcknowledged: ${scopeErrors.join("; ")}`);
369
369
  }
370
370
 
371
+ // Validated here rather than inline below, because the mirror warning reads
372
+ // them: a layer covered by a shared or final command is covered.
373
+ const finalVerification = validateFinalVerification(raw.finalVerification, "contract.finalVerification");
374
+ const sharedVerification = validateSharedVerification(raw.sharedVerification, "contract.sharedVerification");
375
+ const contractCommands = [...finalVerification ?? [], ...sharedVerification ?? []].map((command) => command.argv.join(" "));
376
+ const contractWrites = new Set(nodes.flatMap((node) => node.taskPacket.writeFiles ?? []));
371
377
  const warnings = nodes.flatMap((node, index) => [
372
378
  ...commandCoverageWarnings(node, index),
379
+ ...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
373
380
  ...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
374
381
  ...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
375
382
  ]);
@@ -394,8 +401,8 @@ export function validateContract(raw, contractPath, options = {}) {
394
401
  // count, because it began with 200k tokens of context instead of 45k and
395
402
  // re-read them on every request.
396
403
  phaseSessionReuse: booleanField(raw.phaseSessionReuse, false, "contract.phaseSessionReuse"),
397
- finalVerification: validateFinalVerification(raw.finalVerification, "contract.finalVerification"),
398
- sharedVerification: validateSharedVerification(raw.sharedVerification, "contract.sharedVerification"),
404
+ finalVerification,
405
+ sharedVerification,
399
406
  nodeAdvisory: validateNodeAdvisory(raw.nodeAdvisory),
400
407
  warnings,
401
408
  });
@@ -688,6 +695,12 @@ function dependencyCoversPath(closure, path, cwd) {
688
695
  function writeFileLineBudgetWarnings(node, index, cwd) {
689
696
  const warnings = [];
690
697
  for (const path of node.taskPacket.writeFiles ?? []) {
698
+ // The ceiling is a rule about source modules, and `source-shape` enforces
699
+ // it over `.mjs` alone. Measured 2026-09-22: a packet declaring the
700
+ // generated `docs/COMMANDS.md` was warned that 1141 lines left "-341 from
701
+ // the 800-line ceiling", which is not a budget, not true of that file, and
702
+ // trains the reader to skim past the warnings that are.
703
+ if (!path.endsWith(".mjs")) continue;
691
704
  let text;
692
705
  try {
693
706
  text = readFileSync(resolve(cwd, path), "utf8");
@@ -325,6 +325,14 @@ function validateRelativePath(path, label, cwd, mustExist, options = {}) {
325
325
  }
326
326
  if (!pathInside(realAnchor, realCwd)) throw new TypeError(`${label} escapes cwd`);
327
327
 
328
+ if (!existsSync(absolute) && anchor !== absolute && statSync(anchor).isFile()) {
329
+ // Nothing can ever appear beneath a regular file, so this path is refused
330
+ // outright rather than deferred to a dependency that might produce it.
331
+ // Only POSIX states that through an errno (ENOTDIR, from the walk above);
332
+ // Windows reports the same layout as a plain absence, so the shape is
333
+ // asked here rather than read off a platform error code.
334
+ throw new TypeError(`${label} is under a file, not a directory: ${path}`);
335
+ }
328
336
  if (!mustExist && !existsSync(absolute)) return;
329
337
  if (!existsSync(absolute)) {
330
338
  if (options.deferMissing) return DEFERRED_MISSING;
@@ -390,7 +398,11 @@ function findExistingPath(path) {
390
398
  lstatSync(current);
391
399
  return current;
392
400
  } catch (error) {
393
- if (errorCode(error) !== "ENOENT") throw error;
401
+ // A path whose parent is a regular file is absent, not unreadable:
402
+ // POSIX says ENOTDIR where Windows says ENOENT, and both mean the same
403
+ // thing -- keep walking up to the ancestor that does exist.
404
+ const code = errorCode(error);
405
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
394
406
  }
395
407
  const parent = dirname(current);
396
408
  if (parent === current) return null;
@@ -16,6 +16,7 @@ import { DECLARED_MODEL_CATALOGUES, stableJsonDocument } from "../harnesses/cata
16
16
  import { READ_LINE_LIMIT, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
17
17
  import { appendUsageRecord, emptyUsage, priceUsage } from "../run/usage.mjs";
18
18
  import { errorMessage, fail } from "../util.mjs";
19
+ import { spawnInvocation } from "../host/platform.mjs";
19
20
  import { DISCOVERY_RUNTIME_DEFINITIONS } from "./runtime-discovery.mjs";
20
21
 
21
22
  /** Fixed-size copy buffer: the pack is built through this, never through a corpus-sized string. */
@@ -178,10 +179,15 @@ function invokeDelegation(runtime, prompt, cwd) {
178
179
  /** @type {import("node:child_process").ChildProcess} */
179
180
  let child;
180
181
  try {
181
- child = spawn(command.executable, command.args, {
182
+ // The provider binary, reached the way this platform reaches one: a
183
+ // delegation that spawns it raw is ENOENT on Windows against the very
184
+ // CLI the run is configured to use.
185
+ const invocation = spawnInvocation(command.executable, command.args, { cwd });
186
+ child = spawn(invocation.command, invocation.args, {
182
187
  cwd,
183
188
  env: environmentWith(command.env),
184
189
  stdio: ["pipe", "pipe", "pipe"],
190
+ ...invocation.options,
185
191
  });
186
192
  } catch (error) {
187
193
  observation.spawnError = errorMessage(error);
@@ -31,6 +31,7 @@ import { existsSync, readFileSync, statSync, openSync, closeSync, readSync, writ
31
31
  import { dirname } from "node:path";
32
32
  import { spawn } from "node:child_process";
33
33
  import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
34
+ import { killTarget, spawnInvocation } from "../host/platform.mjs";
34
35
 
35
36
  /** @typedef {{executable: string, args: string[], cwd: string, promptTransport: "stdin"|"argv", harness: string, env: Record<string, string|null>|null, stdoutPath: string, stderrPath: string}} GateConfig */
36
37
 
@@ -92,13 +93,24 @@ if (config.promptTransport === "stdin") {
92
93
 
93
94
  /** @param {NodeJS.Signals} signal */
94
95
  function killGroup(signal) {
95
- try { process.kill(-process.pid, signal); } catch {
96
+ try {
97
+ // `killTarget` reads the negative pid as the group on POSIX and as the tree
98
+ // to walk on Windows, which has no group to signal.
99
+ killTarget(-process.pid, signal);
100
+ } catch {
96
101
  // ESRCH: the process group is already gone, so there is nothing to signal.
97
102
  }
98
103
  }
99
104
 
100
105
  function stopProvider() {
101
- try { provider?.kill("SIGTERM"); } catch {
106
+ try {
107
+ // The tree, not the process: on Windows a harness installed as a `.cmd` is
108
+ // reached through the command interpreter, so the provider this gate holds
109
+ // is `cmd.exe` and the harness is its child. Killing the one it spawned
110
+ // leaves the other running — measured 2026-09-20, as stranded `node`
111
+ // processes outliving a suite that had already stopped waiting for them.
112
+ if (provider?.pid) killTarget(provider.pid, "SIGTERM");
113
+ } catch {
102
114
  // No provider yet, or it already exited: a failed SIGTERM needs no action.
103
115
  }
104
116
  setTimeout(() => killGroup("SIGKILL"), 100).unref();
@@ -181,10 +193,12 @@ const timer = setInterval(() => {
181
193
  clearInterval(timer);
182
194
  const stdoutFd = openSync(config.stdoutPath, "wx", 0o600);
183
195
  const stderrFd = openSync(config.stderrPath, "wx", 0o600);
184
- provider = spawn(config.executable, config.args, {
196
+ const invocation = spawnInvocation(config.executable, config.args, { cwd: config.cwd });
197
+ provider = spawn(invocation.command, invocation.args, {
185
198
  cwd: config.cwd,
186
199
  env: childEnv(),
187
200
  stdio: [config.promptTransport === "stdin" ? "pipe" : "ignore", stdoutFd, stderrFd],
201
+ ...invocation.options,
188
202
  });
189
203
  if (config.promptTransport === "stdin") {
190
204
  for (const chunk of pendingInput) provider.stdin?.write(chunk);
@@ -15,6 +15,7 @@ import { isAbsolute, resolve } from "node:path";
15
15
  import { reviewMode, UNCITED_REJECTION_REASON } from "../contract/review-modes.mjs";
16
16
  import { JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
17
17
  import { sharedVerificationCommands } from "../contract/final-verification.mjs";
18
+ import { killTarget } from "../host/platform.mjs";
18
19
 
19
20
  /** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
20
21
  /** @typedef {import("../contract/verification.mjs").VerificationCommand} VerificationCommand */
@@ -284,8 +285,7 @@ function terminateProofGroup(child) {
284
285
  if (!pid) return;
285
286
  const signal = (/** @type {NodeJS.Signals} */ name) => {
286
287
  try {
287
- if (process.platform !== "win32") process.kill(-pid, name);
288
- else child.kill(name);
288
+ killTarget(process.platform === "win32" ? pid : -pid, name);
289
289
  } catch {
290
290
  try { child.kill(name); } catch {
291
291
  // ESRCH: the group and the leader are already gone.
@@ -17,6 +17,7 @@ import { errorMessage } from "../util.mjs";
17
17
  import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
18
18
  import { join, resolve } from "node:path";
19
19
  import { normalizeProviderResult, probeRuntime, providerCommand } from "../harnesses/index.mjs";
20
+ import { killTarget, spawnInvocation } from "../host/platform.mjs";
20
21
  import { reachableRuntimes } from "../host/preflight.mjs";
21
22
  import { spawn } from "node:child_process";
22
23
  import { boundedGitSync } from "../repo/worktree.mjs";
@@ -34,16 +35,48 @@ import { runsRoot } from "../run/paths.mjs";
34
35
  const LIVE_PREFLIGHT_PROMPT = "Respond with exactly FABERUN_PREFLIGHT_OK and do not use tools.";
35
36
  const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
36
37
  /**
38
+ * `persisted` is for the dispatch gate, which hands over the run's own
39
+ * serialized contract: that one was validated when it was authored, and
40
+ * re-running the filesystem-dependent checks against the checkout reaches a
41
+ * different answer for a run launched with `--base-ref`, whose declared paths
42
+ * live in the ref and not in the working tree. Measured 2026-09-22: without
43
+ * it the gate refused a `--base-ref` run with "readFiles[0] does not exist".
44
+ * The CLI's own `preflight <contract>` leaves it off on purpose -- there the
45
+ * contract is an authored file the operator is asking about, so validating it
46
+ * whole is part of the answer.
47
+ *
37
48
  * @param {string} contractPath
38
- * @param {{static?: boolean, liveTimeoutSec?: number}} [options]
49
+ * @param {{static?: boolean, liveTimeoutSec?: number, persisted?: boolean}} [options]
39
50
  * @returns {Promise<ProbeResult[]>}
40
51
  */
41
52
  export async function preflightContract(contractPath, options = {}) {
42
53
  const absoluteContractPath = resolve(contractPath);
43
- const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath);
44
- const runtimes = reachableRuntimes(contract);
45
- const staticChecks = await Promise.all([...runtimes.values()].map(({ runtime, requiredCapabilitySets }) =>
46
- probeRuntime(runtime, { cwd: contract.cwd, requiredCapabilitySets }),
54
+ const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath, options.persisted === true ? { persisted: true } : {});
55
+ return preflightRuntimes([...reachableRuntimes(contract).values()], { ...options, cwd: contract.cwd });
56
+ }
57
+
58
+ /**
59
+ * The same ask, entered from a set of runtimes rather than a contract.
60
+ *
61
+ * `faberun plan` needs this and a contract cannot give it. Planning contracts
62
+ * carry no gate (`plan/template.mjs`), and `reachableRuntimes` only counts the
63
+ * judge role when a node's gate is enabled -- so preflighting the draft
64
+ * stage's contract asks the planner and never the reviewer, which is reached
65
+ * only as the *worker* of a later stage's own contract. Asking both before the
66
+ * first stage therefore has to name the runtimes directly.
67
+ *
68
+ * One asking function with two entry points, never two: the live probe, the
69
+ * throwaway repository, the budget and the detail wording are decided here
70
+ * once, so a caller cannot accidentally ask a different question.
71
+ *
72
+ * @param {{runtime: RuntimeSnapshot, requiredCapabilitySets?: import("../harnesses/index.mjs").CapabilityRequirements[]}[]} entries
73
+ * @param {{static?: boolean, liveTimeoutSec?: number, cwd?: string}} [options]
74
+ * @returns {Promise<ProbeResult[]>}
75
+ */
76
+ export async function preflightRuntimes(entries, options = {}) {
77
+ const runtimes = entries.map((entry) => entry.runtime);
78
+ const staticChecks = await Promise.all(entries.map(({ runtime, requiredCapabilitySets }) =>
79
+ probeRuntime(runtime, { cwd: options.cwd, requiredCapabilitySets }),
47
80
  ));
48
81
  if (options.static === true) return staticChecks;
49
82
 
@@ -62,8 +95,7 @@ export async function preflightContract(contractPath, options = {}) {
62
95
  }
63
96
  try {
64
97
  return await Promise.all(staticChecks.map(async (check, index) => {
65
- const runtime = [...runtimes.values()][index].runtime;
66
- const live = await livePreflight(runtime, liveRepo, timeoutSec);
98
+ const live = await livePreflight(runtimes[index], liveRepo, timeoutSec);
67
99
  const liveDetail = live.status === "done"
68
100
  ? `live done · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`
69
101
  : `live ${live.status} · ${live.error?.code ?? "provider_error"}: ${redactProviderText(live.error?.message ?? "generation failed")} · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`;
@@ -146,11 +178,13 @@ function livePreflight(runtime, cwd, timeoutSec) {
146
178
  else env[key] = value;
147
179
  }
148
180
  delete env.FABERUN_NOTIFY_BIN;
149
- child = /** @type {import("node:child_process").ChildProcessWithoutNullStreams} */ (spawn(command.executable, command.args, {
181
+ const invocation = spawnInvocation(command.executable, command.args, { cwd });
182
+ child = /** @type {import("node:child_process").ChildProcessWithoutNullStreams} */ (spawn(invocation.command, invocation.args, {
150
183
  cwd,
151
184
  env,
152
185
  detached: process.platform !== "win32",
153
186
  stdio: [command.promptTransport === "stdin" ? "pipe" : "ignore", "pipe", "pipe"],
187
+ ...invocation.options,
154
188
  }));
155
189
  } catch (error) {
156
190
  settle({
@@ -182,8 +216,7 @@ function livePreflight(runtime, cwd, timeoutSec) {
182
216
  /** @param {NodeJS.Signals} name */
183
217
  const signal = (name) => {
184
218
  try {
185
- if (process.platform === "win32") child.kill(name);
186
- else process.kill(-/** @type {number} */ (child.pid), name);
219
+ killTarget(process.platform === "win32" ? /** @type {number} */ (child.pid) : -/** @type {number} */ (child.pid), name);
187
220
  } catch {
188
221
  // ESRCH: the child is already gone, so there is no process to signal.
189
222
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What counts as a provider saying nothing at all.
3
+ *
4
+ * This is one rule with three readers -- the dispatch gate
5
+ * (`engine/run-identity.mjs`), planning's pre-stage refusal
6
+ * (`plan/preflight.mjs`) and `doctor` (`host/preflight.mjs`) -- and it is its
7
+ * own module because those three cannot all import each other. `doctor` owns
8
+ * `reachableRuntimes`, which `engine/live-preflight.mjs` imports, so a
9
+ * `doctor` that read the rule out of the engine closed a runtime import
10
+ * cycle. This module imports nothing: it reads a probe's recorded detail and
11
+ * says whether a provider answered, and nothing else.
12
+ *
13
+ * The rule itself is the campaign's claim in one line. Any verdict a provider
14
+ * returned is an answer, a quota refusal included, and the run proceeds onto
15
+ * whatever the contract declares. Only silence blocks.
16
+ */
17
+
18
+ /**
19
+ * The causes that mean no runtime said anything at all: the provider was
20
+ * asked and did not answer, or could not be started to be asked.
21
+ *
22
+ * `command_invalid` is deliberately not one of them. A command that could not
23
+ * be constructed never reached a provider, so there is no availability
24
+ * verdict either way -- that is a contract defect and validation already owns
25
+ * it. Measured 2026-09-22: three deterministic evals declare a fallback and a
26
+ * judge runtime they never invoke, so those carry no replay recording and the
27
+ * replay adapter throws when asked to build their command. Blocking there
28
+ * refuses a run over a runtime it would never have used, for a fault the
29
+ * provider never had.
30
+ */
31
+ const LIVE_SILENCE_CAUSES = new Set(["preflight_timeout", "spawn_error"]);
32
+
33
+ /**
34
+ * Whether a live probe is pipeline silence rather than a verdict, and which
35
+ * cause. `preflightContract` embeds the provider envelope's error code in the
36
+ * probe detail (`… · live failed · <code>: …`), and the repository-failure
37
+ * wording means no runtime was even asked. Everything else -- a quota
38
+ * refusal, an auth failure, unparsable output -- is a provider that answered,
39
+ * and an answer is hello enough.
40
+ *
41
+ * @param {import("../harnesses/index.mjs").ProbeResult} probe
42
+ * @returns {string|null}
43
+ */
44
+ export function liveSilenceCause(probe) {
45
+ if (probe.ok) return null;
46
+ if (/live preflight repository failed/u.test(probe.detail ?? "")) return "spawn_error";
47
+ const match = / · live \S+ · ([a-z_]+):/u.exec(probe.detail ?? "");
48
+ return match !== null && LIVE_SILENCE_CAUSES.has(match[1]) ? match[1] : null;
49
+ }
@@ -86,6 +86,17 @@ export function processStartTokenMatches(invocation) {
86
86
  * -- a pid this user cannot signal is not the child this controller spawned --
87
87
  * and a null token with no handle is unverifiable, so it is never owned.
88
88
  *
89
+ * Except on Windows, which records no token at all: `wmic` is gone from
90
+ * Windows 11 26200 and the PowerShell that replaced it costs about 400 ms a
91
+ * probe (`run/lock.mjs`). Holding POSIX's answer there means every recorded
92
+ * invocation is unverifiable, so a controller never terminates the provider it
93
+ * started: measured 2026-09-20, a suite run left 105 node fixtures alive and
94
+ * then waited on one of them, and `cancel` reported providers it had not
95
+ * stopped. A live pid this controller recorded is the evidence that platform
96
+ * has, and it is the same evidence `killTarget` already acts on there. What is
97
+ * given up is the pid-reuse defence: a pid recycled into an unrelated process
98
+ * between the record and the kill reads as owned.
99
+ *
89
100
  * @param {InvocationProbe} invocation
90
101
  * @param {{child?: ChildProcess|null}} [options]
91
102
  * @returns {boolean}
@@ -106,6 +117,6 @@ export function invocationOwned(invocation, options = {}) {
106
117
  const child = options.child;
107
118
  if (child && child.exitCode === null && child.signalCode === null) return true;
108
119
  const token = invocation.processStartToken;
109
- if (!token) return false;
120
+ if (!token) return process.platform === "win32";
110
121
  return processStartToken(invocation.pid) === token;
111
122
  }
@@ -23,6 +23,7 @@ import { randomUUID } from "node:crypto";
23
23
  import { spawn } from "node:child_process";
24
24
  import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
25
25
  import { writeNodeSnapshot } from "../run/node-store.mjs";
26
+ import { killTarget } from "../host/platform.mjs";
26
27
 
27
28
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
28
29
  /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
@@ -572,7 +573,7 @@ function signalInvocation(invocation, signal, options = {}) {
572
573
  const pid = invocation.pid;
573
574
  if (pid === null || pid === undefined) return false;
574
575
  const target = process.platform === "win32" ? pid : -(invocation.processGroupId ?? pid);
575
- const kill = options.kill ?? process.kill;
576
+ const kill = options.kill ?? killTarget;
576
577
  try {
577
578
  kill(target, signal);
578
579
  return true;
@@ -17,6 +17,7 @@ import { processStartToken } from "../run/lock.mjs";
17
17
  import { randomUUID } from "node:crypto";
18
18
  import { runMutation } from "./mutation.mjs";
19
19
  import { spawn } from "node:child_process";
20
+ import { killTarget, spawnInvocation } from "../host/platform.mjs";
20
21
  /** @typedef {import("../contract/verification.mjs").VerificationOptions} VerificationOptions */
21
22
 
22
23
  /** @typedef {import("node:child_process").ChildProcess} ChildProcess */
@@ -228,7 +229,11 @@ function runCommand(command, baseCwd, commandCwd, attempt, signal, options, comm
228
229
  };
229
230
  options?.onAttemptStart?.({ ...identity });
230
231
  const env = verificationEnv(command);
231
- child = spawn(command.argv[0], command.argv.slice(1), { cwd, env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
232
+ // A verification command names a binary the same way a harness runtime
233
+ // does, and on Windows `npm test` is `npm.cmd`: the invocation, not the
234
+ // raw argv, is what can actually be spawned there.
235
+ const invocation = spawnInvocation(command.argv[0], command.argv.slice(1), { cwd });
236
+ child = spawn(invocation.command, invocation.args, { cwd, env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], ...invocation.options });
232
237
  const pid = child.pid ?? null;
233
238
  let paused = false;
234
239
  if (process.platform !== "win32" && pid) {
@@ -268,8 +273,7 @@ function runCommand(command, baseCwd, commandCwd, attempt, signal, options, comm
268
273
  */
269
274
  function terminateGroup(child) {
270
275
  try {
271
- if (process.platform !== "win32") process.kill(-/** @type {number} */ (child.pid), "SIGTERM");
272
- else child.kill("SIGTERM");
276
+ killTarget(process.platform === "win32" ? /** @type {number} */ (child.pid) : -/** @type {number} */ (child.pid), "SIGTERM");
273
277
  } catch {
274
278
  try { child.kill("SIGTERM"); } catch {
275
279
  // ESRCH: the group kill failed and the leader was already gone.
@@ -277,8 +281,7 @@ function terminateGroup(child) {
277
281
  }
278
282
  setTimeout(() => {
279
283
  try {
280
- if (process.platform !== "win32") process.kill(-/** @type {number} */ (child.pid), "SIGKILL");
281
- else child.kill("SIGKILL");
284
+ killTarget(process.platform === "win32" ? /** @type {number} */ (child.pid) : -/** @type {number} */ (child.pid), "SIGKILL");
282
285
  } catch {
283
286
  try { child.kill("SIGKILL"); } catch {
284
287
  // ESRCH: the SIGKILL fallback found no leader left to kill.