faberun 0.27.0 → 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 (73) hide show
  1. package/README.md +9 -3
  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 +5 -0
  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 +67 -7
  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 +5 -1
  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 +15 -9
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +29 -0
  45. package/src/harnesses/claude/index.mjs +98 -1
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +14 -0
  48. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  49. package/src/harnesses/fx/index.mjs +13 -0
  50. package/src/harnesses/index.mjs +21 -10
  51. package/src/harnesses/replay/index.mjs +12 -0
  52. package/src/harnesses/zcode/index.mjs +19 -0
  53. package/src/host/config.mjs +8 -2
  54. package/src/host/environment-report.mjs +85 -0
  55. package/src/host/home.mjs +27 -0
  56. package/src/host/install-registry.mjs +511 -0
  57. package/src/host/preflight.mjs +32 -9
  58. package/src/host/uninstall-roots.mjs +196 -0
  59. package/src/plan/proof-check.mjs +15 -0
  60. package/src/plan/repo-facts.mjs +95 -11
  61. package/src/plan/rounds.mjs +27 -0
  62. package/src/repo/declared-paths.mjs +74 -6
  63. package/src/repo/integrate.mjs +1 -1
  64. package/src/repo/scope-closure.mjs +59 -0
  65. package/src/repo/signal-block.mjs +26 -0
  66. package/src/repo/signal.mjs +9 -24
  67. package/src/repo/workspace.mjs +189 -14
  68. package/src/report/campaign-brief.mjs +4 -0
  69. package/src/report/message.mjs +37 -6
  70. package/src/report/progress.mjs +13 -2
  71. package/src/run/paths.mjs +14 -1
  72. package/src/run/usage.mjs +66 -1
  73. 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";
@@ -543,12 +544,10 @@ async function liveAvailabilityChecks(contractPath, runtimes) {
543
544
  // cycle the source gate bans. At call time both modules are fully
544
545
  // evaluated; this is a plain cache hit, not a cycle.
545
546
  const { preflightContract } = await import("../engine/live-preflight.mjs");
546
- // measured 2026-09-22 (dispatch gate): four routed runtimes asked in
547
- // parallel took about 18s, so 60s is the budget; FABERUN_PREFLIGHT_TIMEOUT_SEC
548
- // is the same operator override the gate honours.
549
- const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
550
- const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : 60;
551
- 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);
552
551
  recordProbeVerdicts(probes.filter((probe) => liveVerdict(probe).recorded));
553
552
  return probes.map((probe) => {
554
553
  const verdict = liveVerdict(probe);
@@ -585,13 +584,15 @@ const HARNESS_BIN_OVERRIDES = Object.freeze({
585
584
  * not thereby a runtime the launch cannot run.
586
585
  *
587
586
  * @param {string|undefined} contractPath
588
- * @param {{cwd?: string, json?: boolean, discover?: boolean}} values
587
+ * @param {{cwd?: string, json?: boolean, discover?: boolean, env?: boolean}} values
589
588
  * @returns {Promise<boolean>}
590
589
  */
591
590
  export async function doctorCommand(contractPath, values) {
592
591
  const repoDir = resolve(values.cwd ?? ".");
593
592
  /** @type {{name: string, ok: boolean, advisory?: boolean, detail: string}[]} */
594
593
  const checks = [];
594
+ /** @type {ValidatedContract|undefined} */
595
+ let validatedContract;
595
596
  const gitRepo = isGitWorkTree(repoDir);
596
597
  checks.push({ name: "git repository", ok: gitRepo, detail: gitRepo ? repoDir : "not inside a git work tree" });
597
598
  const runsIgnored = isRunsIgnored(repoDir);
@@ -631,6 +632,7 @@ export async function doctorCommand(contractPath, values) {
631
632
  const absolute = resolve(contractPath);
632
633
  try {
633
634
  const contract = validateContract(JSON.parse(readFileSync(absolute, "utf8")), absolute);
635
+ validatedContract = contract;
634
636
  checks.push({ name: "contract", ok: true, detail: `${contract.id} · ${contract.nodes.length} node${contract.nodes.length === 1 ? "" : "s"}` });
635
637
  const runtimes = reachableRuntimes(contract);
636
638
  routedRuntimes = runtimes;
@@ -665,7 +667,11 @@ export async function doctorCommand(contractPath, values) {
665
667
  checks.push({
666
668
  name: "runtime discovery",
667
669
  ok: available > 0,
668
- 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",
669
675
  });
670
676
  }
671
677
  // A PATH-only check must not fail a runtime whose binary is supplied through
@@ -688,6 +694,23 @@ export async function doctorCommand(contractPath, values) {
688
694
  for (const check of environmentPreflight({ cwd: dispatchCwd, runtimes: routedRuntimes, harnessVersions }).checks) {
689
695
  checks.push({ name: check.name, ok: check.ok || check.advisory, detail: check.ok ? check.detail : `${check.detail} (advisory)` });
690
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
+ }
691
714
  // The live availability lines never gate the verdict: a no-answer is the
692
715
  // finding it is on its own line (ok false, cause named), and what still
693
716
  // fails doctor is every host fact and static probe — which is why the
@@ -697,7 +720,7 @@ export async function doctorCommand(contractPath, values) {
697
720
  const transportWarning = noTransportWarning(process.env);
698
721
  if (transportWarning) process.stderr.write(`${statusToken("warn", colorLevel(process.env, process.stderr.isTTY))} ${transportWarning}\n`);
699
722
  if (values.json === true) {
700
- 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`);
701
724
  } else {
702
725
  const level = colorLevel(process.env, process.stdout.isTTY);
703
726
  for (const check of checks) process.stdout.write(`${statusToken(check.ok ? "ok" : "fail", level)} ${check.name} · ${check.detail}\n`);