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.
- package/README.md +11 -4
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +12 -2
- package/src/cli/manual.mjs +24 -8
- package/src/cli/setup.mjs +6 -1
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +68 -8
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +109 -0
- package/src/contract/index.mjs +7 -4
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +6 -2
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +10 -0
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +114 -18
- package/src/engine/prompts.mjs +41 -3
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +61 -13
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +54 -2
- package/src/harnesses/claude/index.mjs +177 -2
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +18 -116
- package/src/harnesses/dsh/runner.mjs +1 -17
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/home.mjs +57 -0
- package/src/harnesses/fx/index.mjs +137 -0
- package/src/harnesses/fx/native/build.zig +20 -0
- package/src/harnesses/fx/native/build.zig.zon +7 -0
- package/src/harnesses/fx/native/home.zig +85 -0
- package/src/harnesses/fx/native/main.zig +503 -0
- package/src/harnesses/fx/native/permissions.zig +51 -0
- package/src/harnesses/fx/native/relay.zig +253 -0
- package/src/harnesses/fx/native/usage.zig +112 -0
- package/src/harnesses/fx/permissions.mjs +35 -0
- package/src/harnesses/fx/runner.mjs +350 -0
- package/src/harnesses/fx/usage-proxy.mjs +176 -0
- package/src/harnesses/index.mjs +29 -14
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/runner-io.mjs +25 -0
- package/src/harnesses/runner-transcript.mjs +127 -0
- package/src/harnesses/session-metrics.mjs +38 -13
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +34 -10
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/proof-check.mjs +15 -0
- package/src/plan/repo-facts.mjs +95 -11
- package/src/plan/rounds.mjs +27 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- 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
|
+
}
|
package/src/host/preflight.mjs
CHANGED
|
@@ -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
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
const
|
|
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
|
-
|
|
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`
|
|
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`);
|