faberun 0.3.0 → 0.7.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 (51) hide show
  1. package/README.md +152 -100
  2. package/package.json +10 -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/campaign.mjs +2 -0
  13. package/src/cli/contract.mjs +2 -0
  14. package/src/cli/manual.mjs +341 -0
  15. package/src/cli/seat.mjs +2 -0
  16. package/src/cli/setup.mjs +109 -30
  17. package/src/cli/skills.mjs +310 -8
  18. package/src/cli.mjs +3 -2
  19. package/src/contract/final-verification.mjs +31 -2
  20. package/src/contract/index.mjs +28 -25
  21. package/src/contract/runtime.mjs +5 -1
  22. package/src/contract/snapshot.mjs +7 -1
  23. package/src/contract/task-packet.mjs +20 -9
  24. package/src/contract/verification.mjs +1 -1
  25. package/src/engine/backoff.mjs +1 -1
  26. package/src/engine/dispatch.mjs +31 -4
  27. package/src/engine/gate.mjs +12 -0
  28. package/src/engine/process-identity.mjs +39 -0
  29. package/src/engine/prompts.mjs +18 -0
  30. package/src/engine/resume.mjs +2 -2
  31. package/src/engine/review.mjs +9 -1
  32. package/src/engine/run-command.mjs +23 -2
  33. package/src/engine/run-identity.mjs +14 -0
  34. package/src/engine/scheduler.mjs +45 -12
  35. package/src/engine/settle.mjs +29 -0
  36. package/src/engine/supervise.mjs +32 -6
  37. package/src/engine/verify.mjs +98 -9
  38. package/src/harnesses/agy/index.mjs +3 -0
  39. package/src/harnesses/claude/index.mjs +5 -0
  40. package/src/harnesses/codex/index.mjs +3 -0
  41. package/src/harnesses/dsh/index.mjs +26 -0
  42. package/src/harnesses/exec-jsonl/index.mjs +2 -0
  43. package/src/harnesses/index.mjs +10 -3
  44. package/src/harnesses/replay/index.mjs +2 -0
  45. package/src/harnesses/zcode/index.mjs +3 -0
  46. package/src/host/preflight.mjs +5 -1
  47. package/src/notify/index.mjs +45 -2
  48. package/src/repo/source-identity.mjs +4 -3
  49. package/src/report/final.mjs +3 -2
  50. package/src/report/render.mjs +134 -51
  51. package/src/web/index.html +1 -1
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Regenerates the derivable parts of docs/COMMANDS.md — verb and operation
3
+ * headings, synopsis lines and flag-table rows — from the option tables the
4
+ * CLI itself dispatches on. Every other line (description paragraphs,
5
+ * reads/writes prose, examples, Related lines, and the four fixed sections)
6
+ * is copied through unchanged, so the manual's prose stays hand-authored
7
+ * while its command surface cannot drift from the code silently.
8
+ */
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { COMMAND_OPTIONS } from "../cli.mjs";
13
+ import CAMPAIGN_OPERATIONS from "./campaign.mjs";
14
+ import SEAT_OPERATIONS from "./seat.mjs";
15
+ import CONTRACT_OPERATIONS from "./contract.mjs";
16
+ import SKILLS_OPERATIONS from "./skills.mjs";
17
+
18
+ /** @typedef {{type: "string"|"boolean", multiple?: boolean}} FlagSpec */
19
+ /** @typedef {{flags?: Record<string, FlagSpec>, operations?: Record<string, Record<string, FlagSpec>>}} VerbSurface */
20
+ /** @typedef {{verbs: Record<string, VerbSurface>}} Surface */
21
+
22
+ const MANUAL_PATH = fileURLToPath(new URL("../../docs/COMMANDS.md", import.meta.url));
23
+
24
+ /**
25
+ * `campaign`, `seat`, `contract` and `skills` are dispatched before
26
+ * `COMMAND_OPTIONS` is ever consulted (`cli.mjs` routes them by `argv[0]`), so
27
+ * they carry no flags of their own — only the operations their own module
28
+ * declares. Their top-level `## faberun <verb>` section is therefore never
29
+ * regenerated; it is hand-authored overview prose, preserved verbatim.
30
+ *
31
+ * @type {Record<string, Record<string, Record<string, FlagSpec>>>}
32
+ */
33
+ const CONTAINER_OPERATIONS = {
34
+ campaign: CAMPAIGN_OPERATIONS,
35
+ seat: SEAT_OPERATIONS,
36
+ contract: CONTRACT_OPERATIONS,
37
+ skills: SKILLS_OPERATIONS,
38
+ };
39
+
40
+ /**
41
+ * The real command surface, read from the same option tables `cli.mjs`
42
+ * parses argv against. `supervise campaign` is `campaign.mjs`'s `supervise`
43
+ * operation reached through a second spelling (`cli.mjs` routes
44
+ * `argv = ["supervise", "campaign", …]` into `campaignCli`), so it shares that
45
+ * operation's flags rather than declaring its own.
46
+ *
47
+ * @returns {Surface}
48
+ */
49
+ export function collectSurface() {
50
+ /** @type {Record<string, VerbSurface>} */
51
+ const verbs = {};
52
+ for (const [verb, flags] of Object.entries(COMMAND_OPTIONS)) verbs[verb] = { flags };
53
+ if (verbs.supervise) verbs.supervise.operations = { campaign: CAMPAIGN_OPERATIONS.supervise };
54
+ for (const [verb, operations] of Object.entries(CONTAINER_OPERATIONS)) verbs[verb] = { operations };
55
+ return { verbs };
56
+ }
57
+
58
+ const VERB_HEADING = /^## faberun ([a-z][a-z-]*)$/u;
59
+ const TABLE_HEADER = "| Flag | Value | Effect | Default |";
60
+ const TABLE_SEPARATOR = "| --- | --- | --- | --- |";
61
+ /** The start of a flag token in a synopsis line: `--flag` or `[--flag`. */
62
+ const FLAG_TOKEN = /\[?--/u;
63
+
64
+ /**
65
+ * Regenerate the derivable parts of a command manual. A verb absent from
66
+ * `surface` is dropped; one present in `surface` but absent from `current` is
67
+ * appended as a skeleton section.
68
+ *
69
+ * @param {string} current
70
+ * @param {Surface} surface
71
+ * @returns {string}
72
+ */
73
+ export function renderManual(current, surface) {
74
+ const lines = current.split("\n");
75
+ /** @type {string[]} */
76
+ const output = [];
77
+ const seenVerbs = new Set();
78
+ let i = 0;
79
+ while (i < lines.length) {
80
+ const match = VERB_HEADING.exec(lines[i]);
81
+ if (!match) {
82
+ output.push(lines[i]);
83
+ i += 1;
84
+ continue;
85
+ }
86
+ const verb = match[1];
87
+ let end = i + 1;
88
+ while (end < lines.length && !/^## /u.test(lines[end])) end += 1;
89
+ const entry = surface.verbs[verb];
90
+ if (entry) {
91
+ output.push(...renderVerbBlock(verb, lines.slice(i, end), entry));
92
+ seenVerbs.add(verb);
93
+ }
94
+ i = end;
95
+ }
96
+ for (const [verb, entry] of Object.entries(surface.verbs)) {
97
+ if (!seenVerbs.has(verb)) output.push(...renderVerbBlock(verb, [`## faberun ${verb}`], entry));
98
+ }
99
+ return output.join("\n");
100
+ }
101
+
102
+ /**
103
+ * @param {string} verb
104
+ * @param {string[]} block
105
+ * @param {VerbSurface} entry
106
+ * @returns {string[]}
107
+ */
108
+ function renderVerbBlock(verb, block, entry) {
109
+ const heading = block[0] ?? `## faberun ${verb}`;
110
+ const { body, operationBlocks } = splitOperations(verb, block.slice(1));
111
+ const renderedBody = entry.flags
112
+ ? renderFlaggedBody(`faberun ${verb}`, body, entry.flags)
113
+ : body.length
114
+ ? body
115
+ : renderFlaggedBody(`faberun ${verb}`, [], {});
116
+ const renderedOperations = renderOperations(verb, operationBlocks, entry.operations ?? {});
117
+ return [heading, ...renderedBody, ...renderedOperations];
118
+ }
119
+
120
+ /**
121
+ * Splits a verb's body into the part before its first `### faberun <verb>
122
+ * <op>` heading and the operation sub-blocks that follow, each running to the
123
+ * next `### ` heading.
124
+ *
125
+ * @param {string} verb
126
+ * @param {string[]} lines
127
+ * @returns {{body: string[], operationBlocks: {op: string, block: string[]}[]}}
128
+ */
129
+ function splitOperations(verb, lines) {
130
+ const opHeading = new RegExp(`^### faberun ${verb} ([a-z][a-z-]*)$`, "u");
131
+ const firstOpIndex = lines.findIndex((line) => opHeading.test(line));
132
+ if (firstOpIndex === -1) return { body: lines, operationBlocks: [] };
133
+ const body = lines.slice(0, firstOpIndex);
134
+ /** @type {{op: string, block: string[]}[]} */
135
+ const operationBlocks = [];
136
+ let i = firstOpIndex;
137
+ while (i < lines.length) {
138
+ const match = opHeading.exec(lines[i]);
139
+ if (!match) break;
140
+ let end = i + 1;
141
+ while (end < lines.length && !/^###? /u.test(lines[end])) end += 1;
142
+ operationBlocks.push({ op: match[1], block: lines.slice(i, end) });
143
+ i = end;
144
+ }
145
+ return { body, operationBlocks };
146
+ }
147
+
148
+ /**
149
+ * @param {string} verb
150
+ * @param {{op: string, block: string[]}[]} operationBlocks
151
+ * @param {Record<string, Record<string, FlagSpec>>} operations
152
+ * @returns {string[]}
153
+ */
154
+ function renderOperations(verb, operationBlocks, operations) {
155
+ const output = [];
156
+ const seen = new Set();
157
+ for (const { op, block } of operationBlocks) {
158
+ if (!Object.hasOwn(operations, op)) continue;
159
+ output.push(block[0] ?? `### faberun ${verb} ${op}`, ...renderFlaggedBody(`faberun ${verb} ${op}`, block.slice(1), operations[op]));
160
+ seen.add(op);
161
+ }
162
+ for (const [op, flags] of Object.entries(operations)) {
163
+ if (seen.has(op)) continue;
164
+ output.push(`### faberun ${verb} ${op}`, ...renderFlaggedBody(`faberun ${verb} ${op}`, [], flags));
165
+ }
166
+ return output;
167
+ }
168
+
169
+ /**
170
+ * Regenerates a section's synopsis fence and flag table in place; every
171
+ * other line is untouched.
172
+ *
173
+ * @param {string} prefix
174
+ * @param {string[]} body
175
+ * @param {Record<string, FlagSpec>} flags
176
+ * @returns {string[]}
177
+ */
178
+ function renderFlaggedBody(prefix, body, flags) {
179
+ const positional = extractPositional(prefix, body);
180
+ const synopsis = renderSynopsis(prefix, positional, flags);
181
+ const fence = findFence(body, "```text");
182
+ const withSynopsis = fence
183
+ ? [...body.slice(0, fence.start), "```text", synopsis, "```", ...body.slice(fence.end + 1)]
184
+ : ["```text", synopsis, "```", ...body];
185
+ return replaceFlagTable(withSynopsis, flags);
186
+ }
187
+
188
+ /**
189
+ * The positional placeholder a synopsis names, kept verbatim from the
190
+ * current text (including its own brackets, when optional) — everything
191
+ * before the first flag token.
192
+ *
193
+ * @param {string} prefix
194
+ * @param {string[]} body
195
+ * @returns {string}
196
+ */
197
+ function extractPositional(prefix, body) {
198
+ const fence = findFence(body, "```text");
199
+ if (!fence) return "";
200
+ const inner = body[fence.start + 1] ?? "";
201
+ if (!inner.startsWith(prefix)) return "";
202
+ const remainder = inner.slice(prefix.length).trim();
203
+ const flagToken = FLAG_TOKEN.exec(remainder);
204
+ return flagToken ? remainder.slice(0, flagToken.index).trim() : remainder;
205
+ }
206
+
207
+ /**
208
+ * @param {string} prefix
209
+ * @param {string} positional
210
+ * @param {Record<string, FlagSpec>} flags
211
+ * @returns {string}
212
+ */
213
+ function renderSynopsis(prefix, positional, flags) {
214
+ const parts = [prefix];
215
+ if (positional) parts.push(positional);
216
+ for (const [name, spec] of Object.entries(flags)) {
217
+ if (spec.type === "boolean") parts.push(`[--${name}]`);
218
+ else if (spec.multiple) parts.push(`[--${name} <a>...]`);
219
+ else parts.push(`[--${name} <value>]`);
220
+ }
221
+ return parts.join(" ");
222
+ }
223
+
224
+ /**
225
+ * @param {string[]} lines
226
+ * @param {string} opener
227
+ * @returns {{start: number, end: number}|null}
228
+ */
229
+ function findFence(lines, opener) {
230
+ const start = lines.indexOf(opener);
231
+ if (start === -1) return null;
232
+ let end = start + 1;
233
+ while (end < lines.length && lines[end] !== "```") end += 1;
234
+ return { start, end };
235
+ }
236
+
237
+ /**
238
+ * @param {string[]} lines
239
+ * @param {Record<string, FlagSpec>} flags
240
+ * @returns {string[]}
241
+ */
242
+ function replaceFlagTable(lines, flags) {
243
+ const headerIndex = lines.indexOf(TABLE_HEADER);
244
+ const existingRows = headerIndex === -1 ? new Map() : parseRows(lines, headerIndex + 2);
245
+ const newRows = buildRows(flags, existingRows);
246
+ if (headerIndex === -1) {
247
+ const fenceEnd = lines.indexOf("```");
248
+ const insertAt = fenceEnd === -1 ? lines.length : fenceEnd + 1;
249
+ return [...lines.slice(0, insertAt), TABLE_HEADER, TABLE_SEPARATOR, ...newRows, ...lines.slice(insertAt)];
250
+ }
251
+ let rowsEnd = headerIndex + 2;
252
+ while (rowsEnd < lines.length && lines[rowsEnd].startsWith("|")) rowsEnd += 1;
253
+ return [...lines.slice(0, headerIndex), TABLE_HEADER, TABLE_SEPARATOR, ...newRows, ...lines.slice(rowsEnd)];
254
+ }
255
+
256
+ /**
257
+ * @param {string[]} lines
258
+ * @param {number} start
259
+ * @returns {Map<string, {value: string, effect: string, default: string}>}
260
+ */
261
+ function parseRows(lines, start) {
262
+ /** @type {Map<string, {value: string, effect: string, default: string}>} */
263
+ const map = new Map();
264
+ let i = start;
265
+ while (i < lines.length && lines[i].startsWith("|")) {
266
+ const cells = lines[i].trim().replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => cell.trim());
267
+ if (cells.length === 4) map.set(flagKeyOf(cells[0]), { value: cells[1], effect: cells[2], default: cells[3] });
268
+ i += 1;
269
+ }
270
+ return map;
271
+ }
272
+
273
+ /** @param {string} cell @returns {string} */
274
+ function flagKeyOf(cell) {
275
+ const match = /`--([a-z-]+)`/u.exec(cell);
276
+ return match ? match[1] : "—";
277
+ }
278
+
279
+ /**
280
+ * @param {Record<string, FlagSpec>} flags
281
+ * @param {Map<string, {value: string, effect: string, default: string}>} existingRows
282
+ * @returns {string[]}
283
+ */
284
+ function buildRows(flags, existingRows) {
285
+ const names = Object.keys(flags);
286
+ if (names.length === 0) {
287
+ const existing = existingRows.get("—");
288
+ return [existing ? `| — | ${existing.value} | ${existing.effect} | ${existing.default} |` : "| — | — | No flags. | — |"];
289
+ }
290
+ return names.map((name) => {
291
+ const existing = existingRows.get(name);
292
+ const value = existing ? existing.value : "<value>";
293
+ const effect = existing ? existing.effect : "";
294
+ const fallback = existing ? existing.default : "—";
295
+ return `| \`--${name}\` | ${value} | ${effect} | ${fallback} |`;
296
+ });
297
+ }
298
+
299
+ /**
300
+ * A minimal, dependency-free diff summary: every line index where the two
301
+ * texts disagree, capped so a large rewrite does not flood the console.
302
+ *
303
+ * @param {string} current
304
+ * @param {string} next
305
+ * @returns {string}
306
+ */
307
+ function diffSummary(current, next) {
308
+ const a = current.split("\n");
309
+ const b = next.split("\n");
310
+ const max = Math.max(a.length, b.length);
311
+ /** @type {string[]} */
312
+ const lines = [];
313
+ for (let i = 0; i < max && lines.length < 40; i += 1) {
314
+ if (a[i] !== b[i]) lines.push(`line ${i + 1}:\n- ${a[i] ?? "<eof>"}\n+ ${b[i] ?? "<eof>"}`);
315
+ }
316
+ return lines.join("\n");
317
+ }
318
+
319
+ /**
320
+ * @param {string[]} argv
321
+ * @returns {void}
322
+ */
323
+ function main(argv) {
324
+ const mode = argv[0];
325
+ if (mode !== "--write" && mode !== "--check") {
326
+ process.stderr.write("usage: manual.mjs --write|--check\n");
327
+ process.exitCode = 2;
328
+ return;
329
+ }
330
+ const current = readFileSync(MANUAL_PATH, "utf8");
331
+ const next = renderManual(current, collectSurface());
332
+ if (next === current) return;
333
+ if (mode === "--write") {
334
+ writeFileSync(MANUAL_PATH, next);
335
+ return;
336
+ }
337
+ process.stderr.write(`docs/COMMANDS.md is out of date; run \`npm run docs\`.\n${diffSummary(current, next)}\n`);
338
+ process.exitCode = 1;
339
+ }
340
+
341
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) main(process.argv.slice(2));
package/src/cli/seat.mjs CHANGED
@@ -137,3 +137,5 @@ function usage() {
137
137
  process.stderr.write("usage: faberun seat <start|attach|status|stop|switch> [<campaign-id>] [--cwd <dir>] ...\n");
138
138
  process.exitCode = 2;
139
139
  }
140
+
141
+ export default OPERATION_OPTIONS;
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