faberun 0.26.3 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +11 -4
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +6 -1
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +68 -8
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +6 -2
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +61 -13
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +54 -2
  45. package/src/harnesses/claude/index.mjs +177 -2
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +18 -116
  48. package/src/harnesses/dsh/runner.mjs +1 -17
  49. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  50. package/src/harnesses/fx/home.mjs +57 -0
  51. package/src/harnesses/fx/index.mjs +137 -0
  52. package/src/harnesses/fx/native/build.zig +20 -0
  53. package/src/harnesses/fx/native/build.zig.zon +7 -0
  54. package/src/harnesses/fx/native/home.zig +85 -0
  55. package/src/harnesses/fx/native/main.zig +503 -0
  56. package/src/harnesses/fx/native/permissions.zig +51 -0
  57. package/src/harnesses/fx/native/relay.zig +253 -0
  58. package/src/harnesses/fx/native/usage.zig +112 -0
  59. package/src/harnesses/fx/permissions.mjs +35 -0
  60. package/src/harnesses/fx/runner.mjs +350 -0
  61. package/src/harnesses/fx/usage-proxy.mjs +176 -0
  62. package/src/harnesses/index.mjs +29 -14
  63. package/src/harnesses/replay/index.mjs +12 -0
  64. package/src/harnesses/runner-io.mjs +25 -0
  65. package/src/harnesses/runner-transcript.mjs +127 -0
  66. package/src/harnesses/session-metrics.mjs +38 -13
  67. package/src/harnesses/zcode/index.mjs +19 -0
  68. package/src/host/config.mjs +8 -2
  69. package/src/host/environment-report.mjs +85 -0
  70. package/src/host/home.mjs +27 -0
  71. package/src/host/install-registry.mjs +511 -0
  72. package/src/host/preflight.mjs +34 -10
  73. package/src/host/uninstall-roots.mjs +196 -0
  74. package/src/plan/proof-check.mjs +15 -0
  75. package/src/plan/repo-facts.mjs +95 -11
  76. package/src/plan/rounds.mjs +27 -0
  77. package/src/repo/declared-paths.mjs +74 -6
  78. package/src/repo/integrate.mjs +1 -1
  79. package/src/repo/scope-closure.mjs +59 -0
  80. package/src/repo/signal-block.mjs +26 -0
  81. package/src/repo/signal.mjs +9 -24
  82. package/src/repo/workspace.mjs +189 -14
  83. package/src/report/campaign-brief.mjs +4 -0
  84. package/src/report/message.mjs +37 -6
  85. package/src/report/progress.mjs +13 -2
  86. package/src/run/paths.mjs +14 -1
  87. package/src/run/usage.mjs +66 -1
  88. package/src/util.mjs +18 -2
@@ -0,0 +1,511 @@
1
+ /**
2
+ * The install registry under `$FABERUN_HOME`.
3
+ *
4
+ * Faberun writes outside a target repository in three shapes: a skill tree
5
+ * registered in a harness's skills directory, an entry added inside a tool's
6
+ * own settings file (the status line and the tool-policy hook), and the
7
+ * version/project layout under `$FABERUN_HOME`. The first two must be
8
+ * reversible one artifact at a time -- only the entries faberun added, never a
9
+ * neighbour the operator keeps in the same directory or settings file -- so
10
+ * this module records each write with its absolute path, the install-site root
11
+ * that owns it, and, for a settings file, the exact JSON Pointer and value
12
+ * added.
13
+ *
14
+ * `KNOWN_INSTALL_SITES` is the published manifest of where those writes land.
15
+ * The registry only knows what was recorded after it shipped; an artifact an
16
+ * older faberun installed, or one written before the registry file existed, is
17
+ * found by walking the manifest's well-known sites and testing ownership.
18
+ * `faberun uninstall` merges the recorded entries with this manifest, so it
19
+ * can list and remove artifacts that predate the registry.
20
+ *
21
+ * `src/cli/init.mjs` needs no per-artifact entry. Read on 2026-09-27, it writes
22
+ * only inside the target repository -- `.gitignore`, `.claude/skills`, and the
23
+ * agent kit the installer lays down there -- while the project layout it can
24
+ * trigger is written under the effective `$FABERUN_HOME`. Removal deletes
25
+ * `$FABERUN_HOME` wholesale, so every out-of-repository write init can cause is
26
+ * covered by that one delete; registering each project directory would add
27
+ * entries removal ignores and could never be trusted to be complete.
28
+ *
29
+ * The registry is JSON at `$FABERUN_HOME/install-registry.json`, written with
30
+ * the same temp-then-rename discipline as the rest of the home, so a reader
31
+ * never observes a half-written record. A missing or malformed registry is
32
+ * treated as empty rather than fatal: it is a ledger of things to remove, and
33
+ * refusing to read it would block the command that removes them.
34
+ */
35
+ import { join } from "node:path";
36
+ import { readJson, writeJsonAtomic } from "../run/store.mjs";
37
+ import { errorCode } from "../util.mjs";
38
+ import { faberunHome } from "./home.mjs";
39
+
40
+ /** The registry file name under `$FABERUN_HOME`. */
41
+ export const INSTALL_REGISTRY_FILE = "install-registry.json";
42
+
43
+ /** The registry schema this module reads and writes. */
44
+ export const INSTALL_REGISTRY_SCHEMA_VERSION = 1;
45
+
46
+ /**
47
+ * @param {string} home
48
+ * @returns {string} the absolute path of the registry file
49
+ */
50
+ export function installRegistryPath(home) {
51
+ return join(home, INSTALL_REGISTRY_FILE);
52
+ }
53
+
54
+ /**
55
+ * One place faberun is known to write, expressed home-relatively so the
56
+ * manifest is one frozen table and every absolute path is derived from it.
57
+ *
58
+ * @typedef {object} InstallSiteSpec
59
+ * @property {string} harness the operator harness whose site this is
60
+ * @property {readonly string[]} segments the path below `$HOME`
61
+ * @property {string} [key] the settings key faberun owns at this file
62
+ */
63
+
64
+ /**
65
+ * The known install-site manifest: skill destination directories, the settings
66
+ * files that carry the status-line entry, and the hook destinations. This is
67
+ * the fallback for an artifact whose registry entry does not exist, so it names
68
+ * only sites faberun writes and never a target repository's files.
69
+ *
70
+ * @type {Readonly<{skills: readonly InstallSiteSpec[], settings: readonly InstallSiteSpec[], hooks: readonly InstallSiteSpec[]}>}
71
+ */
72
+ export const KNOWN_INSTALL_SITES = Object.freeze({
73
+ skills: Object.freeze([
74
+ Object.freeze({ harness: "claude", segments: Object.freeze([".claude", "skills"]) }),
75
+ Object.freeze({ harness: "codex", segments: Object.freeze([".codex", "skills"]) }),
76
+ Object.freeze({ harness: "zcode", segments: Object.freeze([".agents", "skills"]) }),
77
+ Object.freeze({ harness: "agy", segments: Object.freeze([".gemini", "config", "skills"]) }),
78
+ // The shared convention zcode points at. It is listed on its own because a
79
+ // harness skills directory is often a symlink into it, and a machine may
80
+ // carry it without the harness that introduced it.
81
+ Object.freeze({ harness: "agents", segments: Object.freeze([".agents", "skills"]) }),
82
+ ]),
83
+ settings: Object.freeze([
84
+ // Claude Code is the single statusLine surface; the entry lives in the
85
+ // user-level settings file.
86
+ Object.freeze({ harness: "claude", segments: Object.freeze([".claude", "settings.json"]), key: "statusLine" }),
87
+ ]),
88
+ hooks: Object.freeze([
89
+ // A hook is registered as a key in the settings file...
90
+ Object.freeze({ harness: "claude", segments: Object.freeze([".claude", "settings.json"]), key: "hooks" }),
91
+ // ...and its command may point at a script faberun drops in a hooks dir.
92
+ Object.freeze({ harness: "claude", segments: Object.freeze([".claude", "hooks"]) }),
93
+ ]),
94
+ });
95
+
96
+ /**
97
+ * The skills directory a harness reads, resolved against `home`, or null when
98
+ * the harness has no skills convention (dsh). `skills.mjs` builds its discovery
99
+ * table from this so the manifest is the one home for the paths.
100
+ *
101
+ * @param {string} harness
102
+ * @param {string} home
103
+ * @returns {string|null}
104
+ */
105
+ export function skillSiteDir(harness, home) {
106
+ const site = KNOWN_INSTALL_SITES.skills.find((entry) => entry.harness === harness);
107
+ return site ? join(home, ...site.segments) : null;
108
+ }
109
+
110
+ /**
111
+ * The manifest with every path resolved against `home`, grouped as it is
112
+ * published. Settings and hook sites carry the settings `key` faberun owns when
113
+ * one applies, so a caller can find the entry without guessing.
114
+ *
115
+ * @param {string} home
116
+ * @returns {{skills: {harness: string, path: string}[], settings: {harness: string, path: string, key: string|null}[], hooks: {harness: string, path: string, key: string|null}[]}}
117
+ */
118
+ export function knownInstallSites(home) {
119
+ return {
120
+ skills: KNOWN_INSTALL_SITES.skills.map((site) => ({ harness: site.harness, path: join(home, ...site.segments) })),
121
+ settings: KNOWN_INSTALL_SITES.settings.map((site) => ({
122
+ harness: site.harness,
123
+ path: join(home, ...site.segments),
124
+ key: site.key ?? null,
125
+ })),
126
+ hooks: KNOWN_INSTALL_SITES.hooks.map((site) => ({
127
+ harness: site.harness,
128
+ path: join(home, ...site.segments),
129
+ key: site.key ?? null,
130
+ })),
131
+ };
132
+ }
133
+
134
+ /** Every kind of write the registry records. */
135
+ export const INSTALL_KINDS = Object.freeze(["skill", "statusline", "hook", "config"]);
136
+
137
+ /**
138
+ * One entry faberun added inside a settings file: the JSON Pointer that names
139
+ * it and the exact value written there. Both are kept so removal can delete the
140
+ * entry only while it still holds what faberun put there, leaving a value the
141
+ * operator later edited alone.
142
+ *
143
+ * @typedef {object} SettingsEntry
144
+ * @property {string} pointer an RFC 6901 JSON Pointer into the settings JSON
145
+ * @property {unknown} value the exact value faberun wrote at that pointer
146
+ */
147
+
148
+ /**
149
+ * One artifact faberun wrote outside a target repository.
150
+ *
151
+ * @typedef {object} InstallEntry
152
+ * @property {"skill"|"statusline"|"hook"|"config"} kind
153
+ * @property {string} path the absolute path written
154
+ * @property {string} root the absolute install-site root that owns `path`
155
+ * @property {string|null} harness the owning harness, when one applies
156
+ * @property {SettingsEntry[]} entries settings entries added, empty otherwise
157
+ * @property {string} registeredAt when the write was recorded, ISO-8601
158
+ */
159
+
160
+ /**
161
+ * @typedef {object} InstallRegistry
162
+ * @property {number} schemaVersion
163
+ * @property {InstallEntry[]} entries
164
+ */
165
+
166
+ /**
167
+ * The registry recorded under `$FABERUN_HOME`, or an empty one when the file is
168
+ * absent, unreadable or malformed. The ledger is advisory: a bad read must not
169
+ * stop the command that consumes it, and an empty registry is exactly what a
170
+ * machine that has never installed anything has.
171
+ *
172
+ * @param {NodeJS.ProcessEnv} [env]
173
+ * @returns {InstallRegistry}
174
+ */
175
+ export function readInstallRegistry(env = process.env) {
176
+ const home = faberunHome(env);
177
+ try {
178
+ const parsed = readJson(installRegistryPath(home));
179
+ return isInstallRegistry(parsed) ? parsed : emptyRegistry();
180
+ } catch {
181
+ // Absent, unreadable and unparsable all mean the same thing here: no
182
+ // recorded entries to report.
183
+ return emptyRegistry();
184
+ }
185
+ }
186
+
187
+ /**
188
+ * The recorded entries, in the order they were first installed.
189
+ *
190
+ * @param {NodeJS.ProcessEnv} [env]
191
+ * @returns {InstallEntry[]}
192
+ */
193
+ export function readInstallEntries(env = process.env) {
194
+ return readInstallRegistry(env).entries;
195
+ }
196
+
197
+ /**
198
+ * Record one install, replacing any earlier entry for the same kind and path so
199
+ * a re-install refreshes its timestamp instead of accumulating duplicates.
200
+ *
201
+ * @param {NodeJS.ProcessEnv} env
202
+ * @param {{kind: InstallEntry["kind"], path: string, root: string, harness?: string|null, entries?: SettingsEntry[], registeredAt?: string}} entry
203
+ * @returns {InstallEntry}
204
+ */
205
+ export function recordInstall(env, entry) {
206
+ const home = faberunHome(env);
207
+ const registry = readInstallRegistry(env);
208
+ const normalized = normalizeEntry(entry);
209
+ const index = registry.entries.findIndex((existing) => existing.kind === normalized.kind && existing.path === normalized.path);
210
+ if (index >= 0) registry.entries[index] = normalized;
211
+ else registry.entries.push(normalized);
212
+ writeJsonAtomic(installRegistryPath(home), registry);
213
+ return normalized;
214
+ }
215
+
216
+ /**
217
+ * Record a skill tree faberun linked or copied into a harness directory.
218
+ *
219
+ * @param {NodeJS.ProcessEnv} env
220
+ * @param {{path: string, root: string, harness?: string|null}} skill
221
+ * @returns {InstallEntry}
222
+ */
223
+ export function recordSkillInstall(env, skill) {
224
+ return recordInstall(env, { kind: "skill", path: skill.path, root: skill.root, harness: skill.harness ?? null });
225
+ }
226
+
227
+ /**
228
+ * Record a hook faberun registered, either as entries inside a settings file or
229
+ * as a script it dropped at `path`.
230
+ *
231
+ * @param {NodeJS.ProcessEnv} env
232
+ * @param {{path: string, root: string, harness?: string|null, entries?: SettingsEntry[]}} hook
233
+ * @returns {InstallEntry}
234
+ */
235
+ export function recordHookInstall(env, hook) {
236
+ return recordInstall(env, { kind: "hook", path: hook.path, root: hook.root, harness: hook.harness ?? null, entries: hook.entries ?? [] });
237
+ }
238
+
239
+ /**
240
+ * Record the user config faberun wrote at `$FABERUN_HOME/config.json`. It is
241
+ * informational for removal -- `$FABERUN_HOME` is deleted wholesale -- but it
242
+ * is still an artifact faberun wrote outside a target repository, and the
243
+ * ledger should say so.
244
+ *
245
+ * @param {NodeJS.ProcessEnv} env
246
+ * @param {string} path
247
+ * @returns {InstallEntry}
248
+ */
249
+ export function recordConfigInstall(env, path) {
250
+ return recordInstall(env, { kind: "config", path, root: faberunHome(env) });
251
+ }
252
+
253
+ /**
254
+ * The status-line integration faberun installed into a tool's settings file,
255
+ * recorded with the exact pointer and value so removal can take back only that
256
+ * entry.
257
+ *
258
+ * @param {NodeJS.ProcessEnv} env
259
+ * @param {{path: string, root: string, harness?: string|null, entries: SettingsEntry[]}} statusline
260
+ * @returns {InstallEntry}
261
+ */
262
+ export function recordStatuslineInstall(env, statusline) {
263
+ return recordInstall(env, {
264
+ kind: "statusline",
265
+ path: statusline.path,
266
+ root: statusline.root,
267
+ harness: statusline.harness ?? null,
268
+ entries: statusline.entries,
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Add one entry inside a settings file and record exactly what was added:
274
+ * `pointer` names the location, `value` is written there, and both are kept in
275
+ * the registry. The settings file is created when missing, and the write is
276
+ * atomic, so a concurrent reader sees either the old or the new document.
277
+ *
278
+ * @param {NodeJS.ProcessEnv} env
279
+ * @param {{kind: "statusline"|"hook", path: string, root: string, harness?: string|null, pointer: string, value: unknown}} install
280
+ * @returns {InstallEntry}
281
+ */
282
+ export function installSettingsEntry(env, install) {
283
+ const settings = readSettings(install.path);
284
+ setAtPointer(settings, parsePointer(install.pointer), install.value);
285
+ writeJsonAtomic(install.path, settings);
286
+ return recordInstall(env, {
287
+ kind: install.kind,
288
+ path: install.path,
289
+ root: install.root,
290
+ harness: install.harness ?? null,
291
+ entries: [{ pointer: install.pointer, value: install.value }],
292
+ });
293
+ }
294
+
295
+ /**
296
+ * A copy of `settings` with exactly the recorded entries removed. An entry is
297
+ * removed only while the value at its pointer still deep-equals what faberun
298
+ * recorded, so an operator's later edit survives; every neighbouring key, and
299
+ * every entry faberun did not record, is untouched.
300
+ *
301
+ * @param {unknown} settings
302
+ * @param {readonly SettingsEntry[]} entries
303
+ * @returns {unknown}
304
+ */
305
+ export function removeRecordedSettingsEntries(settings, entries) {
306
+ const copy = structuredClone(settings);
307
+ for (const entry of entries) {
308
+ try {
309
+ removeAtPointer(copy, parsePointer(entry.pointer), entry.value);
310
+ } catch {
311
+ // A malformed recorded pointer is skipped, not fatal: the ledger must
312
+ // never turn one bad entry into a refusal to clean the rest.
313
+ }
314
+ }
315
+ return copy;
316
+ }
317
+
318
+ /** @returns {InstallRegistry} */
319
+ function emptyRegistry() {
320
+ return { schemaVersion: INSTALL_REGISTRY_SCHEMA_VERSION, entries: [] };
321
+ }
322
+
323
+ /**
324
+ * @param {unknown} value
325
+ * @returns {value is InstallRegistry}
326
+ */
327
+ function isInstallRegistry(value) {
328
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
329
+ const record = /** @type {Record<string, unknown>} */ (value);
330
+ if (record.schemaVersion !== INSTALL_REGISTRY_SCHEMA_VERSION) return false;
331
+ return Array.isArray(record.entries) && record.entries.every(isInstallEntry);
332
+ }
333
+
334
+ /**
335
+ * @param {unknown} value
336
+ * @returns {value is InstallEntry}
337
+ */
338
+ function isInstallEntry(value) {
339
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
340
+ const record = /** @type {Record<string, unknown>} */ (value);
341
+ if (typeof record.kind !== "string" || !INSTALL_KINDS.includes(record.kind)) return false;
342
+ if (typeof record.path !== "string" || !record.path) return false;
343
+ if (typeof record.root !== "string" || !record.root) return false;
344
+ if (record.harness !== null && typeof record.harness !== "string") return false;
345
+ if (!Array.isArray(record.entries)) return false;
346
+ if (typeof record.registeredAt !== "string") return false;
347
+ return record.entries.every(isSettingsEntry);
348
+ }
349
+
350
+ /**
351
+ * @param {unknown} value
352
+ * @returns {value is SettingsEntry}
353
+ */
354
+ function isSettingsEntry(value) {
355
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
356
+ const record = /** @type {Record<string, unknown>} */ (value);
357
+ return typeof record.pointer === "string" && record.pointer.startsWith("/");
358
+ }
359
+
360
+ /**
361
+ * @param {{kind: InstallEntry["kind"], path: string, root: string, harness?: string|null, entries?: SettingsEntry[], registeredAt?: string}} entry
362
+ * @returns {InstallEntry}
363
+ */
364
+ function normalizeEntry(entry) {
365
+ return {
366
+ kind: entry.kind,
367
+ path: entry.path,
368
+ root: entry.root,
369
+ harness: entry.harness ?? null,
370
+ entries: dedupeSettingsEntries(entry.entries ?? []),
371
+ registeredAt: entry.registeredAt ?? new Date().toISOString(),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Two records of the same pointer collapse to the last one written, so a
377
+ * settings entry added twice does not make removal run twice.
378
+ *
379
+ * @param {SettingsEntry[]} entries
380
+ * @returns {SettingsEntry[]}
381
+ */
382
+ function dedupeSettingsEntries(entries) {
383
+ /** @type {Map<string, SettingsEntry>} */
384
+ const byPointer = new Map();
385
+ for (const entry of entries) byPointer.set(entry.pointer, entry);
386
+ return [...byPointer.values()];
387
+ }
388
+
389
+ /**
390
+ * @param {string} path
391
+ * @returns {Record<string, unknown>}
392
+ */
393
+ function readSettings(path) {
394
+ try {
395
+ const parsed = readJson(path);
396
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
397
+ } catch (error) {
398
+ if (errorCode(error) === "ENOENT") return {};
399
+ // A settings file that is unreadable or not JSON is replaced with a fresh
400
+ // object only when the caller asked to add an entry; the read itself has
401
+ // nothing to preserve. The caller owns that risk and the atomic write.
402
+ return {};
403
+ }
404
+ }
405
+
406
+ /**
407
+ * An RFC 6901 JSON Pointer split into its decoded reference tokens.
408
+ *
409
+ * @param {string} pointer
410
+ * @returns {string[]}
411
+ */
412
+ function parsePointer(pointer) {
413
+ if (pointer === "") return [];
414
+ if (!pointer.startsWith("/")) throw new Error(`not a JSON Pointer: ${pointer}`);
415
+ return pointer.slice(1).split("/").map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~"));
416
+ }
417
+
418
+ /**
419
+ * @param {unknown} value
420
+ * @returns {value is Record<string, unknown>|unknown[]}
421
+ */
422
+ function isContainer(value) {
423
+ return value !== null && typeof value === "object";
424
+ }
425
+
426
+ /**
427
+ * @param {unknown} node
428
+ * @param {string} token
429
+ * @returns {string|number}
430
+ */
431
+ function childKey(node, token) {
432
+ return Array.isArray(node) ? Number(token) : token;
433
+ }
434
+
435
+ /** @param {string} token @returns {boolean} */
436
+ function isIndexToken(token) {
437
+ return /^(?:0|[1-9]\d*)$/u.test(token);
438
+ }
439
+
440
+ /**
441
+ * Write `value` at `tokens`, creating intermediate containers, and return the
442
+ * (possibly new) root. A pointer into a missing object or array is created as
443
+ * the next token requires; an existing non-container is replaced.
444
+ *
445
+ * @param {unknown} root
446
+ * @param {string[]} tokens
447
+ * @param {unknown} value
448
+ * @returns {unknown}
449
+ */
450
+ function setAtPointer(root, tokens, value) {
451
+ if (tokens.length === 0) return value;
452
+ if (!isContainer(root)) root = {};
453
+ let node = /** @type {Record<string, unknown>|unknown[]} */ (root);
454
+ for (let index = 0; index < tokens.length - 1; index += 1) {
455
+ const key = childKey(node, tokens[index]);
456
+ let child = /** @type {Record<string, unknown>|unknown[]|undefined} */ (/** @type {any} */ (node)[key]);
457
+ if (!isContainer(child)) {
458
+ child = isIndexToken(tokens[index + 1]) ? [] : {};
459
+ /** @type {any} */ (node)[key] = child;
460
+ }
461
+ node = child;
462
+ }
463
+ /** @type {any} */ (node)[childKey(node, tokens.at(-1) ?? "")] = value;
464
+ return root;
465
+ }
466
+
467
+ /**
468
+ * Remove the value at `tokens` when it still deep-equals `expected`. A missing
469
+ * path or a value that changed since it was recorded is left alone; arrays lose
470
+ * the element at the index, objects lose the key.
471
+ *
472
+ * @param {unknown} root
473
+ * @param {string[]} tokens
474
+ * @param {unknown} expected
475
+ * @returns {void}
476
+ */
477
+ function removeAtPointer(root, tokens, expected) {
478
+ if (tokens.length === 0 || !isContainer(root)) return;
479
+ let node = /** @type {Record<string, unknown>|unknown[]} */ (root);
480
+ for (let index = 0; index < tokens.length - 1; index += 1) {
481
+ const child = /** @type {any} */ (node)[childKey(node, tokens[index])];
482
+ if (!isContainer(child)) return;
483
+ node = child;
484
+ }
485
+ const key = childKey(node, tokens.at(-1) ?? "");
486
+ if (!(key in node)) return;
487
+ if (!deepEqual(/** @type {any} */ (node)[key], expected)) return;
488
+ if (Array.isArray(node)) node.splice(Number(key), 1);
489
+ else delete /** @type {any} */ (node)[key];
490
+ }
491
+
492
+ /**
493
+ * Structural equality for JSON values, so removal compares the recorded value
494
+ * with the live one by shape rather than by reference.
495
+ *
496
+ * @param {unknown} left
497
+ * @param {unknown} right
498
+ * @returns {boolean}
499
+ */
500
+ function deepEqual(left, right) {
501
+ if (left === right) return true;
502
+ if (typeof left !== typeof right) return false;
503
+ if (left === null || right === null || typeof left !== "object") return false;
504
+ if (Array.isArray(left) !== Array.isArray(right)) return false;
505
+ const leftObject = /** @type {Record<string, unknown>} */ (left);
506
+ const rightObject = /** @type {Record<string, unknown>} */ (right);
507
+ const leftKeys = Object.keys(leftObject);
508
+ const rightKeys = Object.keys(rightObject);
509
+ if (leftKeys.length !== rightKeys.length) return false;
510
+ return leftKeys.every((key) => deepEqual(leftObject[key], rightObject[key]));
511
+ }
@@ -35,6 +35,7 @@ import { pricingSeedAge } from "../engine/pricing-seed.mjs";
35
35
  import { validateContract } from "../contract/index.mjs";
36
36
  import { sharedVerificationCommands } from "../contract/final-verification.mjs";
37
37
  import { DISCOVERY_RUNTIME_DEFINITIONS, discoverRuntimes } from "../engine/runtime-discovery.mjs";
38
+ import { environmentListings } from "./environment-report.mjs";
38
39
  import { errorMessage } from "../util.mjs";
39
40
  import { boundedGitSync } from "../repo/worktree.mjs";
40
41
  import { routeRuntime } from "../contract/runtime.mjs";
@@ -344,6 +345,7 @@ const SIDE_EFFECT_ENV_KEYS = [
344
345
  "FABERUN_CLAUDE_BIN", // same, for the claude harness
345
346
  "FABERUN_AGY_BIN", // same, for the agy harness
346
347
  "FABERUN_DSH_BIN", // same, for the dsh harness
348
+ "FABERUN_FX_BIN", // same, for the fx harness
347
349
  "FABERUN_ZCODE_BIN", // same, for the zcode harness
348
350
  "FABERUN_EXEC_JSONL_BIN", // same, for the exec-jsonl harness
349
351
  ];
@@ -542,12 +544,10 @@ async function liveAvailabilityChecks(contractPath, runtimes) {
542
544
  // cycle the source gate bans. At call time both modules are fully
543
545
  // evaluated; this is a plain cache hit, not a cycle.
544
546
  const { preflightContract } = await import("../engine/live-preflight.mjs");
545
- // measured 2026-09-22 (dispatch gate): four routed runtimes asked in
546
- // parallel took about 18s, so 60s is the budget; FABERUN_PREFLIGHT_TIMEOUT_SEC
547
- // is the same operator override the gate honours.
548
- const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
549
- const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : 60;
550
- const probes = await preflightContract(contractPath, { liveTimeoutSec: timeoutSec });
547
+ // The live budget is `live-preflight.mjs`'s single measured default (60s,
548
+ // `FABERUN_PREFLIGHT_TIMEOUT_SEC` the operator override); it is not restated
549
+ // here, where a second copy would be a second default to keep honest.
550
+ const probes = await preflightContract(contractPath);
551
551
  recordProbeVerdicts(probes.filter((probe) => liveVerdict(probe).recorded));
552
552
  return probes.map((probe) => {
553
553
  const verdict = liveVerdict(probe);
@@ -584,13 +584,15 @@ const HARNESS_BIN_OVERRIDES = Object.freeze({
584
584
  * not thereby a runtime the launch cannot run.
585
585
  *
586
586
  * @param {string|undefined} contractPath
587
- * @param {{cwd?: string, json?: boolean, discover?: boolean}} values
587
+ * @param {{cwd?: string, json?: boolean, discover?: boolean, env?: boolean}} values
588
588
  * @returns {Promise<boolean>}
589
589
  */
590
590
  export async function doctorCommand(contractPath, values) {
591
591
  const repoDir = resolve(values.cwd ?? ".");
592
592
  /** @type {{name: string, ok: boolean, advisory?: boolean, detail: string}[]} */
593
593
  const checks = [];
594
+ /** @type {ValidatedContract|undefined} */
595
+ let validatedContract;
594
596
  const gitRepo = isGitWorkTree(repoDir);
595
597
  checks.push({ name: "git repository", ok: gitRepo, detail: gitRepo ? repoDir : "not inside a git work tree" });
596
598
  const runsIgnored = isRunsIgnored(repoDir);
@@ -630,6 +632,7 @@ export async function doctorCommand(contractPath, values) {
630
632
  const absolute = resolve(contractPath);
631
633
  try {
632
634
  const contract = validateContract(JSON.parse(readFileSync(absolute, "utf8")), absolute);
635
+ validatedContract = contract;
633
636
  checks.push({ name: "contract", ok: true, detail: `${contract.id} · ${contract.nodes.length} node${contract.nodes.length === 1 ? "" : "s"}` });
634
637
  const runtimes = reachableRuntimes(contract);
635
638
  routedRuntimes = runtimes;
@@ -664,7 +667,11 @@ export async function doctorCommand(contractPath, values) {
664
667
  checks.push({
665
668
  name: "runtime discovery",
666
669
  ok: available > 0,
667
- detail: Object.entries(discovered).map(([id, entry]) => `${id}: ${entry.available ? "available" : `unavailable (${entry.reason})`}${entry.exhaustedUntil ? ` until ${entry.exhaustedUntil}` : ""}`).join(" · ") || "no runtimes discovered",
670
+ // `discoverRuntimes` runs the static version probe, not the live hello:
671
+ // a binary that answers `--version` is present, not proven available.
672
+ // `doctor`'s `availability` lines are the only word on whether a
673
+ // provider answered.
674
+ detail: Object.entries(discovered).map(([id, entry]) => `${id}: ${entry.available ? "binary present" : `binary missing (${entry.reason})`}${entry.exhaustedUntil ? ` until ${entry.exhaustedUntil}` : ""}`).join(" · ") || "no runtimes discovered",
668
675
  });
669
676
  }
670
677
  // A PATH-only check must not fail a runtime whose binary is supplied through
@@ -672,7 +679,7 @@ export async function doctorCommand(contractPath, values) {
672
679
  // already validated whatever the runtime actually resolves to. `zcode` is
673
680
  // absent on purpose — its binary is a shim the harness writes on first use, so
674
681
  // a PATH miss here is the normal state of a fresh machine, not a missing
675
- // dependency; `dsh` runs through its own SDK client, not a PATH binary.
682
+ // dependency; `dsh` and `fx` run through Faberun's own clients, not a PATH binary.
676
683
  for (const binary of ["codex", "claude", "agy", "exec-jsonl"]) {
677
684
  const overrideName = /** @type {Record<string, string>} */ (HARNESS_BIN_OVERRIDES)[binary];
678
685
  const overridden = overriddenHarnesses.has(binary) || Boolean(process.env[overrideName]);
@@ -687,6 +694,23 @@ export async function doctorCommand(contractPath, values) {
687
694
  for (const check of environmentPreflight({ cwd: dispatchCwd, runtimes: routedRuntimes, harnessVersions }).checks) {
688
695
  checks.push({ name: check.name, ok: check.ok || check.advisory, detail: check.ok ? check.detail : `${check.detail} (advisory)` });
689
696
  }
697
+ // `--env` is a listing, not a gate: every line names the variables a runtime
698
+ // would carry and the ones it would not, and never a value. The retained
699
+ // names are the excluded ones shaped like credentials, so the operator reads
700
+ // at a glance that the secret on the controller stays on the controller.
701
+ /** @type {ReturnType<typeof environmentListings>} */
702
+ let environment = [];
703
+ if (values.env === true) {
704
+ environment = environmentListings(routedRuntimes, validatedContract);
705
+ for (const listing of environment) {
706
+ const retained = listing.retained.length ? ` · retained: ${listing.retained.join(", ")}` : "";
707
+ checks.push({
708
+ name: `environment ${listing.runtime}`,
709
+ ok: true,
710
+ detail: `pass: ${listing.passed.join(", ") || "none"} · excluded: ${listing.excluded.join(", ") || "none"}${retained}`,
711
+ });
712
+ }
713
+ }
690
714
  // The live availability lines never gate the verdict: a no-answer is the
691
715
  // finding it is on its own line (ok false, cause named), and what still
692
716
  // fails doctor is every host fact and static probe — which is why the
@@ -696,7 +720,7 @@ export async function doctorCommand(contractPath, values) {
696
720
  const transportWarning = noTransportWarning(process.env);
697
721
  if (transportWarning) process.stderr.write(`${statusToken("warn", colorLevel(process.env, process.stderr.isTTY))} ${transportWarning}\n`);
698
722
  if (values.json === true) {
699
- process.stdout.write(`${JSON.stringify({ schemaVersion: 1, repo: repoDir, ok, checks, ...(values.discover === true ? { runtimes: discovered } : {}) }, null, 2)}\n`);
723
+ process.stdout.write(`${JSON.stringify({ schemaVersion: 1, repo: repoDir, ok, checks, ...(values.env === true ? { environment } : {}), ...(values.discover === true ? { runtimes: discovered } : {}) }, null, 2)}\n`);
700
724
  } else {
701
725
  const level = colorLevel(process.env, process.stdout.isTTY);
702
726
  for (const check of checks) process.stdout.write(`${statusToken(check.ok ? "ok" : "fail", level)} ${check.name} · ${check.detail}\n`);