faberun 0.19.0 → 0.19.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
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": {
package/src/cli/brand.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * untouched.
10
10
  */
11
11
 
12
- import { compareVersions, faberunHome, readUpdateCheck } from "../host/home.mjs";
12
+ import { availableUpdate, faberunHome } from "../host/home.mjs";
13
13
 
14
14
  /** @typedef {"brand"|"ok"|"progress"|"warn"|"fail"|"muted"|"text"} Role */
15
15
  /** @typedef {"terra"|"argila"|"folha"} ColorName */
@@ -150,16 +150,15 @@ export function statusToken(kind, level) {
150
150
  * opening is muted, the wordmark is the brand role, the tagline is plain text
151
151
  * and the last line is muted and filled from the running process. The last line
152
152
  * gains ` · update available: <latest>` when the cached check names a newer
153
- * release; the banner reads only the cache (`update-check.json`) and never
154
- * fetches. ASCII apart from the middle-dot separator, so it survives every
153
+ * release and is still inside `UPDATE_CHECK_MAX_AGE_MS`; the banner reads only
154
+ * the cache (`update-check.json`) and never fetches. ASCII apart from the middle-dot separator, so it survives every
155
155
  * monospace font.
156
156
  *
157
157
  * @param {BannerOptions} options
158
158
  * @returns {string}
159
159
  */
160
160
  export function renderBanner({ version, nodeVersion, harnessCount, level, env = process.env }) {
161
- const cached = readUpdateCheck(faberunHome(env));
162
- const latest = cached && compareVersions(cached.latest, version) > 0 ? cached.latest : null;
161
+ const latest = availableUpdate(faberunHome(env), version);
163
162
  const terra = /** @param {string} text @returns {string} */ (text) => colorize(text, { color: "terra" }, level);
164
163
  const lines = [
165
164
  terra(" .-~~~-."),
@@ -20,7 +20,7 @@ import { existsSync, readFileSync } from "node:fs";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { join, resolve } from "node:path";
22
22
  import { randomUUID } from "node:crypto";
23
- import { readRunNodes } from "../engine/scheduler.mjs";
23
+ import { readRunNodes } from "../run/node-store.mjs";
24
24
  import { spawn } from "node:child_process";
25
25
  import { validateContract } from "../contract/index.mjs";
26
26
  import { runDirectory } from "../run/paths.mjs";
@@ -16,7 +16,7 @@ import { invocationOwned } from "./process-identity.mjs";
16
16
  import { terminateInvocation } from "./process.mjs";
17
17
  import { join, resolve } from "node:path";
18
18
  import { readFileSync } from "node:fs";
19
- import { readRunNodes } from "./scheduler.mjs";
19
+ import { readRunNodes } from "../run/node-store.mjs";
20
20
  import { createPreservedRef, deleteRef, releaseAttemptWorktree, runRefName } from "../repo/worktree.mjs";
21
21
  import { syncAgentSignal } from "../repo/signal.mjs";
22
22
  import { transition, writeNode } from "./state.mjs";
@@ -55,7 +55,7 @@ export async function cancelRun(runDirPath) {
55
55
  }
56
56
  const controllerLock = await acquireStaleLock(runDir);
57
57
  try {
58
- const states = readRunNodes(runDir, contract);
58
+ const states = readRunNodes(runDir, contract, { tolerateMissing: true });
59
59
  /** @type {Error[]} */
60
60
  const failures = [];
61
61
  for (const state of states) {
@@ -196,7 +196,7 @@ async function waitForTerminal(runDir, timeoutMs) {
196
196
  while (Date.now() < deadline) {
197
197
  const contractPath = join(runDir, "contract.json");
198
198
  const contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
199
- const states = readRunNodes(runDir, contract);
199
+ const states = readRunNodes(runDir, contract, { tolerateMissing: true });
200
200
  if (states.every((state) => SETTLED.has(state.status)) && states.every((state) => (state.invocations ?? []).every((invocation) => !invocationOwned(invocation)))) return true;
201
201
  await delay(100);
202
202
  }
@@ -136,15 +136,22 @@ export function failoverEdges(contract) {
136
136
  * The first snapshot wins; every later requirement set is accumulated, so a
137
137
  * runtime reached twice is still checked against both callers' demands.
138
138
  *
139
- * @param {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[]}>} runtimes
139
+ * `routed` says whether some node or default names this runtime, directly or
140
+ * as a declared failover target. A runtime reached only because a role named
141
+ * none -- every catalogue entry is then a candidate availability discovery may
142
+ * pick -- is added with `routed: false`, and one route that names it at all
143
+ * makes it routed for good.
144
+ *
145
+ * @param {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[], routed: boolean}>} runtimes
140
146
  * @param {RuntimeSnapshot} runtime
141
147
  * @param {import("../harnesses/index.mjs").CapabilityRequirements[]} requiredCapabilitySets
148
+ * @param {boolean} [routed]
142
149
  */
143
- export function addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets) {
150
+ export function addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets, routed = true) {
144
151
  const incoming = requiredCapabilitySets.filter((requirements) => requirements && Object.keys(requirements).length);
145
152
  const current = runtimes.get(runtime.id);
146
- if (!current) runtimes.set(runtime.id, { runtime, requiredCapabilitySets: incoming });
147
- else runtimes.set(runtime.id, { runtime: current.runtime, requiredCapabilitySets: [...current.requiredCapabilitySets, ...incoming] });
153
+ if (!current) runtimes.set(runtime.id, { runtime, requiredCapabilitySets: incoming, routed });
154
+ else runtimes.set(runtime.id, { runtime: current.runtime, requiredCapabilitySets: [...current.requiredCapabilitySets, ...incoming], routed: current.routed || routed });
148
155
  }
149
156
 
150
157
  /**
@@ -16,6 +16,7 @@ import { reviewMode, UNCITED_REJECTION_REASON } from "../contract/review-modes.m
16
16
  import { JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
17
17
  import { sharedVerificationCommands } from "../contract/final-verification.mjs";
18
18
  import { killTarget } from "../host/platform.mjs";
19
+ import { shellWords } from "../util.mjs";
19
20
 
20
21
  /** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
21
22
  /** @typedef {import("../contract/verification.mjs").VerificationCommand} VerificationCommand */
@@ -174,31 +175,26 @@ function envForFilteredProof() {
174
175
  /**
175
176
  * The node:test filters a command string declares, in argv order, as flag and
176
177
  * value. Presence alone changes behaviour (the appended reporter and the
177
- * zero-plan look-up); the value is read for the refusal detail alone, which is
178
- * why a whitespace split is close enough even though the command runs through
179
- * a shell.
178
+ * zero-plan look-up); the value lands in the refusal detail, and it is read
179
+ * the way the shell this command runs under reads it -- `shellWords` groups a
180
+ * quoted pattern into one word instead of splitting the pattern itself.
180
181
  *
181
182
  * @param {string} ref
182
183
  * @returns {Array<{flag: string, value: string}>}
183
184
  */
184
185
  function declaredTestFilters(ref) {
185
- const tokens = ref.split(/\s+/u).filter(Boolean);
186
+ const tokens = shellWords(ref);
186
187
  /** @type {Array<{flag: string, value: string}>} */
187
188
  const filters = [];
188
189
  for (const [index, token] of tokens.entries()) {
189
190
  for (const flag of TEST_FILTER_FLAGS) {
190
- if (token.startsWith(`${flag}=`)) filters.push({ flag, value: unquote(token.slice(flag.length + 1)) });
191
- else if (token === flag) filters.push({ flag, value: unquote(tokens[index + 1] ?? "") });
191
+ if (token.startsWith(`${flag}=`)) filters.push({ flag, value: token.slice(flag.length + 1) });
192
+ else if (token === flag) filters.push({ flag, value: tokens[index + 1] ?? "" });
192
193
  }
193
194
  }
194
195
  return filters;
195
196
  }
196
197
 
197
- /** @param {string} value @returns {string} */
198
- function unquote(value) {
199
- return value.replace(/^['"]|['"]$/gu, "");
200
- }
201
-
202
198
  /** @param {Array<{flag: string, value: string}>} filters @returns {string} */
203
199
  function filterNames(filters) {
204
200
  return filters.map(({ flag, value }) => `${flag} "${value}"`).join(", ");
@@ -36,7 +36,7 @@ import { killTarget } from "../host/platform.mjs";
36
36
  /** @typedef {{prompt: string|null, stdout: string, stderr: string}} PathSet */
37
37
  /** @typedef {{id: string, pid: number, processGroupId: number|null, processStartToken: string|null, harness: string, runtimeId: string|null, runtimeFingerprint?: string, revision?: number, phase: string, promptPath: string|null, stdoutPath: string, stderrPath: string, startedAt: string, deadlineAt: string|null, updatedAt: string, closedAt: string|null, exitCode: number|null, signal: string|null, status: "active"|"closed"|"terminated", executable: string, snapshotPath?: string, usage?: Usage, usageEstimated?: boolean, costUsd?: number|null, costProvenance?: "priced", runId?: string, campaignId?: string, nodeId?: string, attempt?: number, workspace?: string, worktreeBranch?: string|null, worktreeBaseSha?: string|null, planPhase?: string, role?: "worker"|"judge", model?: string, reasoning?: string|null, sandbox?: string|null, continuationId?: string|null, continuationMode?: "fresh"|"reuse"|"rotate", session?: import("../harnesses/session-metrics.mjs").SessionLedger|null}} Invocation */
38
38
  /** @typedef {{pid: number|null, processGroupId?: number|null, processStartToken?: string|null}} InvocationProbe */
39
- /** @typedef {{child: ChildProcess, contract: ValidatedContract, node: ValidatedNode, state: NodeSnapshot, runtime: HarnessRuntime & {id: string|null}, cwd: string, paths: PathSet, phase: string, invocation: Invocation, startedAt: string, startedTicks: bigint, progressTicks: bigint, lastOutputAt: number, closed: boolean, exitCode: number|null, signal: string|null, spawnError: Error|null, terminating: Promise<void>|null, gateConfigPath: string, gateReleasePath: string, scopeBaseline?: unknown, scopeChecked?: boolean, scopeViolation?: boolean, resultMaterialization?: boolean, recoveryBaseline?: unknown, observeTimer?: ReturnType<typeof setInterval>, monitorOffset?: number, monitorParser?: import("../harnesses/session-metrics.mjs").SessionMetricsParser, lastEventCount?: number, observedOnce?: boolean, onClose?: (invocation: Invocation) => void, onInvocationUpdate?: (invocation: Invocation) => void, onProgress?: (state: NodeSnapshot) => void}} Job */
39
+ /** @typedef {{child: ChildProcess, contract: ValidatedContract, node: ValidatedNode, state: NodeSnapshot, runtime: HarnessRuntime & {id: string|null}, cwd: string, paths: PathSet, phase: string, invocation: Invocation, startedAt: string, startedTicks: bigint, progressTicks: bigint, lastOutputAt: number, closed: boolean, exitCode: number|null, signal: string|null, spawnError: Error|null, terminating: Promise<void>|null, gateConfigPath: string, gateReleasePath: string, scopeBaseline?: unknown, scopeChecked?: boolean, scopeViolation?: boolean, resultMaterialization?: boolean, recoveryBaseline?: unknown, observeTimer?: ReturnType<typeof setInterval>, monitorOffset?: number, monitorParser?: import("../harnesses/session-metrics.mjs").SessionMetricsParser, lastEventCount?: number, lastMonitorOffset?: number, observedOnce?: boolean, onClose?: (invocation: Invocation) => void, onInvocationUpdate?: (invocation: Invocation) => void, onProgress?: (state: NodeSnapshot) => void}} Job */
40
40
  /** @typedef {{graceMs?: number, killGraceMs?: number, escalate?: boolean, runDir?: string, kill?: (pid: number, signal: string|number) => unknown, child?: ChildProcess|null}} TerminateOptions */
41
41
 
42
42
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -434,8 +434,26 @@ export async function detectStalls(contract, running, onTimeout, onProgress, onB
434
434
  if (streaming) {
435
435
  const monitored = monitorInvocation(job);
436
436
  const events = monitored.turns + monitored.toolCalls;
437
- if (events !== job.lastEventCount || job.observedOnce !== true) {
437
+ // Transcript bytes the monitor consumed count as liveness beside the
438
+ // events, because a turn can transmit for a long time without finishing
439
+ // one. Codex meters progress as `turn.completed` plus tool calls, so a
440
+ // single long reasoning stretch -- streaming `item.completed` records
441
+ // that are neither -- advanced no counter and was killed as a stall
442
+ // while it was actively transmitting. This is not the mtime the module
443
+ // header rejects: mtime moves for a buffered harness that has written
444
+ // nothing a provider produced, and this branch is the streaming
445
+ // harnesses only, where new bytes on the transcript are the provider's
446
+ // own output and the monitor's offset only ever advances. A process
447
+ // that transmits forever without ending is still held by the wall clock
448
+ // and the turn cap below.
449
+ // An offset this job has never recorded is not growth: `observedOnce`
450
+ // below is what covers the first pass, and reading `undefined` as a
451
+ // change would make every first observation look like progress.
452
+ const consumed = job.monitorOffset ?? 0;
453
+ const grew = consumed !== (job.lastMonitorOffset ?? consumed);
454
+ if (events !== job.lastEventCount || grew || job.observedOnce !== true) {
438
455
  job.lastEventCount = events;
456
+ job.lastMonitorOffset = consumed;
439
457
  job.progressTicks = now;
440
458
  // `lastOutputAt` is the supervised controller's provider-progress
441
459
  // signal (scheduler.mjs): keep it advancing for an event that counts
@@ -23,7 +23,8 @@ import { attemptWorktreePath } from "../run/paths.mjs";
23
23
  import { canonicalWorkerResultText, isResultMaterializationInvocation, materializeAttemptResult, recoverWorkerResult } from "./result-file.mjs";
24
24
  import { checkPersistedWorkerScope, persistedScopeBoundary, reconcileAmbiguousWorkerRestart, resolveUnknownEffect } from "./scope.mjs";
25
25
  import { closePersistedInvocation, recoverOrphan, recoveryFromOverride } from "./recover.mjs";
26
- import { driveRun, readRunNodes } from "./scheduler.mjs";
26
+ import { driveRun } from "./scheduler.mjs";
27
+ import { readRunNodes } from "../run/node-store.mjs";
27
28
  import { emptyUsage, invocationCost, invocationUsage, persistRecoveryUsage } from "../run/usage.mjs";
28
29
  import { ensureTerminalEvent, hasDoneEvent, recordExecutionOverride, transition, writeNode } from "./state.mjs";
29
30
  import { errorMessage, excerpt } from "../util.mjs";
@@ -60,7 +60,18 @@ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
60
60
  costRank: 1,
61
61
  },
62
62
  "agy-gemini": { harness: "agy", model: "gemini-3.8-flash-low", vendor: "google", tier: 1, costRank: 1 },
63
+ // Three codex rows, because the harness declares three models and an
64
+ // account is entitled to only some of them: measured 2026-09-21 on the
65
+ // owner's ChatGPT account, plain `gpt-5.6` answers HTTP 400 while
66
+ // `gpt-5.6-sol` answers normally. One row meant `setup` could offer only the
67
+ // model that account cannot use, and the operator's only way out was to hand
68
+ // every contract its own catalogue through `--runtimes`. Declaration order is
69
+ // unchanged, so the composed default is still `codex-gpt`: which of the three
70
+ // an account can reach is not something this file can know, and the live
71
+ // preflight is what reports it per id.
63
72
  "codex-gpt": { harness: "codex", model: "gpt-5.6", vendor: "openai", tier: 2, costRank: 2 },
73
+ "codex-sol": { harness: "codex", model: "gpt-5.6-sol", vendor: "openai", tier: 2, costRank: 2 },
74
+ "codex-luna": { harness: "codex", model: "gpt-5.6-luna", vendor: "openai", tier: 2, costRank: 2 },
64
75
  "claude-sonnet": { harness: "claude", model: "claude-sonnet-5", vendor: "anthropic", tier: 2, costRank: 2 },
65
76
  });
66
77
 
@@ -37,13 +37,11 @@ import { delay, errorCode } from "../util.mjs";
37
37
  import { alreadyNotified, emitNodeAdvisories, notifyQueueFor, notifyQueuesByRun, renderCampaignHandoffSafely } from "./notify-queue.mjs";
38
38
  import { detectStalls, invocationAlive, terminateProcess } from "./process.mjs";
39
39
  import { transition, writeNode } from "./state.mjs";
40
- import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
41
40
  import { render, renderFinalReport, writeFindingsArtifact } from "../report/final.mjs";
42
41
  import { operationNextState, providerReceipts, settleInvocation } from "../run/operations.mjs";
43
42
  import { appendUsageRecord, invocationCost, invocationUsage, recordInvocationUsage } from "../run/usage.mjs";
44
43
  import { captureNodeScopeBoundaries, checkWorkerScope, emptyScope } from "./scope.mjs";
45
44
  import { validateContractForLaunch } from "../campaign/chain.mjs";
46
- import { validateNodeSnapshot } from "../contract/snapshot.mjs";
47
45
  import { finalVerificationCommands, sharedVerificationCommands } from "../contract/final-verification.mjs";
48
46
  import { startJudge, startWorker } from "./dispatch.mjs";
49
47
  import { assertEnvironmentReady, captureRunIdentity, createRunMetadata, serializableContract, statesFingerprint } from "./run-identity.mjs";
@@ -238,6 +236,19 @@ export async function runContract(contractPath, options = {}) {
238
236
  }
239
237
  const lock = acquireLock(runDir);
240
238
  try {
239
+ // `contract.json` is written before anything else claims a name outside
240
+ // this directory, because `cancel` is the only verb that releases those
241
+ // names and it reads the contract from here. Everything below can fail or
242
+ // be killed -- `runtimeAssignments` probes providers, `captureRunIdentity`
243
+ // shells out to git, `createRunRef` claims `refs/faberun/<id>/run` -- and a
244
+ // launch that died between the ref and this write used to leave an
245
+ // occupied ref plus a run directory `cancel` could not parse: the operator
246
+ // was refused the directory, deleted it, was then refused the ref, and had
247
+ // no single verb for either. Written first, the directory is always
248
+ // cancellable from the instant it exists.
249
+ mkdirSync(join(runDir, "nodes"), { recursive: true });
250
+ mkdirSync(join(runDir, "logs"), { recursive: true });
251
+ writeJsonAtomic(join(runDir, "contract.json"), serializableContract(contract));
241
252
  const runtimePlan = await runtimeAssignments(contract);
242
253
  const scopeBoundaries = captureNodeScopeBoundaries(contract);
243
254
  const sourceIdentity = await captureRunIdentity(contract, scopeBoundaries);
@@ -245,9 +256,6 @@ export async function runContract(contractPath, options = {}) {
245
256
  lock.assert();
246
257
  const runsDir = runsRoot(contract.cwd);
247
258
  const campaign = resolveCampaign(runsDir, contract.campaignId);
248
- mkdirSync(join(runDir, "nodes"), { recursive: true });
249
- mkdirSync(join(runDir, "logs"), { recursive: true });
250
- writeJsonAtomic(join(runDir, "contract.json"), serializableContract(contract));
251
259
  writeJsonAtomic(join(runDir, "judge.schema.json"), JUDGE_SCHEMA);
252
260
  writeJsonAtomic(join(runDir, "run.json"), createRunMetadata(lock, sourceIdentity, {}, integrationRef));
253
261
  registerRun(campaign.path, contract.id);
@@ -755,20 +763,3 @@ export async function driveRun(contract, runDir, states, campaign, lock, sourceI
755
763
  }
756
764
  return { runDir, states, ok: failed.length === 0 };
757
765
  }
758
-
759
- /**
760
- * @param {string} runDir
761
- * @param {ValidatedContract} contract
762
- * @returns {NodeSnapshot[]}
763
- */
764
- export function readRunNodes(runDir, contract) {
765
- const names = listNodeSnapshots(runDir);
766
- const expected = new Map(contract.nodes.map((node) => [`${node.id}.json`, node]));
767
- for (const name of names) if (!expected.has(name)) throw new TypeError(`unexpected persisted node snapshot ${name}`);
768
- return contract.nodes.map((node) => {
769
- const name = `${node.id}.json`;
770
- if (!names.includes(name)) throw new TypeError(`missing persisted node snapshot ${name}`);
771
- return validateNodeSnapshot(readNodeSnapshot(runDir, node.id), node);
772
- });
773
- }
774
-
@@ -15,7 +15,7 @@ import { SETTLED } from "./prompts.mjs";
15
15
  import { appendTransitionEvent, recordExecutionOverride, transition, writeNode } from "./state.mjs";
16
16
  import { attemptWorkspace } from "../repo/worktree.mjs";
17
17
 
18
- import { errorCode, errorMessage, excerpt, isContained } from "../util.mjs";
18
+ import { errorCode, errorMessage, excerpt, isContained, shellWords } from "../util.mjs";
19
19
  import { executeControllerVerification } from "./verify.mjs";
20
20
  import { providerReceiptsFromInvocationTail, settleInvocation } from "../run/operations.mjs";
21
21
  import { readJson } from "../run/store.mjs";
@@ -108,10 +108,17 @@ export function workerScope(taskPacket) {
108
108
 
109
109
  /**
110
110
  * Everything a node's own proofs name: a Definition of Done `path` proof's
111
- * path, the words of a `command` proof (a command proof carries a display
112
- * string, not an argv, so a quoted path holding a space is not recovered), the
113
- * argv of the verification entry a `verification` proof references, and the
114
- * argv of every verification command the packet declares.
111
+ * path, the words of a `command` proof, the argv of the verification entry a
112
+ * `verification` proof references, and the argv of every verification command
113
+ * the packet declares.
114
+ *
115
+ * A command proof carries a display string, not an argv, so its words are
116
+ * recovered with `shellWords` rather than a whitespace split -- the split lost
117
+ * exactly what the shell it runs under preserves. Measured 2026-09-22:
118
+ * `spawn(ref, {shell: true})` hands the whole string to `sh -c`, which groups
119
+ * `"b c"` into one argument, while the split here cut it into `"b` and `c"`,
120
+ * so a proof naming a real path with a space cited two fragments that matched
121
+ * no file and the write it excused read as unexpected.
115
122
  *
116
123
  * @param {ValidatedNode} node
117
124
  * @returns {ProofCitation[]}
@@ -129,7 +136,7 @@ function proofCitations(node) {
129
136
  if (proof.kind === "path") {
130
137
  citations.push({ tokens: [proof.ref], cwd: ".", literal: true, citation: `${item.id} path proof` });
131
138
  } else if (proof.kind === "command") {
132
- citations.push({ tokens: proof.ref.split(/\s+/u), cwd: ".", literal: false, citation: `${item.id} command proof` });
139
+ citations.push({ tokens: shellWords(proof.ref), cwd: ".", literal: false, citation: `${item.id} command proof` });
133
140
  } else {
134
141
  const command = commands[Number.parseInt(proof.ref, 10)];
135
142
  if (command) citations.push({ tokens: command.argv, cwd: command.cwd ?? ".", literal: false, citation: `${item.id} verification[${proof.ref}] proof` });
package/src/host/home.mjs CHANGED
@@ -154,3 +154,41 @@ function parseVersion(text) {
154
154
  if (!match) return null;
155
155
  return [Number(match[1]), Number(match[2]), Number(match[3])];
156
156
  }
157
+
158
+ /**
159
+ * How old a cached update check may be and still be repeated as a fact about
160
+ * the world. The banner and the notification message read the cache and never
161
+ * the network -- a version hint must not put a request in front of every
162
+ * command -- so the cache is only ever as fresh as the last
163
+ * `faberun update --check` somebody remembered to run. Measured 2026-09-22 on
164
+ * the owner's machine: `update-check.json` was five days old and still named
165
+ * 0.10.0 as the latest release while 0.19.0 was being cut.
166
+ *
167
+ * A week is the bound, not a refresh: past it the hint stops being shown
168
+ * rather than being re-fetched, because a stale claim asserted with authority
169
+ * is worse than no claim. The command that does reach the network is
170
+ * unaffected, and the file keeps its `checkedAt` either way.
171
+ */
172
+ export const UPDATE_CHECK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
173
+
174
+ /**
175
+ * The newer release a still-fresh cached check names, or null: no cache, a
176
+ * cache older than the window, a cache timestamped in the future (a record
177
+ * that would otherwise never expire), or a `latest` that is not newer than
178
+ * what is running. The one home of the "is there an update to mention" rule,
179
+ * so the banner and the notification message cannot come to disagree.
180
+ *
181
+ * @param {string} home
182
+ * @param {string} version the version actually running
183
+ * @param {number} [now] epoch milliseconds, injected by tests
184
+ * @returns {string|null}
185
+ */
186
+ export function availableUpdate(home, version, now = Date.now()) {
187
+ const check = readUpdateCheck(home);
188
+ if (!check) return null;
189
+ const checkedAt = Date.parse(check.checkedAt);
190
+ if (!Number.isFinite(checkedAt)) return null;
191
+ const age = now - checkedAt;
192
+ if (age < 0 || age > UPDATE_CHECK_MAX_AGE_MS) return null;
193
+ return compareVersions(check.latest, version) > 0 ? check.latest : null;
194
+ }
@@ -50,7 +50,7 @@ import { availabilityKey, readAvailability, recordAvailability } from "../run/av
50
50
  /** @typedef {import("../harnesses/index.mjs").CapabilityRequirements} CapabilityRequirements */
51
51
  /** @typedef {import("../harnesses/index.mjs").ProbeResult} ProbeResult */
52
52
  /** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
53
- /** @typedef {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: CapabilityRequirements[]}>} ReachableRuntimes */
53
+ /** @typedef {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: CapabilityRequirements[], routed: boolean}>} ReachableRuntimes */
54
54
  /** @typedef {{name: string, ok: boolean, advisory: boolean, detail: string}} EnvCheck */
55
55
  /** @typedef {{schemaVersion: number, ok: boolean, checks: EnvCheck[]}} EnvReport */
56
56
 
@@ -169,9 +169,19 @@ export function checkWorktree(cwd, requireClean) {
169
169
  }
170
170
 
171
171
  /**
172
- * Every runtime the run can route to — initial and failover must resolve to
173
- * a binary that exists and reports a version. A version-less runtime is fatal
174
- * up front because a resume refuses a runtime whose probe came back null.
172
+ * Every runtime the run committed to — a node's or a default's named runtime,
173
+ * and the failover target it declares must resolve to a binary that exists
174
+ * and reports a version. A version-less runtime is fatal up front because a
175
+ * resume refuses a runtime whose probe came back null.
176
+ *
177
+ * A runtime that is merely a candidate is held to a weaker rule: at least one
178
+ * of them has to resolve. A role that names no runtime lets availability
179
+ * discovery choose at dispatch, so every catalogue entry is reachable without
180
+ * any of them being chosen, and demanding a binary for each of them refused a
181
+ * run over a harness it would never have started. Measured 2026-09-21 on the
182
+ * owner's machine: `codex reported no version` blocked a launch whose work was
183
+ * routed to `zcode`. What is still fatal is a catalogue where nothing resolves
184
+ * — then there is no route at all, and discovery has nothing to choose.
175
185
  *
176
186
  * @param {ReachableRuntimes} runtimes
177
187
  * @param {Record<string, string|null>} harnessVersions
@@ -182,20 +192,34 @@ export function checkRuntimeBinaries(runtimes, harnessVersions, cwd = ".") {
182
192
  /** @type {string[]} */
183
193
  const problems = [];
184
194
  /** @type {string[]} */
195
+ const unavailableCandidates = [];
196
+ /** @type {string[]} */
185
197
  const resolved = [];
186
- for (const [id, { runtime }] of runtimes) {
198
+ let candidates = 0;
199
+ let candidatesResolved = 0;
200
+ for (const [id, { runtime, routed }] of runtimes) {
187
201
  // The harness owns the resolution: a per-runtime executable, an
188
202
  // FABERUN_*_BIN override, and each harness's default binary all
189
203
  // land here, and a relative path belongs to the run cwd, not to ours.
190
204
  const executable = getHarness(runtime.harness).executable(runtime);
191
205
  const found = findExecutable(executable.includes("/") || executable.includes("\\") ? resolve(cwd, executable) : executable);
192
206
  const version = harnessVersions[id] ?? null;
193
- if (found === null) problems.push(`${id}: ${executable} not found on PATH`);
194
- else if (version === null) problems.push(`${id}: ${executable} reported no version`);
195
- else resolved.push(`${id} ${version}`);
207
+ if (!routed) candidates += 1;
208
+ const problem = found === null
209
+ ? `${id}: ${executable} not found on PATH`
210
+ : version === null ? `${id}: ${executable} reported no version` : null;
211
+ if (problem === null) {
212
+ resolved.push(`${id} ${version}`);
213
+ if (!routed) candidatesResolved += 1;
214
+ } else if (routed) problems.push(problem);
215
+ else unavailableCandidates.push(problem);
196
216
  }
197
217
  if (problems.length) return fail("runtime binaries", problems.join(" · "));
198
- return pass("runtime binaries", resolved.length ? resolved.join(" · ") : "no routed runtime");
218
+ if (candidates > 0 && candidatesResolved === 0) {
219
+ return fail("runtime binaries", `no catalogue runtime resolves, so availability discovery has nothing to choose: ${unavailableCandidates.join(" · ")}`);
220
+ }
221
+ const aside = unavailableCandidates.length ? ` · not candidates: ${unavailableCandidates.join(" · ")}` : "";
222
+ return pass("runtime binaries", resolved.length ? `${resolved.join(" · ")}${aside}` : "no routed runtime");
199
223
  }
200
224
 
201
225
  /**
@@ -360,10 +384,10 @@ export function timeVerificationCommands(contract, probes = {}) {
360
384
  * can take only one hop, and its reachable set stops at B.
361
385
  *
362
386
  * @param {ValidatedContract} contract
363
- * @returns {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[]}>}
387
+ * @returns {ReachableRuntimes}
364
388
  */
365
389
  export function reachableRuntimes(contract) {
366
- /** @type {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[]}>} */
390
+ /** @type {ReachableRuntimes} */
367
391
  const runtimes = new Map();
368
392
  for (const node of contract.nodes) {
369
393
  for (const role of /** @type {("worker"|"judge")[]} */ (["worker", ...(node.gate.enabled ? ["judge"] : [])])) {
@@ -384,14 +408,22 @@ export function reachableRuntimes(contract) {
384
408
  ? [runtime.requiredCapabilities, node.gate.requiredCapabilities]
385
409
  : [runtime.requiredCapabilities, node.requiredCapabilities];
386
410
  const requiredCapabilitySets = required.filter((item) => item !== undefined);
387
- addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets);
411
+ if (explicit) addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets);
388
412
  const current = { node, role, runtimeId: /** @type {string} */ (runtime.id) };
389
413
  for (const fallbackRuntime of failoverTargets(contract, current)) {
390
- addRuntimeRequirement(runtimes, fallbackRuntime, requiredCapabilitySets);
414
+ addRuntimeRequirement(runtimes, fallbackRuntime, requiredCapabilitySets, Boolean(explicit));
391
415
  }
392
416
  if (!explicit) {
417
+ // Nothing names a runtime for this role, so availability discovery
418
+ // picks one at dispatch and every catalogue entry is a candidate --
419
+ // including the stand-in above, which was chosen as "the first entry"
420
+ // and is no more routed than the rest. They are reachable, so their
421
+ // capabilities still have to hold, but none of them is a runtime this
422
+ // run committed to: requiring a binary for each turned one absent
423
+ // harness into a refusal of a run that would never have used it. The
424
+ // loop covers the stand-in as well -- it is one of the keys.
393
425
  for (const candidate of Object.keys(contract.runtimes)) {
394
- addRuntimeRequirement(runtimes, runtimeSnapshot(contract, candidate), requiredCapabilitySets);
426
+ addRuntimeRequirement(runtimes, runtimeSnapshot(contract, candidate), requiredCapabilitySets, false);
395
427
  }
396
428
  }
397
429
  }
@@ -27,7 +27,7 @@ import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/a
27
27
  import { askPlanningRuntimes, refusePlanningSilence } from "./preflight.mjs";
28
28
  import { parseSpec, validateSpec } from "./spec.mjs";
29
29
  import { collectRepoFacts } from "./repo-facts.mjs";
30
- import { RISK_TIERS, buildPlanningContract, validateFindings, validatePlanOutput } from "./template.mjs";
30
+ import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validateFindings, validatePlanOutput } from "./template.mjs";
31
31
  import { MIN_WRITE_FILES, applySizingRules, provenParallelism } from "./sizing.mjs";
32
32
  import { resolveRuntimes } from "./routing.mjs";
33
33
  import { freezePlan } from "./freeze.mjs";
@@ -135,6 +135,12 @@ export async function runPlanningPipeline(options) {
135
135
  const repoFactsPath = join(scratchDir, "repo-facts.json");
136
136
  writeFileSync(repoFactsPath, `${JSON.stringify(repoFacts, null, 2)}\n`);
137
137
  const relativeRepoFactsPath = relative(cwd, repoFactsPath);
138
+ // The taskKind catalogue is staged like every other planning input. It used
139
+ // to be handed over as faberun's own `src/plan/template.mjs`, which resolves
140
+ // against the target repository and therefore exists in exactly one of them.
141
+ const cataloguePath = join(scratchDir, TASK_KIND_CATALOGUE_FILE);
142
+ writeFileSync(cataloguePath, renderTaskKindCatalogue());
143
+ const relativeCataloguePath = relative(cwd, cataloguePath);
138
144
  logStage("repo-facts", { gitHead: repoFacts.gitHead });
139
145
 
140
146
  let n = 0;
@@ -177,7 +183,7 @@ export async function runPlanningPipeline(options) {
177
183
  return { contract: validated, output };
178
184
  };
179
185
 
180
- const draft = await runStage("draft", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath });
186
+ const draft = await runStage("draft", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath });
181
187
  /** @type {PlanOutput|null} */
182
188
  let plan = null;
183
189
  // Everything still open against the plan in hand, accumulated across rounds
@@ -365,7 +371,7 @@ export async function runPlanningPipeline(options) {
365
371
  const findingsPath = join(scratchDir, `findings-round-${round}.json`);
366
372
  writeJsonAtomic(findingsPath, findings);
367
373
  const revise = await runStage("revise", {
368
- specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, findingsPath: relative(cwd, findingsPath),
374
+ specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, findingsPath),
369
375
  });
370
376
  // Kept for the write-drop comparison: the plan the revise revised, against
371
377
  // the plan it produced.
@@ -23,25 +23,57 @@ import { validateVerificationCommands } from "../contract/verification.mjs";
23
23
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
24
24
  /** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
25
25
  /** @typedef {"low"|"standard"|"high"} RiskTier */
26
- /** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
26
+ /** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, cataloguePath?: string, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
27
27
  /** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], scopeAcknowledged: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[], expectedTurns?: number}} PlanOutputNode */
28
28
  /** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
29
29
  /** @typedef {{id: string, requirementIds: string[], deliverable: string}} PlanPhase */
30
30
  /** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
31
31
 
32
- /**
33
- * The taskKind catalogue a draft or revise classifies against. Exported here,
34
- * not read from a separate document, so `TASK_KIND_CATALOGUE_PATH` (this
35
- * module's own repo-relative path) is a real, always-present file a
36
- * closed-context worker can be told to read for the authoritative list.
37
- */
32
+ /** The taskKind catalogue a draft or revise classifies against. */
38
33
  export const TASK_KINDS = Object.freeze(["docs", "implement", "test", "refactor", "infra", "judge"]);
39
34
 
40
35
  /** The risk tiers a draft or revise classifies against. */
41
36
  export const RISK_TIERS = Object.freeze(["low", "standard", "high"]);
42
37
 
43
- /** This module's own repo-relative path: the taskKind catalogue's home. */
44
- export const TASK_KIND_CATALOGUE_PATH = "src/plan/template.mjs";
38
+ /**
39
+ * The file name the planner stages the catalogue under, beside the spec and
40
+ * the repository facts it already stages.
41
+ *
42
+ * This used to be `src/plan/template.mjs` -- this module's own repo-relative
43
+ * path -- so that a closed-context worker had a real, always-present file to
44
+ * read for the authoritative list. It is always present in *this* repository.
45
+ * A planning contract's `readFiles` resolve against the target repository, and
46
+ * every other repository refuses the contract with `readFiles[2] does not
47
+ * exist: src/plan/template.mjs`, which made `faberun plan` able to plan only
48
+ * faberun. The catalogue is data, so it is written out like the other inputs
49
+ * instead of being pointed at across repository boundaries.
50
+ */
51
+ export const TASK_KIND_CATALOGUE_FILE = "task-kinds.md";
52
+
53
+ /**
54
+ * The catalogue document itself, rendered from the two exported lists so the
55
+ * file a worker reads and the values `validatePlanOutput` accepts cannot
56
+ * drift apart.
57
+ *
58
+ * @returns {string}
59
+ */
60
+ export function renderTaskKindCatalogue() {
61
+ return [
62
+ "# taskKind and riskTier catalogue",
63
+ "",
64
+ "Written by `faberun plan` for this stage. These are the only values a plan may use;",
65
+ "any other value is rejected when the plan is validated.",
66
+ "",
67
+ "## taskKind",
68
+ "",
69
+ ...TASK_KINDS.map((kind) => `- ${kind}`),
70
+ "",
71
+ "## riskTier",
72
+ "",
73
+ ...RISK_TIERS.map((tier) => `- ${tier}`),
74
+ "",
75
+ ].join("\n");
76
+ }
45
77
 
46
78
  /**
47
79
  * Which of the caller's `runtimeDefaults` roles resolves this contract's
@@ -62,8 +94,8 @@ const KIND_ROLE = Object.freeze({
62
94
 
63
95
  /** @type {Record<PlanningKind, string[]>} */
64
96
  const REQUIRED_INPUTS = Object.freeze({
65
- draft: ["specPath", "repoFactsPath"],
66
- revise: ["specPath", "repoFactsPath", "findingsPath"],
97
+ draft: ["specPath", "repoFactsPath", "cataloguePath"],
98
+ revise: ["specPath", "repoFactsPath", "cataloguePath", "findingsPath"],
67
99
  review: ["specPath", "repoFactsPath", "planPath"],
68
100
  "spec-author": ["notesPath"],
69
101
  "spec-review": ["specPath"],
@@ -110,7 +142,7 @@ const OBJECTIVES = Object.freeze({
110
142
  /** @type {Record<PlanningKind, string[]>} */
111
143
  const INSTRUCTIONS = Object.freeze({
112
144
  draft: [
113
- `Consult ${TASK_KIND_CATALOGUE_PATH}'s exported TASK_KINDS before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
145
+ `Consult the ${TASK_KIND_CATALOGUE_FILE} in readFiles before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
114
146
  "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies and the deliverable it produces in one sentence. A phase associated with no requirement is reported as a finding, not refused.",
115
147
  ...SCOPE_CLOSURE_RULE,
116
148
  `Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
@@ -156,9 +188,9 @@ const NON_GOALS = Object.freeze({
156
188
  * @returns {string[]}
157
189
  */
158
190
  function readFilesForKind(kind, inputs) {
159
- if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), TASK_KIND_CATALOGUE_PATH];
191
+ if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath)];
160
192
  if (kind === "revise") {
161
- return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), TASK_KIND_CATALOGUE_PATH, /** @type {string} */ (inputs.findingsPath)];
193
+ return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath), /** @type {string} */ (inputs.findingsPath)];
162
194
  }
163
195
  if (kind === "review") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.planPath)];
164
196
  if (kind === "spec-author") return [/** @type {string} */ (inputs.notesPath)];
@@ -28,7 +28,7 @@ import { chooseLanguage, labelsFor } from "./locale.mjs";
28
28
  import { readNodeSnapshot } from "../run/node-store.mjs";
29
29
  import { campaignDir } from "../campaign/layout.mjs";
30
30
  import { readJournal } from "../campaign/journal.mjs";
31
- import { compareVersions, faberunHome, readUpdateCheck } from "../host/home.mjs";
31
+ import { availableUpdate, faberunHome } from "../host/home.mjs";
32
32
  import { packageVersion } from "../host/package.mjs";
33
33
  import { boundedUtf8, compactTokens } from "../util.mjs";
34
34
  import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
@@ -277,23 +277,23 @@ function footer(view, eventType) {
277
277
  }
278
278
 
279
279
  /**
280
- * One line when the cached update check names a release newer than the one
281
- * running. The cache only, never the network: `faberun update --check`
280
+ * One line when a still-fresh cached update check names a release newer than
281
+ * the one running. The cache only, never the network: `faberun update --check`
282
282
  * refreshes it, and a notification must never wait on a request.
283
283
  *
284
284
  * @param {(key: string) => string} label
285
285
  * @returns {string[]}
286
286
  */
287
287
  function updateLine(label) {
288
- let check;
288
+ let latest;
289
289
  try {
290
- check = readUpdateCheck(faberunHome());
290
+ latest = availableUpdate(faberunHome(), packageVersion());
291
291
  } catch {
292
292
  // An unreadable install root is the banner's problem to report, not the message's.
293
293
  return [];
294
294
  }
295
- if (!check || compareVersions(check.latest, packageVersion()) <= 0) return [];
296
- return [`⬆️ faberun ${check.latest} ${label("available")} · ${label("runUpdate")} faberun update`];
295
+ if (!latest) return [];
296
+ return [`⬆️ faberun ${latest} ${label("available")} · ${label("runUpdate")} faberun update`];
297
297
  }
298
298
 
299
299
  /**
@@ -11,6 +11,7 @@ import { validateNodeSnapshot } from "../contract/snapshot.mjs";
11
11
 
12
12
  /** @typedef {ReturnType<typeof import("./lock.mjs").acquire>} LockHandle */
13
13
  /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
14
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
14
15
 
15
16
  const NODES_DIR_NAME = "nodes";
16
17
 
@@ -60,3 +61,35 @@ export function listNodeSnapshots(runDir) {
60
61
  throw error;
61
62
  }
62
63
  }
64
+
65
+ /**
66
+ * The persisted node snapshots of one run.
67
+ *
68
+ * `tolerateMissing` returns only the snapshots that exist instead of refusing
69
+ * the run. For resume and supervise a node the contract declares and the run
70
+ * never persisted is corruption and stays fatal; for `cancel` it is the
71
+ * ordinary shape of what is being cancelled. A launch writes `contract.json`
72
+ * first and can die before it writes any node -- probing providers, shelling
73
+ * out to git, claiming the run ref -- and the directory then holds a
74
+ * contract, an occupied ref and no node state at all. Such a node started
75
+ * nothing, holds no invocation and no worktree, so there is nothing to
76
+ * terminate and only the git names to release, which is what cancel is for.
77
+ *
78
+ * @param {string} runDir
79
+ * @param {ValidatedContract} contract
80
+ * @param {{tolerateMissing?: boolean}} [options]
81
+ * @returns {NodeSnapshot[]}
82
+ */
83
+ export function readRunNodes(runDir, contract, options = {}) {
84
+ const names = listNodeSnapshots(runDir);
85
+ const expected = new Map(contract.nodes.map((node) => [`${node.id}.json`, node]));
86
+ for (const name of names) if (!expected.has(name)) throw new TypeError(`unexpected persisted node snapshot ${name}`);
87
+ return contract.nodes.flatMap((node) => {
88
+ const name = `${node.id}.json`;
89
+ if (!names.includes(name)) {
90
+ if (options.tolerateMissing === true) return [];
91
+ throw new TypeError(`missing persisted node snapshot ${name}`);
92
+ }
93
+ return [validateNodeSnapshot(readNodeSnapshot(runDir, node.id), node)];
94
+ });
95
+ }
package/src/util.mjs CHANGED
@@ -229,3 +229,49 @@ export function readJsonTolerant(path) {
229
229
  export function finite(value) {
230
230
  return typeof value === "number" && Number.isFinite(value) ? value : null;
231
231
  }
232
+
233
+ /**
234
+ * Split a command string into the words a POSIX shell would pass as argv, for
235
+ * the two readers handed a command as a display string rather than an argv: a
236
+ * Definition of Done `command` proof, whose words `engine/scope.mjs` matches
237
+ * against the files the node wrote, and the node:test filters one declares
238
+ * (`engine/judge-gate.mjs`). Quotes group and are stripped; whitespace
239
+ * outside them separates.
240
+ *
241
+ * Deliberately not a shell: no expansion, no substitution, no operators, and
242
+ * no backslash escape -- on Windows a backslash is a path separator and
243
+ * `C:\Users\x` must survive this intact. The question both callers ask is
244
+ * "which words does this command name", and a quoted path holding a space is
245
+ * one word: splitting it on whitespace produced two fragments that matched no
246
+ * file, so a proof naming a real path read as naming none.
247
+ *
248
+ * @param {string} text
249
+ * @returns {string[]}
250
+ */
251
+ export function shellWords(text) {
252
+ /** @type {string[]} */
253
+ const words = [];
254
+ /** @type {string|null} */
255
+ let current = null;
256
+ /** @type {string|null} */
257
+ let quote = null;
258
+ for (const character of text) {
259
+ if (quote === null && /\s/u.test(character)) {
260
+ if (current !== null) words.push(current);
261
+ current = null;
262
+ continue;
263
+ }
264
+ if (quote === null && (character === '"' || character === "'")) {
265
+ quote = character;
266
+ current ??= "";
267
+ continue;
268
+ }
269
+ if (quote === character) {
270
+ quote = null;
271
+ continue;
272
+ }
273
+ current = (current ?? "") + character;
274
+ }
275
+ if (current !== null) words.push(current);
276
+ return words;
277
+ }