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.
Files changed (45) hide show
  1. package/README.md +152 -100
  2. package/package.json +8 -2
  3. package/skills/faberun/SKILL.md +6 -5
  4. package/skills/faberun/references/contract.md +23 -11
  5. package/skills/faberun/references/engineering.md +3 -1
  6. package/skills/faberun/references/operations.md +19 -12
  7. package/skills/faberun/references/rules.md +3 -1
  8. package/src/campaign/chain.mjs +6 -2
  9. package/src/campaign/index.mjs +17 -1
  10. package/src/campaign/metrics.mjs +3 -3
  11. package/src/cli/brand.mjs +2 -1
  12. package/src/cli/setup.mjs +109 -30
  13. package/src/cli/skills.mjs +308 -8
  14. package/src/cli.mjs +2 -1
  15. package/src/contract/final-verification.mjs +31 -2
  16. package/src/contract/index.mjs +27 -24
  17. package/src/contract/runtime.mjs +5 -1
  18. package/src/contract/task-packet.mjs +20 -9
  19. package/src/contract/verification.mjs +1 -1
  20. package/src/engine/backoff.mjs +1 -1
  21. package/src/engine/dispatch.mjs +5 -3
  22. package/src/engine/gate.mjs +12 -0
  23. package/src/engine/process-identity.mjs +39 -0
  24. package/src/engine/prompts.mjs +18 -0
  25. package/src/engine/resume.mjs +2 -2
  26. package/src/engine/review.mjs +9 -1
  27. package/src/engine/run-command.mjs +23 -2
  28. package/src/engine/run-identity.mjs +14 -0
  29. package/src/engine/scheduler.mjs +45 -12
  30. package/src/engine/settle.mjs +29 -0
  31. package/src/engine/supervise.mjs +32 -6
  32. package/src/engine/verify.mjs +98 -9
  33. package/src/harnesses/agy/index.mjs +3 -0
  34. package/src/harnesses/claude/index.mjs +5 -0
  35. package/src/harnesses/codex/index.mjs +3 -0
  36. package/src/harnesses/dsh/index.mjs +26 -0
  37. package/src/harnesses/exec-jsonl/index.mjs +2 -0
  38. package/src/harnesses/index.mjs +10 -3
  39. package/src/harnesses/replay/index.mjs +2 -0
  40. package/src/harnesses/zcode/index.mjs +3 -0
  41. package/src/host/preflight.mjs +5 -1
  42. package/src/notify/index.mjs +45 -2
  43. package/src/repo/source-identity.mjs +4 -3
  44. package/src/report/render.mjs +128 -49
  45. 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`. The judge must resolve to a vendor
9
- * other than the worker's; the prompt refuses a same-vendor answer once and the
10
- * command fails on the second.
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 defaultWorker = cheapest(candidates)?.id ?? "";
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? [${availableHarnesses.join(", ")}] `)).trim();
130
- selectedHarnesses = splitHarnesses(harnessAnswer).length ? splitHarnesses(harnessAnswer) : availableHarnesses;
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
- let judgeAnswer = (await asker.ask(`Default judge runtime? [${defaultJudge(selectedWorker, candidates)}] `)).trim();
136
- let judge = judgeAnswer || defaultJudge(selectedWorker, candidates);
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 ?? "") : availableHarnesses;
159
+ selectedHarnesses = splitHarnesses(options.harnesses ?? "").length ? splitHarnesses(options.harnesses ?? "") : defaultHarnesses;
146
160
  selectedWorker = options.worker ?? defaultWorker;
147
- selectedJudge = options.judge ?? defaultJudge(selectedWorker, candidates);
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
- const config = {
159
- schemaVersion: /** @type {1} */ (1),
160
- harnesses: selectedHarnesses,
161
- worker: selectedWorker,
162
- judge: selectedJudge,
163
- updatedAt: new Date().toISOString(),
164
- };
165
- writeUserConfig(env, config);
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
- if (json) {
168
- stdout(`${JSON.stringify({ requirements, runtimes, config }, null, 2)}\n`);
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
@@ -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 `faberun init` installs
6
- * the same catalogue into a target repository; this file owns the wire, the
7
- * same split `seat.mjs` uses.
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 { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
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. The persisted node-snapshot shape for
9
- * verification evidence lives here too, next to the schema it records.
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