faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Environment preflight: the host facts a run depends on, checked before the
3
+ * first dispatch and again by `doctor` on demand.
4
+ *
5
+ * Four checks gate a dispatch — free disk, a functional git, the worktree
6
+ * state, and every routed runtime binary present and versioned. A gate that
7
+ * fails leaves the run materialized and resumable: the controller records the
8
+ * report as run evidence and stops, so the operator fixes the host and
9
+ * resumes instead of starting over and paying for the finished nodes twice.
10
+ *
11
+ * A check may be advisory, meaning it reports a fact without blocking: a
12
+ * merely dirty worktree is normal in this repository (the run captures a
13
+ * dirtyTreeFingerprint for it), while unmerged paths or an interrupted git
14
+ * operation are not, because a worker's scope diff cannot be read against
15
+ * them. Set FABERUN_REQUIRE_CLEAN_WORKTREE=1 to make any dirt fatal.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync, readFileSync, statfsSync } from "node:fs";
19
+ import { delimiter, join, resolve } from "node:path";
20
+ import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, getHarness, probeRuntime } from "../harnesses/index.mjs";
21
+ import { addRuntimeRequirement, failoverTargets, runtimeSnapshot } from "../engine/failover.mjs";
22
+ import { validateContract } from "../contract/index.mjs";
23
+ import { DISCOVERY_RUNTIME_DEFINITIONS, discoverRuntimes } from "../engine/runtime-discovery.mjs";
24
+ import { errorMessage } from "../util.mjs";
25
+ import { boundedGitSync } from "../repo/worktree.mjs";
26
+ import { routeRuntime } from "../contract/runtime.mjs";
27
+ import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
28
+ import { colorLevel, statusToken } from "../cli/brand.mjs";
29
+
30
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
31
+ /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
32
+ /** @typedef {import("../harnesses/index.mjs").CapabilityRequirements} CapabilityRequirements */
33
+ /** @typedef {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: CapabilityRequirements[]}>} ReachableRuntimes */
34
+ /** @typedef {{name: string, ok: boolean, advisory: boolean, detail: string}} EnvCheck */
35
+ /** @typedef {{schemaVersion: number, ok: boolean, checks: EnvCheck[]}} EnvReport */
36
+
37
+ const ENV_PREFLIGHT_SCHEMA_VERSION = 1;
38
+
39
+ /** Free space below this leaves no room for logs, capsules, and snapshots. */
40
+ const DEFAULT_MIN_FREE_DISK_BYTES = 512 * 1024 * 1024;
41
+
42
+ /** Worktree states in which a scope diff is not readable. */
43
+ const GIT_IN_PROGRESS = Object.freeze({
44
+ MERGE_HEAD: "merge",
45
+ CHERRY_PICK_HEAD: "cherry-pick",
46
+ REVERT_HEAD: "revert",
47
+ BISECT_LOG: "bisect",
48
+ });
49
+
50
+ /** @param {string} name @param {string} detail @returns {EnvCheck} */
51
+ const pass = (name, detail) => ({ name, ok: true, advisory: false, detail });
52
+
53
+ /** @param {string} name @param {string} detail @param {boolean} [advisory] @returns {EnvCheck} */
54
+ const fail = (name, detail, advisory = false) => ({ name, ok: false, advisory, detail });
55
+
56
+ /**
57
+ * @param {string} dir
58
+ * @param {string[]} args
59
+ * @returns {{status: number|null, stdout: string}}
60
+ */
61
+ function git(dir, args) {
62
+ const result = boundedGitSync(["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
63
+ return { status: result.error ? null : result.status, stdout: String(result.stdout ?? "") };
64
+ }
65
+
66
+ /** @param {NodeJS.ProcessEnv} env @returns {number} */
67
+ export function minFreeDiskBytes(env) {
68
+ const raw = env.FABERUN_MIN_FREE_DISK_BYTES;
69
+ if (raw === undefined) return DEFAULT_MIN_FREE_DISK_BYTES;
70
+ const parsed = Number(raw);
71
+ if (!Number.isFinite(parsed) || parsed < 0) throw new TypeError("FABERUN_MIN_FREE_DISK_BYTES must be a non-negative number of bytes");
72
+ return parsed;
73
+ }
74
+
75
+ /** @param {number} bytes */
76
+ function formatBytes(bytes) {
77
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
78
+ let value = bytes;
79
+ let unit = 0;
80
+ while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
81
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
82
+ }
83
+
84
+ /**
85
+ * Free space on the filesystem holding the run. A filesystem that cannot
86
+ * report statfs is advisory: an unknown figure must not block a dispatch.
87
+ *
88
+ * @param {string} cwd
89
+ * @param {number} minFreeBytes
90
+ * @returns {EnvCheck}
91
+ */
92
+ export function checkDisk(cwd, minFreeBytes) {
93
+ let free;
94
+ try {
95
+ const stats = statfsSync(cwd);
96
+ free = Number(stats.bsize) * Number(stats.bavail);
97
+ } catch (error) {
98
+ return fail("disk", `free space unavailable: ${error instanceof Error ? error.message : String(error)}`, true);
99
+ }
100
+ const detail = `${formatBytes(free)} free · threshold ${formatBytes(minFreeBytes)}`;
101
+ return free >= minFreeBytes ? pass("disk", detail) : fail("disk", `${detail} · free at least ${formatBytes(minFreeBytes - free)} more`);
102
+ }
103
+
104
+ /**
105
+ * A functional git, not merely a git on PATH: the run reads HEAD and diffs
106
+ * the worktree through it, so a git that cannot execute is fatal.
107
+ *
108
+ * @param {string} cwd
109
+ * @returns {EnvCheck}
110
+ */
111
+ export function checkGit(cwd) {
112
+ const version = boundedGitSync(["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
113
+ if (version.error || version.status !== 0) return fail("git", `git is not executable: ${version.error ? version.error.message : `exit ${version.status}`}`);
114
+ const label = String(version.stdout ?? "").trim() || "git";
115
+ if (!existsSync(cwd)) return fail("git", `${label} · cwd does not exist: ${cwd}`);
116
+ const inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
117
+ if (inside.status !== 0 || inside.stdout.trim() !== "true") return fail("git", `${label} · execution requires a git work tree with at least one commit`);
118
+ const head = git(cwd, ["rev-parse", "HEAD"]);
119
+ if (head.status !== 0) return fail("git", `${label} · repository must have at least one commit before an isolated execution can start`);
120
+ return pass("git", `${label} · HEAD ${head.stdout.trim().slice(0, 12)}`);
121
+ }
122
+
123
+ /**
124
+ * @param {string} cwd
125
+ * @param {boolean} requireClean
126
+ * @returns {EnvCheck}
127
+ */
128
+ export function checkWorktree(cwd, requireClean) {
129
+ const inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
130
+ if (inside.status !== 0 || inside.stdout.trim() !== "true") return pass("worktree", `${cwd} is not a git work tree; nothing to compare`);
131
+ const gitDir = git(cwd, ["rev-parse", "--git-dir"]);
132
+ const root = gitDir.status === 0 ? resolve(cwd, gitDir.stdout.trim()) : null;
133
+ if (root) {
134
+ if (existsSync(join(root, "rebase-merge")) || existsSync(join(root, "rebase-apply"))) return fail("worktree", "a rebase is in progress; finish or abort it before dispatching");
135
+ for (const [file, operation] of Object.entries(GIT_IN_PROGRESS)) {
136
+ if (existsSync(join(root, file))) return fail("worktree", `a ${operation} is in progress; finish or abort it before dispatching`);
137
+ }
138
+ }
139
+ const status = git(cwd, ["status", "--porcelain"]);
140
+ if (status.status !== 0) return fail("worktree", "git status failed; the worktree state is unknown");
141
+ const lines = status.stdout.split("\n").filter((line) => line.trim());
142
+ const conflicted = lines.filter((line) => /^(DD|AU|UD|UA|DU|AA|UU)/u.test(line));
143
+ if (conflicted.length) return fail("worktree", `${conflicted.length} unmerged path${conflicted.length === 1 ? "" : "s"}; resolve the conflict before dispatching`);
144
+ if (!lines.length) return pass("worktree", "clean");
145
+ const detail = `${lines.length} dirty path${lines.length === 1 ? "" : "s"}`;
146
+ return requireClean
147
+ ? fail("worktree", `${detail}; FABERUN_REQUIRE_CLEAN_WORKTREE demands a clean tree`)
148
+ : fail("worktree", `${detail}; recorded in the run's dirtyTreeFingerprint`, true);
149
+ }
150
+
151
+ /**
152
+ * Every runtime the run can route to — initial and failover — must resolve to
153
+ * a binary that exists and reports a version. A version-less runtime is fatal
154
+ * up front because a resume refuses a runtime whose probe came back null.
155
+ *
156
+ * @param {ReachableRuntimes} runtimes
157
+ * @param {Record<string, string|null>} harnessVersions
158
+ * @param {string} [cwd] the run cwd a relative executable is resolved against
159
+ * @returns {EnvCheck}
160
+ */
161
+ export function checkRuntimeBinaries(runtimes, harnessVersions, cwd = ".") {
162
+ /** @type {string[]} */
163
+ const problems = [];
164
+ /** @type {string[]} */
165
+ const resolved = [];
166
+ for (const [id, { runtime }] of runtimes) {
167
+ // The harness owns the resolution: a per-runtime executable, an
168
+ // FABERUN_*_BIN override, and each harness's default binary all
169
+ // land here, and a relative path belongs to the run cwd, not to ours.
170
+ const executable = getHarness(runtime.harness).executable(runtime);
171
+ const found = findExecutable(executable.includes("/") || executable.includes("\\") ? resolve(cwd, executable) : executable);
172
+ const version = harnessVersions[id] ?? null;
173
+ if (found === null) problems.push(`${id}: ${executable} not found on PATH`);
174
+ else if (version === null) problems.push(`${id}: ${executable} reported no version`);
175
+ else resolved.push(`${id} ${version}`);
176
+ }
177
+ if (problems.length) return fail("runtime binaries", problems.join(" · "));
178
+ return pass("runtime binaries", resolved.length ? resolved.join(" · ") : "no routed runtime");
179
+ }
180
+
181
+ /**
182
+ * @param {{cwd: string, runtimes: ReachableRuntimes, harnessVersions?: Record<string, string|null>, env?: NodeJS.ProcessEnv}} options
183
+ * @returns {EnvReport}
184
+ */
185
+ export function environmentPreflight(options) {
186
+ const env = options.env ?? process.env;
187
+ const cwd = options.cwd;
188
+ const checks = [
189
+ checkDisk(cwd, minFreeDiskBytes(env)),
190
+ checkGit(cwd),
191
+ checkWorktree(cwd, env.FABERUN_REQUIRE_CLEAN_WORKTREE === "1"),
192
+ checkRuntimeBinaries(options.runtimes, options.harnessVersions ?? {}, cwd),
193
+ ];
194
+ return { schemaVersion: ENV_PREFLIGHT_SCHEMA_VERSION, ok: checks.every((check) => check.ok || check.advisory), checks };
195
+ }
196
+
197
+ /**
198
+ * The named no-transport check, advisory so it never gates a dispatch. It is
199
+ * rendered by `preflight` and warned about by `doctor` and the foreground
200
+ * launch, but it is kept out of `environmentPreflight`'s own check set so the
201
+ * dispatch gate stays exactly the host facts it always was.
202
+ *
203
+ * @param {NodeJS.ProcessEnv} [env]
204
+ * @returns {EnvCheck}
205
+ */
206
+ export function notifyTransportCheck(env = process.env) {
207
+ const warning = noTransportWarning(env);
208
+ return warning
209
+ ? fail("notify transport", warning, true)
210
+ : pass("notify transport", `${NOTIFY_BIN_ENV}=${env[NOTIFY_BIN_ENV]}`);
211
+ }
212
+
213
+ /** @param {EnvReport} report @returns {EnvCheck[]} the checks that block a dispatch */
214
+ export function blockingChecks(report) {
215
+ return report.checks.filter((check) => !check.ok && !check.advisory);
216
+ }
217
+
218
+ /** The share of its declared timeout a command may take before it is a warning. */
219
+ const VERIFICATION_DURATION_WARN_RATIO = 0.8;
220
+
221
+ /**
222
+ * Every distinct verification command the contract declares, with the
223
+ * strictest timeout any node gives it and the nodes that share it.
224
+ *
225
+ * Commands are keyed by argv and cwd, never merged across different argv, so
226
+ * one measurement stands in for every node that declares the same command —
227
+ * a contract that puts `npm test` on three nodes gets timed once.
228
+ *
229
+ * @param {ValidatedContract} contract
230
+ * @returns {{argv: string[], cwd?: string, timeoutSec: number, nodes: string[]}[]}
231
+ */
232
+ export function declaredVerificationCommands(contract) {
233
+ /** @type {Map<string, {argv: string[], cwd?: string, timeoutSec: number, nodes: string[]}>} */
234
+ const commands = new Map();
235
+ const declarations = [
236
+ ...contract.nodes.flatMap((node) => (node.taskPacket?.verification ?? []).map((command) => ({ command, node: node.id }))),
237
+ ...(contract.finalVerification ?? []).map((command) => ({ command, node: "finalVerification" })),
238
+ ];
239
+ for (const { command, node } of declarations) {
240
+ const timeoutSec = command.timeoutSec ?? 120;
241
+ const key = JSON.stringify([command.argv, command.cwd ?? null]);
242
+ const existing = commands.get(key);
243
+ if (existing) {
244
+ existing.timeoutSec = Math.min(existing.timeoutSec, timeoutSec);
245
+ if (!existing.nodes.includes(node)) existing.nodes.push(node);
246
+ } else {
247
+ commands.set(key, { argv: command.argv, cwd: command.cwd, timeoutSec, nodes: [node] });
248
+ }
249
+ }
250
+ return [...commands.values()];
251
+ }
252
+
253
+ /**
254
+ * Run every declared verification command once and report what it actually
255
+ * costs against the timeout the contract gives it.
256
+ *
257
+ * A verification entry is capped at 600s by the schema, and nothing else in
258
+ * the toolchain measures whether a command fits: a suite that grows past its
259
+ * declared timeout only announces itself by failing a node that did its work
260
+ * correctly, after the tokens are spent. Campaign
261
+ * faberun-suite-speed-20260909 lost roughly 49 minutes and two nodes
262
+ * to exactly that — `npm test` at 644s against a declared 600s.
263
+ *
264
+ * A non-zero exit is reported, never failed on: a node may legitimately be
265
+ * the thing that turns a red command green. Only duration decides `ok`.
266
+ *
267
+ * @param {ValidatedContract} contract
268
+ * @param {{now?: () => number, run?: typeof spawnSync}} [probes] injectable for tests
269
+ * @returns {EnvCheck[]}
270
+ */
271
+ export function timeVerificationCommands(contract, probes = {}) {
272
+ const now = probes.now ?? (() => Date.now());
273
+ const run = probes.run ?? spawnSync;
274
+ return declaredVerificationCommands(contract).map((command) => {
275
+ const label = command.argv.join(" ");
276
+ const name = `verification timing · ${label}`;
277
+ const shared = command.nodes.length > 1 ? ` · declared by ${command.nodes.join(", ")}` : "";
278
+ // Let a slow command overrun its declared timeout so the report can say by
279
+ // how much; killing it at the declared value would only prove "at least".
280
+ const ceilingSec = Math.min(Math.max(command.timeoutSec * 2, command.timeoutSec + 120), 1800);
281
+ const startedAt = now();
282
+ const result = run(command.argv[0], command.argv.slice(1), {
283
+ cwd: command.cwd ? resolve(contract.cwd, command.cwd) : contract.cwd,
284
+ timeout: ceilingSec * 1_000,
285
+ stdio: "ignore",
286
+ encoding: "utf8",
287
+ });
288
+ const seconds = (now() - startedAt) / 1_000;
289
+ const measured = `${seconds.toFixed(1)}s measured against ${command.timeoutSec}s declared`;
290
+ const exit = result.status === null ? `killed by ${result.signal ?? "timeout"}` : `exit ${result.status}`;
291
+ if (result.error && /** @type {{code?: string}} */ (result.error).code === "ENOENT") {
292
+ return fail(name, `${command.argv[0]} is not on PATH${shared}`);
293
+ }
294
+ if (seconds >= command.timeoutSec) {
295
+ return fail(name, `${measured}: this command cannot pass its own verification entry${shared} · ${exit}`);
296
+ }
297
+ if (seconds >= command.timeoutSec * VERIFICATION_DURATION_WARN_RATIO) {
298
+ return fail(name, `${measured}: within ${Math.round((1 - VERIFICATION_DURATION_WARN_RATIO) * 100)}% of the cap, so growth will break it${shared} · ${exit}`, true);
299
+ }
300
+ return pass(name, `${measured}${shared} · ${exit}`);
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Collect initial worker/judge runtimes and every runtime reachable through
306
+ * the one declared fallback hop, preserving each capability requirement so a
307
+ * runtime a run might fall over to is checked before it runs.
308
+ *
309
+ * The enumeration is exactly the reachable-state set the run can actually
310
+ * occupy: a node's role starts on its assigned runtime, and — if that
311
+ * runtime declares a `fallback` — may take exactly one hop to it. It never
312
+ * re-derives `failoverTargets` from the hop target itself, so a chain like
313
+ * A.fallback=B, B.fallback=C never probes C for a node assigned A: that node
314
+ * can take only one hop, and its reachable set stops at B.
315
+ *
316
+ * @param {ValidatedContract} contract
317
+ * @returns {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[]}>}
318
+ */
319
+ export function reachableRuntimes(contract) {
320
+ /** @type {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[]}>} */
321
+ const runtimes = new Map();
322
+ for (const node of contract.nodes) {
323
+ for (const role of /** @type {("worker"|"judge")[]} */ (["worker", ...(node.gate.enabled ? ["judge"] : [])])) {
324
+ const explicit = role === "judge" ? node.gate.runtime ?? contract.runtimeDefaults?.judge : node.runtime ?? contract.runtimeDefaults?.worker;
325
+ const fallbackId = role === "worker"
326
+ ? Object.keys(contract.runtimes)[0]
327
+ : Object.entries(contract.runtimes).find(([, candidate]) => candidate.vendor !== contract.runtimes[Object.keys(contract.runtimes)[0]]?.vendor)?.[0]
328
+ ?? Object.keys(contract.runtimes)[0];
329
+ if (!fallbackId) throw new Error("runtime discovery catalogue is empty");
330
+ const runtime = explicit
331
+ ? /** @type {RuntimeSnapshot} */ (routeRuntime(contract, node, role))
332
+ : runtimeSnapshot(contract, fallbackId);
333
+ // The judge role carries no extra capability set: the verdict contract
334
+ // is enforced at the review boundary (judgePrompt embeds the schema in
335
+ // the prompt text, parseJudge validates, the bounded re-ask arbiters),
336
+ // so a harness without a schema channel — zcode — can still judge.
337
+ const required = role === "judge"
338
+ ? [runtime.requiredCapabilities, node.gate.requiredCapabilities]
339
+ : [runtime.requiredCapabilities, node.requiredCapabilities];
340
+ const requiredCapabilitySets = required.filter((item) => item !== undefined);
341
+ addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets);
342
+ const current = { node, role, runtimeId: /** @type {string} */ (runtime.id) };
343
+ for (const fallbackRuntime of failoverTargets(contract, current)) {
344
+ addRuntimeRequirement(runtimes, fallbackRuntime, requiredCapabilitySets);
345
+ }
346
+ if (!explicit) {
347
+ for (const candidate of Object.keys(contract.runtimes)) {
348
+ addRuntimeRequirement(runtimes, runtimeSnapshot(contract, candidate), requiredCapabilitySets);
349
+ }
350
+ }
351
+ }
352
+ }
353
+ return runtimes;
354
+ }
355
+
356
+ const HARNESS_BIN_OVERRIDES = Object.freeze({
357
+ codex: "FABERUN_CODEX_BIN",
358
+ claude: "FABERUN_CLAUDE_BIN",
359
+ agy: "FABERUN_AGY_BIN",
360
+ zcode: "FABERUN_ZCODE_BIN",
361
+ "exec-jsonl": "FABERUN_EXEC_JSONL_BIN",
362
+ });
363
+
364
+ /**
365
+ * Mutation-free environment doctor: repository prerequisites, ignored .runs,
366
+ * required binaries, the dispatch environment gate, and (when a contract is
367
+ * given) schema and harness versions.
368
+ *
369
+ * @param {string|undefined} contractPath
370
+ * @param {{cwd?: string, json?: boolean, discover?: boolean}} values
371
+ * @returns {Promise<boolean>}
372
+ */
373
+ export async function doctorCommand(contractPath, values) {
374
+ const repoDir = resolve(values.cwd ?? ".");
375
+ /** @type {{name: string, ok: boolean, detail: string}[]} */
376
+ const checks = [];
377
+ const gitRepo = isGitWorkTree(repoDir);
378
+ checks.push({ name: "git repository", ok: gitRepo, detail: gitRepo ? repoDir : "not inside a git work tree" });
379
+ const runsIgnored = isRunsIgnored(repoDir);
380
+ checks.push({
381
+ name: ".runs ignored",
382
+ ok: runsIgnored,
383
+ detail: runsIgnored ? ".runs/ is git-ignored" : ".runs/ is not git-ignored; add .runs/ to .gitignore",
384
+ });
385
+ for (const binary of ["node", "npm"]) {
386
+ const found = findExecutable(binary);
387
+ checks.push({ name: `binary ${binary}`, ok: found !== null, detail: found ?? "not found on PATH" });
388
+ }
389
+ checks.push({ name: "runner schema", ok: true, detail: `protocol ${PROTOCOL_SCHEMA_VERSION} · runner ${CONTRACT_VERSION}` });
390
+ /** @type {Set<string>} */
391
+ let usedHarnesses = new Set();
392
+ /** @type {Set<string>} */
393
+ const overriddenHarnesses = new Set();
394
+ /** @type {ReachableRuntimes} */
395
+ let routedRuntimes = new Map();
396
+ /** @type {Record<string, string|null>} */
397
+ const harnessVersions = {};
398
+ /** @type {Record<string, import("../engine/runtime-discovery.mjs").RuntimeAvailability>} */
399
+ let discovered = {};
400
+ let dispatchCwd = repoDir;
401
+ if (contractPath) {
402
+ const absolute = resolve(contractPath);
403
+ try {
404
+ const contract = validateContract(JSON.parse(readFileSync(absolute, "utf8")), absolute);
405
+ checks.push({ name: "contract", ok: true, detail: `${contract.id} · ${contract.nodes.length} node${contract.nodes.length === 1 ? "" : "s"}` });
406
+ const runtimes = reachableRuntimes(contract);
407
+ routedRuntimes = runtimes;
408
+ dispatchCwd = contract.cwd;
409
+ usedHarnesses = new Set([...runtimes.values()].map(({ runtime }) => runtime.harness));
410
+ for (const runtime of Object.values(contract.runtimes)) {
411
+ if (typeof runtime.executable === "string") overriddenHarnesses.add(runtime.harness);
412
+ }
413
+ for (const [id, { runtime, requiredCapabilitySets }] of runtimes) {
414
+ const probe = await probeRuntime(runtime, { cwd: contract.cwd, requiredCapabilitySets });
415
+ harnessVersions[id] = probe.version;
416
+ checks.push({ name: `harness ${probe.id ?? runtime.harness}`, ok: probe.ok, detail: probe.detail ?? (probe.ok ? "ok" : "probe failed") });
417
+ }
418
+ } catch (error) {
419
+ checks.push({ name: "contract", ok: false, detail: errorMessage(error) });
420
+ }
421
+ } else {
422
+ checks.push({ name: "contract", ok: true, detail: "no contract.json provided; skipping runtime probes" });
423
+ }
424
+ if (values.discover === true) {
425
+ const discoveryRuntimes = contractPath
426
+ ? (() => {
427
+ try { return validateContract(JSON.parse(readFileSync(resolve(contractPath), "utf8")), resolve(contractPath)).runtimes; } catch { return {}; }
428
+ })()
429
+ : /** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */ (DISCOVERY_RUNTIME_DEFINITIONS);
430
+ discovered = await discoverRuntimes(discoveryRuntimes, { cwd: dispatchCwd });
431
+ const available = Object.values(discovered).filter((entry) => entry.available).length;
432
+ checks.push({
433
+ name: "runtime discovery",
434
+ ok: available > 0,
435
+ detail: Object.entries(discovered).map(([id, entry]) => `${id}: ${entry.available ? "available" : `unavailable (${entry.reason})`}${entry.exhaustedUntil ? ` until ${entry.exhaustedUntil}` : ""}`).join(" · ") || "no runtimes discovered",
436
+ });
437
+ }
438
+ // A PATH-only check must not fail a runtime whose binary is supplied through
439
+ // an explicit executable or a FABERUN_*_BIN override; the harness probe above
440
+ // already validated whatever the runtime actually resolves to. `zcode` is
441
+ // absent on purpose — its binary is a shim the harness writes on first use, so
442
+ // a PATH miss here is the normal state of a fresh machine, not a missing
443
+ // dependency; `dsh` runs through its own SDK client, not a PATH binary.
444
+ for (const binary of ["codex", "claude", "agy", "exec-jsonl"]) {
445
+ const overrideName = /** @type {Record<string, string>} */ (HARNESS_BIN_OVERRIDES)[binary];
446
+ const overridden = overriddenHarnesses.has(binary) || Boolean(process.env[overrideName]);
447
+ const found = findExecutable(binary);
448
+ const required = usedHarnesses.has(binary) && !overridden;
449
+ checks.push({
450
+ name: `binary ${binary}`,
451
+ ok: !required || found !== null,
452
+ detail: overridden && !found ? "resolved via executable or env override" : required ? (found ?? "required by contract but not found on PATH") : (found ? "present" : "not on PATH (not required by this contract)"),
453
+ });
454
+ }
455
+ for (const check of environmentPreflight({ cwd: dispatchCwd, runtimes: routedRuntimes, harnessVersions }).checks) {
456
+ checks.push({ name: check.name, ok: check.ok || check.advisory, detail: check.ok ? check.detail : `${check.detail} (advisory)` });
457
+ }
458
+ const ok = checks.every((check) => check.ok);
459
+ const transportWarning = noTransportWarning(process.env);
460
+ if (transportWarning) process.stderr.write(`${statusToken("warn", colorLevel(process.env, process.stderr.isTTY))} ${transportWarning}\n`);
461
+ if (values.json === true) {
462
+ process.stdout.write(`${JSON.stringify({ schemaVersion: 1, repo: repoDir, ok, checks, ...(values.discover === true ? { runtimes: discovered } : {}) }, null, 2)}\n`);
463
+ } else {
464
+ const level = colorLevel(process.env, process.stdout.isTTY);
465
+ for (const check of checks) process.stdout.write(`${statusToken(check.ok ? "ok" : "fail", level)} ${check.name} · ${check.detail}\n`);
466
+ }
467
+ return ok;
468
+ }
469
+
470
+ /**
471
+ * @param {string} repoDir
472
+ * @returns {boolean}
473
+ */
474
+ function isGitWorkTree(repoDir) {
475
+ if (existsSync(join(repoDir, ".git"))) return true;
476
+ try {
477
+ const result = boundedGitSync(["-C", repoDir, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
478
+ return result.status === 0 && String(result.stdout).trim() === "true";
479
+ } catch {
480
+ return false;
481
+ }
482
+ }
483
+
484
+ /**
485
+ * @param {string} repoDir
486
+ * @returns {boolean}
487
+ */
488
+ function isRunsIgnored(repoDir) {
489
+ try {
490
+ const result = boundedGitSync(["-C", repoDir, "check-ignore", "-q", ".runs"], { stdio: ["ignore", "ignore", "ignore"] });
491
+ if (result.status === 0) return true;
492
+ } catch {
493
+ // A git that cannot run leaves the check-ignore answer unknown; fall through to reading .gitignore directly.
494
+ }
495
+ try {
496
+ const gitignore = readFileSync(join(repoDir, ".gitignore"), "utf8");
497
+ return gitignore.split(/\r?\n/u).some((line) => /^\.runs\/?$/u.test(line.trim()));
498
+ } catch {
499
+ return false;
500
+ }
501
+ }
502
+
503
+ /**
504
+ * The first directory on PATH that holds `name`, or null. Exported because the
505
+ * banner counts the harness binaries with the same lookup the doctor uses,
506
+ * rather than a second copy that can disagree.
507
+ *
508
+ * @param {string} name
509
+ * @returns {string|null}
510
+ */
511
+ export function findExecutable(name) {
512
+ if (name.includes("/") || name.includes("\\")) return existsSync(name) ? name : null;
513
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
514
+ if (!dir) continue;
515
+ const candidate = join(dir, name);
516
+ if (existsSync(candidate)) return candidate;
517
+ }
518
+ return null;
519
+ }
520
+