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.
- package/README.md +152 -100
- package/package.json +10 -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/campaign.mjs +2 -0
- package/src/cli/contract.mjs +2 -0
- package/src/cli/manual.mjs +341 -0
- package/src/cli/seat.mjs +2 -0
- package/src/cli/setup.mjs +109 -30
- package/src/cli/skills.mjs +310 -8
- package/src/cli.mjs +3 -2
- package/src/contract/final-verification.mjs +31 -2
- package/src/contract/index.mjs +28 -25
- package/src/contract/runtime.mjs +5 -1
- package/src/contract/snapshot.mjs +7 -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 +31 -4
- 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/final.mjs +3 -2
- package/src/report/render.mjs +134 -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
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
|