faberun 0.3.0 → 0.6.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 +152 -100
- package/package.json +8 -2
- package/skills/faberun/SKILL.md +6 -5
- package/skills/faberun/references/contract.md +23 -11
- package/skills/faberun/references/engineering.md +3 -1
- package/skills/faberun/references/operations.md +19 -12
- package/skills/faberun/references/rules.md +3 -1
- package/src/campaign/chain.mjs +6 -2
- package/src/campaign/index.mjs +17 -1
- package/src/campaign/metrics.mjs +3 -3
- package/src/cli/brand.mjs +2 -1
- package/src/cli/setup.mjs +109 -30
- package/src/cli/skills.mjs +308 -8
- package/src/cli.mjs +2 -1
- package/src/contract/final-verification.mjs +31 -2
- package/src/contract/index.mjs +27 -24
- package/src/contract/runtime.mjs +5 -1
- package/src/contract/task-packet.mjs +20 -9
- package/src/contract/verification.mjs +1 -1
- package/src/engine/backoff.mjs +1 -1
- package/src/engine/dispatch.mjs +5 -3
- package/src/engine/gate.mjs +12 -0
- package/src/engine/process-identity.mjs +39 -0
- package/src/engine/prompts.mjs +18 -0
- package/src/engine/resume.mjs +2 -2
- package/src/engine/review.mjs +9 -1
- package/src/engine/run-command.mjs +23 -2
- package/src/engine/run-identity.mjs +14 -0
- package/src/engine/scheduler.mjs +45 -12
- package/src/engine/settle.mjs +29 -0
- package/src/engine/supervise.mjs +32 -6
- package/src/engine/verify.mjs +98 -9
- package/src/harnesses/agy/index.mjs +3 -0
- package/src/harnesses/claude/index.mjs +5 -0
- package/src/harnesses/codex/index.mjs +3 -0
- package/src/harnesses/dsh/index.mjs +26 -0
- package/src/harnesses/exec-jsonl/index.mjs +2 -0
- package/src/harnesses/index.mjs +10 -3
- package/src/harnesses/replay/index.mjs +2 -0
- package/src/harnesses/zcode/index.mjs +3 -0
- package/src/host/preflight.mjs +5 -1
- package/src/notify/index.mjs +45 -2
- package/src/repo/source-identity.mjs +4 -3
- package/src/report/render.mjs +128 -49
- package/src/web/index.html +1 -1
package/src/cli/setup.mjs
CHANGED
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
* It checks the two host prerequisites, discovers the catalogue runtimes
|
|
6
6
|
* through the same `discoverRuntimes` the engine uses, asks which harnesses to
|
|
7
7
|
* enable and which runtime is the default worker and judge, and writes the user
|
|
8
|
-
* config at `$FABERUN_HOME/config.json`.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* config at `$FABERUN_HOME/config.json`. When a config already exists, its
|
|
9
|
+
* recorded harnesses, worker and judge seed the defaults instead of the
|
|
10
|
+
* fresh-machine ones, narrowed to whatever discovery still reports available;
|
|
11
|
+
* an explicit `--harnesses`, `--worker` or `--judge` still wins. The judge must
|
|
12
|
+
* resolve to a vendor other than the worker's; the prompt refuses a same-vendor
|
|
13
|
+
* answer once and the command fails on the second. Once the config is written it offers to register
|
|
14
|
+
* the faberun skill into every installed harness's skills directory, reusing
|
|
15
|
+
* `registerSkills` from `./skills.mjs` so discovery has one home.
|
|
11
16
|
*
|
|
12
17
|
* Discovery and the question function are injected so tests never touch a real
|
|
13
18
|
* binary or a terminal. `--json` never asks: it reports the same facts as one
|
|
@@ -25,10 +30,12 @@ import { boundedGitSync } from "../repo/worktree.mjs";
|
|
|
25
30
|
import { colorLevel, renderBanner, statusToken } from "./brand.mjs";
|
|
26
31
|
import { packageVersion } from "../host/package.mjs";
|
|
27
32
|
import { configPath, faberunHome } from "../host/home.mjs";
|
|
28
|
-
import { writeUserConfig } from "../host/config.mjs";
|
|
33
|
+
import { readUserConfig, writeUserConfig } from "../host/config.mjs";
|
|
34
|
+
import { discoverSkillTargets, registerSkills } from "./skills.mjs";
|
|
29
35
|
|
|
30
36
|
/** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
|
|
31
37
|
/** @typedef {import("../host/config.mjs").UserConfig} UserConfig */
|
|
38
|
+
/** @typedef {import("./skills.mjs").SkillRegistration} SkillRegistration */
|
|
32
39
|
/** @typedef {(text: string) => void} Writer */
|
|
33
40
|
/** @typedef {(question: string) => Promise<string>} Asker */
|
|
34
41
|
/** @typedef {{id: string, harness: string, model: string, available: boolean, status: string, missing: string[]}} RuntimeView */
|
|
@@ -39,9 +46,11 @@ import { writeUserConfig } from "../host/config.mjs";
|
|
|
39
46
|
* @property {string} [harnesses]
|
|
40
47
|
* @property {string} [worker]
|
|
41
48
|
* @property {string} [judge]
|
|
49
|
+
* @property {boolean} [skill] whether to register the faberun skill (default true)
|
|
42
50
|
* @property {boolean} [json]
|
|
43
51
|
* @property {NodeJS.ProcessEnv} [env]
|
|
44
52
|
* @property {() => Promise<Record<string, RuntimeAvailability>>} [discover]
|
|
53
|
+
* @property {(name: string) => boolean} [isInstalled]
|
|
45
54
|
* @property {Asker} [ask]
|
|
46
55
|
* @property {Writer} [stdout]
|
|
47
56
|
* @property {Writer} [stderr]
|
|
@@ -94,7 +103,7 @@ export async function setupCommand(options = {}) {
|
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
if (requirements.some((requirement) => !requirement.ok)) {
|
|
97
|
-
if (json) stdout(`${JSON.stringify({ requirements, runtimes, config: null }, null, 2)}\n`);
|
|
106
|
+
if (json) stdout(`${JSON.stringify({ requirements, runtimes, config: null, skills: [] }, null, 2)}\n`);
|
|
98
107
|
return 1;
|
|
99
108
|
}
|
|
100
109
|
|
|
@@ -107,7 +116,7 @@ export async function setupCommand(options = {}) {
|
|
|
107
116
|
|
|
108
117
|
if (availableHarnesses.length === 0) {
|
|
109
118
|
if (json) {
|
|
110
|
-
stdout(`${JSON.stringify({ requirements, runtimes, config: null }, null, 2)}\n`);
|
|
119
|
+
stdout(`${JSON.stringify({ requirements, runtimes, config: null, skills: [] }, null, 2)}\n`);
|
|
111
120
|
} else {
|
|
112
121
|
stdout(`${statusToken("fail", level)} harnesses · none available · install one of: ${INSTALL_HARNESSES.join(", ")}\n`);
|
|
113
122
|
}
|
|
@@ -115,7 +124,9 @@ export async function setupCommand(options = {}) {
|
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
const candidates = availableCandidates(DISCOVERY_RUNTIME_DEFINITIONS, availability);
|
|
118
|
-
const
|
|
127
|
+
const kept = mergeExistingConfig(readUserConfig(env), availability);
|
|
128
|
+
const defaultHarnesses = kept.harnesses.length > 0 ? kept.harnesses : availableHarnesses;
|
|
129
|
+
const defaultWorker = kept.worker || (cheapest(candidates)?.id ?? "");
|
|
119
130
|
const interactive = isTTY && !json && !yes
|
|
120
131
|
&& options.harnesses === undefined && options.worker === undefined && options.judge === undefined;
|
|
121
132
|
|
|
@@ -124,16 +135,19 @@ export async function setupCommand(options = {}) {
|
|
|
124
135
|
let selectedWorker;
|
|
125
136
|
let selectedJudge;
|
|
126
137
|
const asker = makeAsker(options.ask);
|
|
138
|
+
/** @type {SkillRegistration[]} */
|
|
139
|
+
let skills = [];
|
|
127
140
|
try {
|
|
128
141
|
if (interactive) {
|
|
129
|
-
const harnessAnswer = (await asker.ask(`Enable which harnesses? [${
|
|
130
|
-
selectedHarnesses = splitHarnesses(harnessAnswer).length ? splitHarnesses(harnessAnswer) :
|
|
142
|
+
const harnessAnswer = (await asker.ask(`Enable which harnesses? [${defaultHarnesses.join(", ")}] `)).trim();
|
|
143
|
+
selectedHarnesses = splitHarnesses(harnessAnswer).length ? splitHarnesses(harnessAnswer) : defaultHarnesses;
|
|
131
144
|
|
|
132
145
|
const workerAnswer = (await asker.ask(`Default worker runtime? [${defaultWorker}] `)).trim();
|
|
133
146
|
selectedWorker = workerAnswer || defaultWorker;
|
|
134
147
|
|
|
135
|
-
|
|
136
|
-
let
|
|
148
|
+
const judgeDefault = keptJudgeDefault(kept, selectedWorker, candidates);
|
|
149
|
+
let judgeAnswer = (await asker.ask(`Default judge runtime? [${judgeDefault}] `)).trim();
|
|
150
|
+
let judge = judgeAnswer || judgeDefault;
|
|
137
151
|
if (!crossVendor(judge, selectedWorker)) {
|
|
138
152
|
stderr("the judge must come from a different vendor than the worker\n");
|
|
139
153
|
judgeAnswer = (await asker.ask(`Default judge runtime? [${defaultJudge(selectedWorker, candidates)}] `)).trim();
|
|
@@ -142,35 +156,56 @@ export async function setupCommand(options = {}) {
|
|
|
142
156
|
}
|
|
143
157
|
selectedJudge = judge;
|
|
144
158
|
} else {
|
|
145
|
-
selectedHarnesses = splitHarnesses(options.harnesses ?? "").length ? splitHarnesses(options.harnesses ?? "") :
|
|
159
|
+
selectedHarnesses = splitHarnesses(options.harnesses ?? "").length ? splitHarnesses(options.harnesses ?? "") : defaultHarnesses;
|
|
146
160
|
selectedWorker = options.worker ?? defaultWorker;
|
|
147
|
-
selectedJudge = options.judge ??
|
|
161
|
+
selectedJudge = options.judge ?? keptJudgeDefault(kept, selectedWorker, candidates);
|
|
148
162
|
if (!crossVendor(selectedJudge, selectedWorker)) {
|
|
149
|
-
if (json) stdout(`${JSON.stringify({ requirements, runtimes, config: null }, null, 2)}\n`);
|
|
163
|
+
if (json) stdout(`${JSON.stringify({ requirements, runtimes, config: null, skills }, null, 2)}\n`);
|
|
150
164
|
else stdout(`${statusToken("fail", level)} judge · the judge must come from a different vendor than the worker\n`);
|
|
151
165
|
return 1;
|
|
152
166
|
}
|
|
153
167
|
}
|
|
154
|
-
} finally {
|
|
155
|
-
asker.close();
|
|
156
|
-
}
|
|
157
168
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
const config = {
|
|
170
|
+
schemaVersion: /** @type {1} */ (1),
|
|
171
|
+
harnesses: selectedHarnesses,
|
|
172
|
+
worker: selectedWorker,
|
|
173
|
+
judge: selectedJudge,
|
|
174
|
+
updatedAt: new Date().toISOString(),
|
|
175
|
+
};
|
|
176
|
+
writeUserConfig(env, config);
|
|
177
|
+
if (!json) stdout(`${statusToken("ok", level)} config · ${configPath(faberunHome(env))}\n`);
|
|
166
178
|
|
|
167
|
-
|
|
168
|
-
|
|
179
|
+
// The offer comes after the config is durable, so a machine that answers
|
|
180
|
+
// no still has a usable setup. Discovery is `skills.mjs`'s table, reused
|
|
181
|
+
// rather than re-probed here.
|
|
182
|
+
const detected = discoverSkillTargets({ env, isInstalled: options.isInstalled })
|
|
183
|
+
.filter((target) => target.dir !== null && target.dirExists && target.installed && !target.unsupported);
|
|
184
|
+
if (options.skill !== false && detected.length > 0) {
|
|
185
|
+
let register = true;
|
|
186
|
+
if (interactive) {
|
|
187
|
+
const answer = (await asker.ask(`Register the faberun skill for ${detected.map((target) => target.harness).join(", ")}? [Y/n] `)).trim();
|
|
188
|
+
register = !answer.toLowerCase().startsWith("n");
|
|
189
|
+
}
|
|
190
|
+
if (register) {
|
|
191
|
+
skills = registerSkills({
|
|
192
|
+
env,
|
|
193
|
+
level,
|
|
194
|
+
isInstalled: options.isInstalled,
|
|
195
|
+
stdout: json ? () => {} : stdout,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (json) {
|
|
201
|
+
stdout(`${JSON.stringify({ requirements, runtimes, config, skills }, null, 2)}\n`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
stdout("next · faberun init in a repository · faberun doctor\n");
|
|
169
205
|
return 0;
|
|
206
|
+
} finally {
|
|
207
|
+
asker.close();
|
|
170
208
|
}
|
|
171
|
-
stdout(`${statusToken("ok", level)} config · ${configPath(faberunHome(env))}\n`);
|
|
172
|
-
stdout("next · faberun init in a repository · faberun doctor\n");
|
|
173
|
-
return 0;
|
|
174
209
|
}
|
|
175
210
|
|
|
176
211
|
/**
|
|
@@ -225,6 +260,50 @@ function missingEnvKeys(runtime, env) {
|
|
|
225
260
|
return [...new Set(names)];
|
|
226
261
|
}
|
|
227
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The recorded harnesses, worker and judge that discovery still reports
|
|
265
|
+
* available, so a re-run of setup keeps an operator's earlier choices instead
|
|
266
|
+
* of resetting them to the fresh-machine defaults. A choice discovery cannot
|
|
267
|
+
* find is dropped, not kept blindly; the caller fills anything empty with
|
|
268
|
+
* today's defaults. A null `existing` (no config yet, or a malformed one)
|
|
269
|
+
* yields nothing kept.
|
|
270
|
+
*
|
|
271
|
+
* @param {UserConfig|null} existing
|
|
272
|
+
* @param {Record<string, RuntimeAvailability>} availability
|
|
273
|
+
* @returns {{harnesses: string[], worker: string, judge: string}}
|
|
274
|
+
*/
|
|
275
|
+
export function mergeExistingConfig(existing, availability) {
|
|
276
|
+
if (!existing) return { harnesses: [], worker: "", judge: "" };
|
|
277
|
+
const availableHarnesses = new Set(
|
|
278
|
+
Object.entries(DISCOVERY_RUNTIME_DEFINITIONS)
|
|
279
|
+
.filter(([id]) => availability[id]?.available === true)
|
|
280
|
+
.map(([, definition]) => definition.harness),
|
|
281
|
+
);
|
|
282
|
+
const candidateIds = new Set(
|
|
283
|
+
availableCandidates(DISCOVERY_RUNTIME_DEFINITIONS, availability).map((candidate) => candidate.id),
|
|
284
|
+
);
|
|
285
|
+
return {
|
|
286
|
+
harnesses: existing.harnesses.filter((harness) => availableHarnesses.has(harness)),
|
|
287
|
+
worker: existing.worker && candidateIds.has(existing.worker) ? existing.worker : "",
|
|
288
|
+
judge: existing.judge && candidateIds.has(existing.judge) ? existing.judge : "",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The judge default for the interactive prompt and `--yes`: the recorded judge
|
|
294
|
+
* when it is still available and still a different vendor than `workerId`,
|
|
295
|
+
* otherwise the strongest cross-vendor candidate as today.
|
|
296
|
+
*
|
|
297
|
+
* @param {{judge: string}} kept
|
|
298
|
+
* @param {string} workerId
|
|
299
|
+
* @param {import("../engine/runtime-discovery.mjs").RuntimeCandidate[]} candidates
|
|
300
|
+
* @returns {string}
|
|
301
|
+
*/
|
|
302
|
+
function keptJudgeDefault(kept, workerId, candidates) {
|
|
303
|
+
if (kept.judge && crossVendor(kept.judge, workerId)) return kept.judge;
|
|
304
|
+
return defaultJudge(workerId, candidates);
|
|
305
|
+
}
|
|
306
|
+
|
|
228
307
|
/**
|
|
229
308
|
* The strongest available runtime whose vendor differs from the worker's. An
|
|
230
309
|
* empty string means no cross-vendor runtime is available, which the caller
|
package/src/cli/skills.mjs
CHANGED
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `skills` argv: list, install.
|
|
2
|
+
* `skills` argv: list, install, register.
|
|
3
3
|
*
|
|
4
4
|
* Per-operation options only, so `--force` is rejected by `list`. The
|
|
5
|
-
* behavior lives in `installSkills`, exported because
|
|
6
|
-
* the same catalogue into a target repository
|
|
7
|
-
*
|
|
5
|
+
* behavior lives in `installSkills` and `registerSkills`, exported because
|
|
6
|
+
* `faberun init` installs the same catalogue into a target repository and
|
|
7
|
+
* `faberun setup` registers the skill into every installed harness; this file
|
|
8
|
+
* owns the wire, the same split `seat.mjs` uses.
|
|
9
|
+
*
|
|
10
|
+
* `install` copies the catalogue into a caller-chosen `.claude/skills`.
|
|
11
|
+
* `register` discovers each installed harness's own skills directory and links
|
|
12
|
+
* (or, with `--copy`, copies) the `faberun` skill into it. Discovery is a
|
|
13
|
+
* measured table below, not a probe of live harness state.
|
|
8
14
|
*/
|
|
9
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
cpSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
lstatSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
readdirSync,
|
|
21
|
+
realpathSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
symlinkSync,
|
|
24
|
+
} from "node:fs";
|
|
10
25
|
import { homedir } from "node:os";
|
|
11
|
-
import { join, resolve } from "node:path";
|
|
26
|
+
import { join, resolve, sep } from "node:path";
|
|
12
27
|
import { fileURLToPath } from "node:url";
|
|
13
28
|
import { parseArgs as parseFlags } from "node:util";
|
|
14
29
|
|
|
30
|
+
import { faberunHome, installedVersionDir } from "../host/home.mjs";
|
|
31
|
+
import { findExecutable } from "../host/preflight.mjs";
|
|
32
|
+
import { colorLevel, statusToken } from "./brand.mjs";
|
|
33
|
+
|
|
15
34
|
const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url));
|
|
35
|
+
const CHECKOUT_SKILL = join(SKILLS_DIR, "faberun");
|
|
16
36
|
|
|
17
37
|
/** Flags are scoped to the operation that declares them; all others are rejected. */
|
|
18
38
|
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
@@ -23,8 +43,64 @@ const OPERATION_OPTIONS = {
|
|
|
23
43
|
global: { type: "boolean" },
|
|
24
44
|
force: { type: "boolean" },
|
|
25
45
|
},
|
|
46
|
+
register: {
|
|
47
|
+
harness: { type: "string" },
|
|
48
|
+
copy: { type: "boolean" },
|
|
49
|
+
force: { type: "boolean" },
|
|
50
|
+
json: { type: "boolean" },
|
|
51
|
+
},
|
|
26
52
|
};
|
|
27
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Where each operator harness keeps the skills a user can add, measured
|
|
56
|
+
* 2026-09-16 on the machine this node ran on.
|
|
57
|
+
*
|
|
58
|
+
* - `claude` -> `~/.claude/skills` and `codex` -> `~/.codex/skills` are the
|
|
59
|
+
* documented conventions; on this machine both directories hold symlinks into
|
|
60
|
+
* `~/.agents/skills`.
|
|
61
|
+
* - `agents` -> `~/.agents/skills` is the shared convention. It is registered
|
|
62
|
+
* on its own too, because a harness directory is often a symlink to it, and
|
|
63
|
+
* it is optional so a machine without it stays quiet.
|
|
64
|
+
* - `zcode` 0.16.5: `zcode skills list` labels every local, non-plugin entry
|
|
65
|
+
* `(user/agents)` and resolves it under `~/.agents/skills`; `~/.zcode` holds
|
|
66
|
+
* only plugin and CLI state, so its user skill convention is the shared one.
|
|
67
|
+
* - `agy` (Antigravity CLI): the installed binary's embedded guide names
|
|
68
|
+
* `~/.gemini/config/` as the global customization root and
|
|
69
|
+
* `~/.gemini/config/skills/<name>/` as a global skill; the
|
|
70
|
+
* `~/.gemini/antigravity-cli/builtin/skills` tree is shipped, read-only.
|
|
71
|
+
* - `dsh`: `dsh --help` lists no skills command and no skills directory exists,
|
|
72
|
+
* so it has no registration target. It is kept in the table to record the
|
|
73
|
+
* measurement, and reported as `no skill support`.
|
|
74
|
+
*
|
|
75
|
+
* @type {Record<string, {binary: string|null, optional: boolean, dir: ((home: string) => string)|null}>}
|
|
76
|
+
*/
|
|
77
|
+
const HARNESS_SKILL_DIRS = {
|
|
78
|
+
claude: { binary: "claude", optional: false, dir: (home) => join(home, ".claude", "skills") },
|
|
79
|
+
codex: { binary: "codex", optional: false, dir: (home) => join(home, ".codex", "skills") },
|
|
80
|
+
zcode: { binary: "zcode", optional: false, dir: (home) => join(home, ".agents", "skills") },
|
|
81
|
+
agy: { binary: "agy", optional: false, dir: (home) => join(home, ".gemini", "config", "skills") },
|
|
82
|
+
dsh: { binary: "dsh", optional: false, dir: null },
|
|
83
|
+
agents: { binary: null, optional: true, dir: (home) => join(home, ".agents", "skills") },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** @typedef {(text: string) => void} Writer */
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {object} SkillTarget
|
|
89
|
+
* @property {string} harness
|
|
90
|
+
* @property {string|null} dir
|
|
91
|
+
* @property {string|null} binary
|
|
92
|
+
* @property {boolean} installed
|
|
93
|
+
* @property {boolean} dirExists
|
|
94
|
+
* @property {boolean} unsupported
|
|
95
|
+
* @property {boolean} optional
|
|
96
|
+
*/
|
|
97
|
+
/**
|
|
98
|
+
* @typedef {object} SkillRegistration
|
|
99
|
+
* @property {string} harness
|
|
100
|
+
* @property {string|null} dir
|
|
101
|
+
* @property {"linked"|"copied"|"unchanged"|"skipped"|"no_dir"|"not_installed"|"unsupported"} action
|
|
102
|
+
*/
|
|
103
|
+
|
|
28
104
|
/**
|
|
29
105
|
* @param {string[]} args
|
|
30
106
|
* @returns {void}
|
|
@@ -39,12 +115,30 @@ export function skillsCli(args) {
|
|
|
39
115
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
40
116
|
return usage();
|
|
41
117
|
}
|
|
42
|
-
const values = /** @type {{target?: string, global?: boolean, force?: boolean}} */ (parsed.values);
|
|
118
|
+
const values = /** @type {{target?: string, global?: boolean, force?: boolean, copy?: boolean, json?: boolean, harness?: string}} */ (parsed.values);
|
|
43
119
|
if (operation === "list") {
|
|
44
120
|
if (parsed.positionals.length) return usage();
|
|
45
121
|
for (const name of catalog()) process.stdout.write(`${name}\n`);
|
|
46
122
|
return;
|
|
47
123
|
}
|
|
124
|
+
if (operation === "register") {
|
|
125
|
+
if (parsed.positionals.length) return usage();
|
|
126
|
+
try {
|
|
127
|
+
const results = registerSkills({
|
|
128
|
+
harnesses: splitHarnesses(values.harness),
|
|
129
|
+
copy: values.copy === true,
|
|
130
|
+
force: values.force === true,
|
|
131
|
+
env: process.env,
|
|
132
|
+
level: values.json === true ? 0 : colorLevel(process.env, process.stdout.isTTY),
|
|
133
|
+
stdout: values.json === true ? () => {} : (text) => process.stdout.write(text),
|
|
134
|
+
});
|
|
135
|
+
if (values.json === true) process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
138
|
+
return usage();
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
48
142
|
const skillsDir = values.global
|
|
49
143
|
? join(homedir(), ".claude", "skills")
|
|
50
144
|
: join(values.target ? resolve(values.target) : process.cwd(), ".claude", "skills");
|
|
@@ -88,6 +182,212 @@ export function installSkills({ names, skillsDir, force, stdout }) {
|
|
|
88
182
|
return { installed, skipped };
|
|
89
183
|
}
|
|
90
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The harness skills directories that exist on this machine, in table order,
|
|
187
|
+
* deduplicated by directory. `zcode` and the shared `agents` entry name the
|
|
188
|
+
* same `~/.agents/skills`; whichever is installed first claims it. A filter of
|
|
189
|
+
* harness names limits the result and rejects a name that is not in the table.
|
|
190
|
+
*
|
|
191
|
+
* @param {{env?: NodeJS.ProcessEnv, harnesses?: string[], isInstalled?: (name: string) => boolean}} [options]
|
|
192
|
+
* @returns {SkillTarget[]}
|
|
193
|
+
*/
|
|
194
|
+
export function discoverSkillTargets(options = {}) {
|
|
195
|
+
const env = options.env ?? process.env;
|
|
196
|
+
const home = skillHome(env);
|
|
197
|
+
const requested = options.harnesses && options.harnesses.length ? options.harnesses : null;
|
|
198
|
+
if (requested) {
|
|
199
|
+
for (const name of requested) {
|
|
200
|
+
if (!Object.hasOwn(HARNESS_SKILL_DIRS, name)) {
|
|
201
|
+
throw new Error(`no harness named "${name}" (choose from ${Object.keys(HARNESS_SKILL_DIRS).join(", ")})`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const filter = requested ? new Set(requested) : null;
|
|
206
|
+
const isInstalled = options.isInstalled ?? ((name) => findExecutable(name) !== null);
|
|
207
|
+
/** @type {SkillTarget[]} */
|
|
208
|
+
const targets = [];
|
|
209
|
+
const claimed = new Set();
|
|
210
|
+
for (const [harness, entry] of Object.entries(HARNESS_SKILL_DIRS)) {
|
|
211
|
+
if (filter && !filter.has(harness)) continue;
|
|
212
|
+
const dir = entry.dir ? entry.dir(home) : null;
|
|
213
|
+
const installed = entry.binary ? isInstalled(entry.binary) : true;
|
|
214
|
+
if (dir && claimed.has(dir)) continue;
|
|
215
|
+
if (installed && dir && !entry.optional) claimed.add(dir);
|
|
216
|
+
targets.push({
|
|
217
|
+
harness,
|
|
218
|
+
dir,
|
|
219
|
+
binary: entry.binary,
|
|
220
|
+
installed,
|
|
221
|
+
dirExists: dir !== null && existsSync(dir),
|
|
222
|
+
unsupported: dir === null,
|
|
223
|
+
optional: entry.optional,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return targets;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Link or copy the `faberun` skill into every installed harness's skills
|
|
231
|
+
* directory. A harness whose binary is absent is reported and skipped; one
|
|
232
|
+
* whose directory is missing is a `[warn]` unless `force` creates it. A real
|
|
233
|
+
* directory already at the destination is left alone unless `force`. The
|
|
234
|
+
* caller owns the wire and the streams; `--json` passes a no-op writer.
|
|
235
|
+
*
|
|
236
|
+
* @param {{harnesses?: string[], copy?: boolean, force?: boolean, env?: NodeJS.ProcessEnv, level?: number, stdout?: Writer, isInstalled?: (name: string) => boolean}} [options]
|
|
237
|
+
* @returns {SkillRegistration[]}
|
|
238
|
+
*/
|
|
239
|
+
export function registerSkills(options = {}) {
|
|
240
|
+
const env = options.env ?? process.env;
|
|
241
|
+
const home = skillHome(env);
|
|
242
|
+
const stdout = options.stdout ?? ((text) => process.stdout.write(text));
|
|
243
|
+
const level = options.level ?? 0;
|
|
244
|
+
const force = options.force === true;
|
|
245
|
+
const source = skillSource(env);
|
|
246
|
+
const targets = discoverSkillTargets({ env, harnesses: options.harnesses, isInstalled: options.isInstalled });
|
|
247
|
+
/** @type {SkillRegistration[]} */
|
|
248
|
+
const results = [];
|
|
249
|
+
for (const target of targets) {
|
|
250
|
+
if (target.unsupported || target.dir === null) {
|
|
251
|
+
results.push({ harness: target.harness, dir: null, action: "unsupported" });
|
|
252
|
+
stdout(`${statusToken("ok", level)} ${target.harness} · no skill support\n`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const dir = target.dir;
|
|
256
|
+
const destination = join(dir, "faberun");
|
|
257
|
+
if (!target.installed) {
|
|
258
|
+
results.push({ harness: target.harness, dir: destination, action: "not_installed" });
|
|
259
|
+
stdout(`${statusToken("ok", level)} ${target.harness} · not installed\n`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (!target.dirExists && !force) {
|
|
263
|
+
if (target.optional) continue;
|
|
264
|
+
results.push({ harness: target.harness, dir: destination, action: "no_dir" });
|
|
265
|
+
stdout(`${statusToken("warn", level)} ${target.harness} · no skills directory (${displayPath(dir, home)})\n`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
mkdirSync(dir, { recursive: true });
|
|
269
|
+
const action = options.copy === true ? copySkill(source, destination, force) : linkSkill(source, destination, force);
|
|
270
|
+
results.push({ harness: target.harness, dir: destination, action });
|
|
271
|
+
const path = displayPath(destination, home);
|
|
272
|
+
if (action === "skipped") {
|
|
273
|
+
stdout(`${statusToken("warn", level)} ${target.harness} · ${path} · exists, use --force\n`);
|
|
274
|
+
} else {
|
|
275
|
+
stdout(`${statusToken("ok", level)} ${target.harness} · ${path} · ${action}\n`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return results;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The skill tree a registration points at: `$FABERUN_HOME/current/skills/faberun`
|
|
283
|
+
* when this CLI runs from the installed home layout, so the link follows every
|
|
284
|
+
* update, and this checkout's `skills/faberun` otherwise.
|
|
285
|
+
*
|
|
286
|
+
* @param {NodeJS.ProcessEnv} env
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
function skillSource(env) {
|
|
290
|
+
const home = faberunHome(env);
|
|
291
|
+
return installedVersionDir(process.argv[1], home) ? join(home, "current", "skills", "faberun") : CHECKOUT_SKILL;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {string} source
|
|
296
|
+
* @param {string} destination
|
|
297
|
+
* @param {boolean} force
|
|
298
|
+
* @returns {"linked"|"unchanged"|"skipped"}
|
|
299
|
+
*/
|
|
300
|
+
function linkSkill(source, destination, force) {
|
|
301
|
+
const existing = lstatOrNull(destination);
|
|
302
|
+
if (existing) {
|
|
303
|
+
if (existing.isSymbolicLink() && !force && resolvesTo(destination, source)) return "unchanged";
|
|
304
|
+
if (!existing.isSymbolicLink() && !force) return "skipped";
|
|
305
|
+
rmSync(destination, { recursive: true, force: true });
|
|
306
|
+
}
|
|
307
|
+
symlinkSync(source, destination);
|
|
308
|
+
return "linked";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {string} source
|
|
313
|
+
* @param {string} destination
|
|
314
|
+
* @param {boolean} force
|
|
315
|
+
* @returns {"copied"|"unchanged"|"skipped"}
|
|
316
|
+
*/
|
|
317
|
+
function copySkill(source, destination, force) {
|
|
318
|
+
const existing = lstatOrNull(destination);
|
|
319
|
+
if (existing) {
|
|
320
|
+
if (existing.isDirectory() && !force) return "unchanged";
|
|
321
|
+
if (!force && !existing.isSymbolicLink()) return "skipped";
|
|
322
|
+
rmSync(destination, { recursive: true, force: true });
|
|
323
|
+
}
|
|
324
|
+
cpSync(source, destination, { recursive: true });
|
|
325
|
+
return "copied";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* @param {string} path
|
|
330
|
+
* @returns {import("node:fs").Stats|null}
|
|
331
|
+
*/
|
|
332
|
+
function lstatOrNull(path) {
|
|
333
|
+
try {
|
|
334
|
+
return lstatSync(path);
|
|
335
|
+
} catch {
|
|
336
|
+
// A path that does not exist has no stats; the caller treats that as "free".
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @param {string} destination
|
|
343
|
+
* @param {string} source
|
|
344
|
+
* @returns {boolean}
|
|
345
|
+
*/
|
|
346
|
+
function resolvesTo(destination, source) {
|
|
347
|
+
try {
|
|
348
|
+
return realpathSync(destination) === realpathSync(source);
|
|
349
|
+
} catch {
|
|
350
|
+
// Either path may be a dangling symlink; it cannot already point at source.
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The home directory the skill directories hang off. `HOME` is honoured so a
|
|
357
|
+
* test or an install can point the whole discovery at a temporary tree.
|
|
358
|
+
*
|
|
359
|
+
* @param {NodeJS.ProcessEnv} env
|
|
360
|
+
* @returns {string}
|
|
361
|
+
*/
|
|
362
|
+
function skillHome(env) {
|
|
363
|
+
const configured = env.HOME;
|
|
364
|
+
if (typeof configured === "string" && configured) return configured;
|
|
365
|
+
return homedir();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Render a path under the home directory as `~/…` for the human lines; the
|
|
370
|
+
* machine-readable `dir` stays absolute.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} path
|
|
373
|
+
* @param {string} home
|
|
374
|
+
* @returns {string}
|
|
375
|
+
*/
|
|
376
|
+
function displayPath(path, home) {
|
|
377
|
+
const prefix = home.endsWith(sep) ? home : `${home}${sep}`;
|
|
378
|
+
if (path === home) return "~";
|
|
379
|
+
return path.startsWith(prefix) ? `~${sep}${path.slice(prefix.length)}` : path;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {string|undefined} text
|
|
384
|
+
* @returns {string[]}
|
|
385
|
+
*/
|
|
386
|
+
function splitHarnesses(text) {
|
|
387
|
+
if (typeof text !== "string" || !text) return [];
|
|
388
|
+
return [...new Set(text.split(",").map((name) => name.trim()).filter(Boolean))];
|
|
389
|
+
}
|
|
390
|
+
|
|
91
391
|
/**
|
|
92
392
|
* @returns {string[]} catalogue entries that carry a SKILL.md
|
|
93
393
|
*/
|
|
@@ -100,6 +400,6 @@ function catalog() {
|
|
|
100
400
|
|
|
101
401
|
/** @returns {void} */
|
|
102
402
|
function usage() {
|
|
103
|
-
process.stderr.write("usage: faberun skills <list|install> [<name>...] [--target <dir>] [--global] [--force]\n");
|
|
403
|
+
process.stderr.write("usage: faberun skills <list|install|register> [<name>...] [--target <dir>] [--global] [--force] [--copy] [--harness <a,b>] [--json]\n");
|
|
104
404
|
process.exitCode = 2;
|
|
105
405
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -102,7 +102,7 @@ const COMMAND_OPTIONS = {
|
|
|
102
102
|
"bulk-read": { question: { type: "string" }, paths: { type: "string", multiple: true }, json: { type: "boolean" } },
|
|
103
103
|
next: { cwd: { type: "string" }, json: { type: "boolean" } },
|
|
104
104
|
update: { check: { type: "boolean" }, json: { type: "boolean" } },
|
|
105
|
-
setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, json: { type: "boolean" } },
|
|
105
|
+
setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
|
|
106
106
|
init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
|
|
107
107
|
metrics: METRICS_OPTIONS,
|
|
108
108
|
};
|
|
@@ -251,6 +251,7 @@ async function main(argv) {
|
|
|
251
251
|
harnesses: typeof values.harnesses === "string" ? values.harnesses : undefined,
|
|
252
252
|
worker: typeof values.worker === "string" ? values.worker : undefined,
|
|
253
253
|
judge: typeof values.judge === "string" ? values.judge : undefined,
|
|
254
|
+
skill: values["no-skill"] !== true,
|
|
254
255
|
json: values.json === true,
|
|
255
256
|
env: process.env,
|
|
256
257
|
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
* `contract.finalVerification` is the contract-wide proof that the phase as a
|
|
6
6
|
* whole closes: the controller runs it before the judge on the phase-terminal
|
|
7
7
|
* node (the node no other node depends on), so no final checkpoint is ever
|
|
8
|
-
* approved on partial verification.
|
|
9
|
-
*
|
|
8
|
+
* approved on partial verification. `contract.sharedVerification` carries the
|
|
9
|
+
* same command schema but is appended to every node's attempt and integration
|
|
10
|
+
* candidate, for the fast repository ratchets a node's write set can break.
|
|
11
|
+
* The persisted node-snapshot shape for verification evidence lives here too,
|
|
12
|
+
* next to the schema it records.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import { Buffer } from "node:buffer";
|
|
@@ -29,6 +32,32 @@ export function validateFinalVerification(value, label = "contract.finalVerifica
|
|
|
29
32
|
return validateVerificationCommands(value, label);
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Validate the optional contract-level `sharedVerification` field. It carries
|
|
37
|
+
* the identical verification-command schema as `finalVerification`; only the
|
|
38
|
+
* audience differs.
|
|
39
|
+
*
|
|
40
|
+
* @param {unknown} value
|
|
41
|
+
* @param {string} label
|
|
42
|
+
* @returns {VerificationCommand[]|undefined}
|
|
43
|
+
*/
|
|
44
|
+
export function validateSharedVerification(value, label = "contract.sharedVerification") {
|
|
45
|
+
if (value === undefined) return undefined;
|
|
46
|
+
return validateVerificationCommands(value, label);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The contract's `sharedVerification` commands, which every node's verification
|
|
51
|
+
* carries on both its attempt and its integration candidate. Absent means none,
|
|
52
|
+
* so a contract that declares nothing is unchanged.
|
|
53
|
+
*
|
|
54
|
+
* @param {{sharedVerification?: VerificationCommand[]}} contract
|
|
55
|
+
* @returns {VerificationCommand[]}
|
|
56
|
+
*/
|
|
57
|
+
export function sharedVerificationCommands(contract) {
|
|
58
|
+
return contract.sharedVerification ?? [];
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
/**
|
|
33
62
|
* The contract's `finalVerification` commands when this node is the one that
|
|
34
63
|
* closes the phase, otherwise none. A node is phase-terminal when no other
|