faberun 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +12 -2
- package/src/cli/manual.mjs +24 -8
- package/src/cli/setup.mjs +5 -0
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +67 -7
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +109 -0
- package/src/contract/index.mjs +7 -4
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +5 -1
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +10 -0
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +114 -18
- package/src/engine/prompts.mjs +41 -3
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +15 -9
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +29 -0
- package/src/harnesses/claude/index.mjs +98 -1
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +14 -0
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/index.mjs +13 -0
- package/src/harnesses/index.mjs +21 -10
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +32 -9
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/proof-check.mjs +15 -0
- package/src/plan/repo-facts.mjs +95 -11
- package/src/plan/rounds.mjs +27 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- package/src/util.mjs +18 -2
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The executable half of `npm run docs:check`: it runs the getting-started
|
|
3
|
+
* walkthrough on a throwaway repository, under an isolated `FABERUN_HOME`,
|
|
4
|
+
* with the `replay` harness standing in for every provider, and compares the
|
|
5
|
+
* CLI's output against the blocks the guide marks as checked.
|
|
6
|
+
*
|
|
7
|
+
* The manual generator next door keeps `docs/COMMANDS.md` from drifting from
|
|
8
|
+
* the option tables; this module does the same for the one document a
|
|
9
|
+
* newcomer follows literally. Machine-varying fragments — the install home,
|
|
10
|
+
* the project's directory under it, and the run directory — are normalized to
|
|
11
|
+
* the fixed markers `<home>`, `<project>` and `<run>` before the comparison,
|
|
12
|
+
* so the same guide is checkable on any host. A checked output that diverges
|
|
13
|
+
* fails with both the expected excerpt and the one the CLI actually printed.
|
|
14
|
+
*
|
|
15
|
+
* The guide marks its blocks with three invisible HTML comments:
|
|
16
|
+
*
|
|
17
|
+
* `<!-- walkthrough:contract -->` before the fenced contract JSON, which is
|
|
18
|
+
* written to `contract.json` with the replay runtimes composed in;
|
|
19
|
+
* `<!-- walkthrough:run -->` before a fenced command block the check runs; and
|
|
20
|
+
* `<!-- walkthrough:check -->` before a fenced output block that must match
|
|
21
|
+
* the preceding command's output.
|
|
22
|
+
*
|
|
23
|
+
* The markers are HTML comments so the rendered guide is unchanged.
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
31
|
+
|
|
32
|
+
/** The guide this check reads. */
|
|
33
|
+
export const GUIDE_PATH = fileURLToPath(new URL("../../docs/GETTING-STARTED.md", import.meta.url));
|
|
34
|
+
|
|
35
|
+
/** The CLI every `faberun …` line in the guide is run as. */
|
|
36
|
+
const CLI_ENTRY = fileURLToPath(new URL("../cli.mjs", import.meta.url));
|
|
37
|
+
|
|
38
|
+
const CONTRACT_MARKER = "<!-- walkthrough:contract -->";
|
|
39
|
+
const RUN_MARKER = "<!-- walkthrough:run -->";
|
|
40
|
+
const CHECK_MARKER = "<!-- walkthrough:check -->";
|
|
41
|
+
|
|
42
|
+
/** The placeholders the guide and its checked outputs may carry. */
|
|
43
|
+
const PROJECT_LITERAL = "/path/to/target-repository";
|
|
44
|
+
|
|
45
|
+
/** @typedef {{home: string, project: string, projectId: string|null, runId: string, runDir: string|null}} WalkthroughContext */
|
|
46
|
+
/** @typedef {{type: "contract", text: string, line: number}|{type: "command", command: string, output: string|null, line: number}} WalkthroughStep */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The fence whose opening line follows `from`, skipping blank lines. Returns
|
|
50
|
+
* the language token, the body and the line index of the opening and closing
|
|
51
|
+
* fences; `null` when the next non-blank line is not a fence.
|
|
52
|
+
*
|
|
53
|
+
* @param {string[]} lines
|
|
54
|
+
* @param {number} from
|
|
55
|
+
* @returns {{language: string, text: string, start: number, end: number}|null}
|
|
56
|
+
*/
|
|
57
|
+
function readFence(lines, from) {
|
|
58
|
+
let index = from;
|
|
59
|
+
while (index < lines.length && lines[index].trim() === "") index += 1;
|
|
60
|
+
const opening = /^(`{3,})(\S*)/u.exec(lines[index] ?? "");
|
|
61
|
+
if (!opening) return null;
|
|
62
|
+
const fence = opening[1];
|
|
63
|
+
const language = opening[2];
|
|
64
|
+
let end = index + 1;
|
|
65
|
+
while (end < lines.length && lines[end].trim() !== fence) end += 1;
|
|
66
|
+
return { language, text: lines.slice(index + 1, end).join("\n"), start: index, end };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The marked blocks of the guide, in document order.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} markdown
|
|
73
|
+
* @returns {WalkthroughStep[]}
|
|
74
|
+
*/
|
|
75
|
+
export function parseWalkthrough(markdown) {
|
|
76
|
+
const lines = markdown.split("\n");
|
|
77
|
+
/** @type {WalkthroughStep[]} */
|
|
78
|
+
const steps = [];
|
|
79
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
80
|
+
const marker = lines[index].trim();
|
|
81
|
+
if (marker !== CONTRACT_MARKER && marker !== RUN_MARKER && marker !== CHECK_MARKER) continue;
|
|
82
|
+
const block = readFence(lines, index + 1);
|
|
83
|
+
if (!block) throw new Error(`GETTING-STARTED.md line ${index + 1}: ${marker} is not followed by a fenced block`);
|
|
84
|
+
if (marker === CONTRACT_MARKER) {
|
|
85
|
+
steps.push({ type: "contract", text: block.text, line: index + 1 });
|
|
86
|
+
} else if (marker === RUN_MARKER) {
|
|
87
|
+
steps.push({ type: "command", command: block.text, output: null, line: index + 1 });
|
|
88
|
+
} else {
|
|
89
|
+
const command = [...steps].reverse().find((step) => step.type === "command");
|
|
90
|
+
if (!command || command.type !== "command") {
|
|
91
|
+
throw new Error(`GETTING-STARTED.md line ${index + 1}: ${CHECK_MARKER} has no preceding command`);
|
|
92
|
+
}
|
|
93
|
+
command.output = block.text;
|
|
94
|
+
}
|
|
95
|
+
index = block.end;
|
|
96
|
+
}
|
|
97
|
+
return steps;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every spelling of a path that may appear in CLI output: the literal one the
|
|
102
|
+
* check passed in and its canonical form. On macOS `/tmp` and `/private/tmp`
|
|
103
|
+
* are the same directory with two names, and `init` prints the canonical one.
|
|
104
|
+
*
|
|
105
|
+
* @param {string|null} path
|
|
106
|
+
* @returns {string[]}
|
|
107
|
+
*/
|
|
108
|
+
function pathVariants(path) {
|
|
109
|
+
if (!path) return [];
|
|
110
|
+
const variants = [path];
|
|
111
|
+
try {
|
|
112
|
+
const canonical = realpathSync(path);
|
|
113
|
+
if (canonical !== path) variants.push(canonical);
|
|
114
|
+
} catch {
|
|
115
|
+
// A path that does not exist yet — the run directory before the run — has
|
|
116
|
+
// only its literal spelling.
|
|
117
|
+
}
|
|
118
|
+
return variants;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Replace every machine-varying fragment with its fixed marker. The run
|
|
123
|
+
* directory is replaced first because it contains all three, then the project
|
|
124
|
+
* directory under the home, then the home, then the project id and the project
|
|
125
|
+
* path on their own.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} text
|
|
128
|
+
* @param {WalkthroughContext} context
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
export function normalizeWalkthroughOutput(text, context) {
|
|
132
|
+
/** @type {[string, string][]} */
|
|
133
|
+
const replacements = [];
|
|
134
|
+
const runDir = context.runDir;
|
|
135
|
+
if (runDir) {
|
|
136
|
+
for (const variant of pathVariants(runDir)) replacements.push([variant, "<home>/projects/<project>/runs/<run>"]);
|
|
137
|
+
}
|
|
138
|
+
if (context.projectId) {
|
|
139
|
+
for (const variant of pathVariants(join(context.home, "projects", context.projectId))) {
|
|
140
|
+
replacements.push([variant, "<home>/projects/<project>"]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const variant of pathVariants(context.home)) replacements.push([variant, "<home>"]);
|
|
144
|
+
if (context.projectId) replacements.push([context.projectId, "<project>"]);
|
|
145
|
+
for (const variant of pathVariants(context.project)) replacements.push([variant, "<project>"]);
|
|
146
|
+
// Longest needle first: a literal path is a suffix of its canonical
|
|
147
|
+
// `/private`-prefixed twin, so replacing the short one first would leave the
|
|
148
|
+
// canonical spelling mangled (`/private<project>`).
|
|
149
|
+
replacements.sort((left, right) => right[0].length - left[0].length);
|
|
150
|
+
let output = text;
|
|
151
|
+
for (const [needle, replacement] of replacements) {
|
|
152
|
+
if (needle) output = output.split(needle).join(replacement);
|
|
153
|
+
}
|
|
154
|
+
return output;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Remove ANSI SGR escape sequences from captured output. The check reads a
|
|
159
|
+
* pipe, not a terminal, but a CLI that always colorizes its status markers
|
|
160
|
+
* still emits the escapes here; the guide deliberately carries the plain
|
|
161
|
+
* rendering a terminal strips the colors down to, so the comparison must see
|
|
162
|
+
* the same text a person would read.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} text
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
export function stripAnsi(text) {
|
|
168
|
+
return text.replace(/\u001B\[[0-9;]*m/gu, "");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether `expected` appears in `actual` as a consecutive block of whole
|
|
173
|
+
* lines. Whole lines, not a substring: a guide that promises `valid` must not
|
|
174
|
+
* pass against `valid (2 warnings)`.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} actual
|
|
177
|
+
* @param {string} expected
|
|
178
|
+
* @returns {boolean}
|
|
179
|
+
*/
|
|
180
|
+
export function containsExpectedBlock(actual, expected) {
|
|
181
|
+
const actualLines = actual.split("\n").map((line) => line.trimEnd());
|
|
182
|
+
const expectedLines = expected.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim() !== "");
|
|
183
|
+
if (expectedLines.length === 0) return true;
|
|
184
|
+
for (let start = 0; start + expectedLines.length <= actualLines.length; start += 1) {
|
|
185
|
+
if (expectedLines.every((line, index) => actualLines[start + index] === line)) return true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Substitute the guide's placeholders in one command line.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} command
|
|
194
|
+
* @param {WalkthroughContext} context
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function substitutePlaceholders(command, context) {
|
|
198
|
+
return command
|
|
199
|
+
.split(PROJECT_LITERAL).join(context.project)
|
|
200
|
+
.split("<home>").join(context.home)
|
|
201
|
+
.split("<project>").join(context.projectId ?? context.project)
|
|
202
|
+
.split("<run>").join(context.runId)
|
|
203
|
+
.split("<session-id>").join("walkthrough");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {string} value
|
|
208
|
+
* @returns {string}
|
|
209
|
+
*/
|
|
210
|
+
function shellQuote(value) {
|
|
211
|
+
if (process.platform === "win32") return `"${value}"`;
|
|
212
|
+
return `'${value.replace(/'/gu, "'\\''")}'`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Run one marked command block. A leading `cd <dir>` line moves the working
|
|
217
|
+
* directory for the rest of the block; every `faberun …` line is run as the
|
|
218
|
+
* checkout's CLI; everything else goes to the shell (the guide's `git add &&
|
|
219
|
+
* git commit` is one such line).
|
|
220
|
+
*
|
|
221
|
+
* @param {string} block
|
|
222
|
+
* @param {WalkthroughContext} context
|
|
223
|
+
* @returns {{stdout: string, stderr: string, status: number, command: string}}
|
|
224
|
+
*/
|
|
225
|
+
function runCommandBlock(block, context) {
|
|
226
|
+
const lines = block
|
|
227
|
+
.split("\n")
|
|
228
|
+
.map((line) => line.trim())
|
|
229
|
+
.filter((line) => line !== "" && !line.startsWith("#"));
|
|
230
|
+
let cwd = context.project;
|
|
231
|
+
let stdout = "";
|
|
232
|
+
let stderr = "";
|
|
233
|
+
let status = 0;
|
|
234
|
+
let command = "";
|
|
235
|
+
for (const raw of lines) {
|
|
236
|
+
const line = substitutePlaceholders(raw, context);
|
|
237
|
+
if (line.startsWith("cd ")) {
|
|
238
|
+
cwd = resolve(cwd, line.slice(3).trim());
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
command = line.replace(/^faberun(?=\s)/u, `${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)}`);
|
|
242
|
+
const result = spawnSync(command, {
|
|
243
|
+
cwd,
|
|
244
|
+
shell: true,
|
|
245
|
+
encoding: "utf8",
|
|
246
|
+
env: { ...process.env, FABERUN_HOME: context.home },
|
|
247
|
+
input: "",
|
|
248
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
249
|
+
});
|
|
250
|
+
stdout += result.stdout ?? "";
|
|
251
|
+
stderr += result.stderr ?? "";
|
|
252
|
+
status = result.status ?? (result.error ? 1 : 0);
|
|
253
|
+
if (status !== 0) break;
|
|
254
|
+
}
|
|
255
|
+
return { stdout, stderr, status, command };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {string} directory
|
|
260
|
+
* @returns {void}
|
|
261
|
+
*/
|
|
262
|
+
function initializeRepository(directory) {
|
|
263
|
+
/** @param {string[]} args */
|
|
264
|
+
const run = (args) => {
|
|
265
|
+
const result = boundedGitSync(args, { stdio: "ignore" });
|
|
266
|
+
if (result.error || result.status !== 0) {
|
|
267
|
+
throw new Error(`walkthrough git ${args.join(" ")} failed: ${result.error?.message ?? `exit ${result.status}`}`);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
run(["init", "-q", directory]);
|
|
271
|
+
run(["-C", directory, "config", "user.email", "walkthrough@example.test"]);
|
|
272
|
+
run(["-C", directory, "config", "user.name", "walkthrough"]);
|
|
273
|
+
run(["-C", directory, "config", "commit.gpgSign", "false"]);
|
|
274
|
+
writeFileSync(join(directory, "README.md"), "# Getting started walkthrough\n");
|
|
275
|
+
run(["-C", directory, "add", "-A"]);
|
|
276
|
+
run(["-C", directory, "commit", "-qm", "walkthrough baseline"]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The two envelopes a one-node walkthrough needs: a worker that writes
|
|
281
|
+
* `hello.txt`, and a cross-vendor judge that accepts it. They are written
|
|
282
|
+
* beside the throwaway repository, never into it, so the replay cursor
|
|
283
|
+
* sidecars cannot dirty the tree the run cuts from.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} directory
|
|
286
|
+
* @returns {{worker: string, judge: string}}
|
|
287
|
+
*/
|
|
288
|
+
function writeRecordings(directory) {
|
|
289
|
+
mkdirSync(directory, { recursive: true });
|
|
290
|
+
const worker = join(directory, "worker.jsonl");
|
|
291
|
+
writeFileSync(worker, `${JSON.stringify({
|
|
292
|
+
envelope: {
|
|
293
|
+
status: "done",
|
|
294
|
+
result: JSON.stringify({ status: "done", summary: "wrote hello.txt", verification: [], artifacts: [], missingContext: [] }),
|
|
295
|
+
continuationId: null,
|
|
296
|
+
usage: { inputTokens: 5, outputTokens: 2, cacheReadInputTokens: 0 },
|
|
297
|
+
costUsd: null,
|
|
298
|
+
error: null,
|
|
299
|
+
},
|
|
300
|
+
files: [{ path: "hello.txt", content: "hello\n" }],
|
|
301
|
+
})}\n`);
|
|
302
|
+
const judge = join(directory, "judge.jsonl");
|
|
303
|
+
writeFileSync(judge, `${JSON.stringify({
|
|
304
|
+
envelope: {
|
|
305
|
+
status: "done",
|
|
306
|
+
result: JSON.stringify({ verdict: "pass", maxSeverity: "none", summary: "clean", findings: [] }),
|
|
307
|
+
continuationId: null,
|
|
308
|
+
usage: { inputTokens: 5, outputTokens: 2, cacheReadInputTokens: 0 },
|
|
309
|
+
costUsd: null,
|
|
310
|
+
error: null,
|
|
311
|
+
},
|
|
312
|
+
})}\n`);
|
|
313
|
+
return { worker, judge };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {string} id
|
|
318
|
+
* @param {string} recording
|
|
319
|
+
* @returns {Record<string, unknown>}
|
|
320
|
+
*/
|
|
321
|
+
function replayRuntime(id, recording) {
|
|
322
|
+
return {
|
|
323
|
+
harness: "replay",
|
|
324
|
+
model: `${id}-model`,
|
|
325
|
+
vendor: `${id}-vendor`,
|
|
326
|
+
config: { "replay.recording": recording },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The project id the throwaway repository registered under the home, or null
|
|
332
|
+
* before any command has resolved a runs root.
|
|
333
|
+
*
|
|
334
|
+
* @param {string} home
|
|
335
|
+
* @returns {string|null}
|
|
336
|
+
*/
|
|
337
|
+
function findProjectId(home) {
|
|
338
|
+
try {
|
|
339
|
+
const entry = readdirSync(join(home, "projects"), { withFileTypes: true })
|
|
340
|
+
.find((candidate) => candidate.isDirectory());
|
|
341
|
+
return entry?.name ?? null;
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @param {string} output
|
|
349
|
+
* @returns {string}
|
|
350
|
+
*/
|
|
351
|
+
function indent(output) {
|
|
352
|
+
return output.split("\n").map((line) => ` ${line}`).join("\n");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Run the guide and return every divergence. The returned `mismatches` is
|
|
357
|
+
* empty exactly when the guide matches the CLI.
|
|
358
|
+
*
|
|
359
|
+
* @param {{guidePath?: string}} [options]
|
|
360
|
+
* @returns {{ok: boolean, mismatches: {message: string, command?: string, expected?: string|null, actual?: string}[], error?: string}}
|
|
361
|
+
*/
|
|
362
|
+
export function checkGettingStarted(options = {}) {
|
|
363
|
+
const guidePath = options.guidePath ?? GUIDE_PATH;
|
|
364
|
+
/** @type {{message: string, command?: string, expected?: string|null, actual?: string}[]} */
|
|
365
|
+
const mismatches = [];
|
|
366
|
+
let steps;
|
|
367
|
+
try {
|
|
368
|
+
steps = parseWalkthrough(readFileSync(guidePath, "utf8"));
|
|
369
|
+
} catch (error) {
|
|
370
|
+
return { ok: false, mismatches, error: error instanceof Error ? error.message : String(error) };
|
|
371
|
+
}
|
|
372
|
+
if (!steps.some((step) => step.type === "command")) {
|
|
373
|
+
return { ok: false, mismatches, error: "GETTING-STARTED.md marks no walkthrough command to run" };
|
|
374
|
+
}
|
|
375
|
+
if (!steps.some((step) => step.type === "command" && step.output !== null)) {
|
|
376
|
+
return { ok: false, mismatches, error: "GETTING-STARTED.md marks no walkthrough output to check" };
|
|
377
|
+
}
|
|
378
|
+
const workRoot = mkdtempSync(join(tmpdir(), "faberun-walkthrough-"));
|
|
379
|
+
const home = join(workRoot, "home");
|
|
380
|
+
const project = join(workRoot, "project");
|
|
381
|
+
mkdirSync(home, { recursive: true });
|
|
382
|
+
mkdirSync(project, { recursive: true });
|
|
383
|
+
const recordings = writeRecordings(join(workRoot, "recordings"));
|
|
384
|
+
initializeRepository(project);
|
|
385
|
+
let projectId = findProjectId(home);
|
|
386
|
+
let contractId = "hello";
|
|
387
|
+
try {
|
|
388
|
+
for (const step of steps) {
|
|
389
|
+
if (step.type === "contract") {
|
|
390
|
+
const contract = /** @type {Record<string, unknown>} */ (JSON.parse(step.text));
|
|
391
|
+
if (typeof contract.id === "string" && contract.id) contractId = contract.id;
|
|
392
|
+
// The guide deliberately omits runtimes so the controller composes
|
|
393
|
+
// them from discovery. The check cannot discover a recording, so it
|
|
394
|
+
// pins the replay pair the way the guide says an operator may.
|
|
395
|
+
contract.runtimeDefaults = { worker: "walkthrough-worker", judge: "walkthrough-judge" };
|
|
396
|
+
contract.runtimes = {
|
|
397
|
+
"walkthrough-worker": replayRuntime("walkthrough-worker", recordings.worker),
|
|
398
|
+
"walkthrough-judge": replayRuntime("walkthrough-judge", recordings.judge),
|
|
399
|
+
};
|
|
400
|
+
writeFileSync(join(project, "contract.json"), `${JSON.stringify(contract, null, 2)}\n`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
let context = /** @type {WalkthroughContext} */ ({
|
|
404
|
+
home,
|
|
405
|
+
project,
|
|
406
|
+
projectId,
|
|
407
|
+
runId: contractId,
|
|
408
|
+
runDir: projectId ? join(home, "projects", projectId, "runs", contractId) : null,
|
|
409
|
+
});
|
|
410
|
+
const result = runCommandBlock(step.command, context);
|
|
411
|
+
projectId = projectId ?? findProjectId(home);
|
|
412
|
+
context = {
|
|
413
|
+
home,
|
|
414
|
+
project,
|
|
415
|
+
projectId,
|
|
416
|
+
runId: contractId,
|
|
417
|
+
runDir: projectId ? join(home, "projects", projectId, "runs", contractId) : null,
|
|
418
|
+
};
|
|
419
|
+
if (result.status !== 0) {
|
|
420
|
+
mismatches.push({
|
|
421
|
+
message: `walkthrough command exited ${result.status}`,
|
|
422
|
+
command: result.command,
|
|
423
|
+
expected: step.output,
|
|
424
|
+
actual: stripAnsi(`${result.stdout}${result.stderr}`).trim(),
|
|
425
|
+
});
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
if (step.output !== null) {
|
|
429
|
+
const normalized = normalizeWalkthroughOutput(stripAnsi(result.stdout), context);
|
|
430
|
+
if (!containsExpectedBlock(normalized, step.output)) {
|
|
431
|
+
mismatches.push({
|
|
432
|
+
message: "walkthrough output diverged",
|
|
433
|
+
command: result.command,
|
|
434
|
+
expected: step.output.trim(),
|
|
435
|
+
actual: normalized.trim(),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
} catch (error) {
|
|
441
|
+
return { ok: false, mismatches, error: error instanceof Error ? error.message : String(error) };
|
|
442
|
+
}
|
|
443
|
+
return { ok: mismatches.length === 0, mismatches };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A human-readable report of a failed check, for `manual.mjs --check`'s
|
|
448
|
+
* stderr. Bounded: at most the first few divergences.
|
|
449
|
+
*
|
|
450
|
+
* @param {{mismatches: {message: string, command?: string, expected?: string|null, actual?: string}[], error?: string}} result
|
|
451
|
+
* @returns {string}
|
|
452
|
+
*/
|
|
453
|
+
export function renderWalkthroughFailure(result) {
|
|
454
|
+
if (result.error) return `docs/GETTING-STARTED.md walkthrough could not run: ${result.error}\n`;
|
|
455
|
+
const lines = ["docs/GETTING-STARTED.md walkthrough is out of date; fix the guide or the CLI it describes."];
|
|
456
|
+
for (const mismatch of result.mismatches.slice(0, 5)) {
|
|
457
|
+
lines.push(`- ${mismatch.message}${mismatch.command ? ` · \`${mismatch.command}\`` : ""}`);
|
|
458
|
+
if (mismatch.expected !== undefined && mismatch.expected !== null) lines.push(` expected:\n${indent(mismatch.expected)}`);
|
|
459
|
+
if (mismatch.actual !== undefined) lines.push(` obtained:\n${indent(mismatch.actual)}`);
|
|
460
|
+
}
|
|
461
|
+
return `${lines.join("\n")}\n`;
|
|
462
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { campaignCli } from "./cli/campaign.mjs";
|
|
|
35
35
|
import { seatCli } from "./cli/seat.mjs";
|
|
36
36
|
import { initCommand } from "./cli/init.mjs";
|
|
37
37
|
import { setupCommand } from "./cli/setup.mjs";
|
|
38
|
+
import { uninstallCommand } from "./cli/uninstall.mjs";
|
|
38
39
|
import { skillsCli } from "./cli/skills.mjs";
|
|
39
40
|
import { updateCommand } from "./cli/update.mjs";
|
|
40
41
|
import { contractCli, validateContractFile } from "./cli/contract.mjs";
|
|
@@ -98,7 +99,7 @@ export function hasDetachedBootstrapNonce() {
|
|
|
98
99
|
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
99
100
|
export const COMMAND_OPTIONS = {
|
|
100
101
|
run: { detach: { type: "boolean" }, "base-ref": { type: "string" }, "fresh-preflight": { type: "boolean" } },
|
|
101
|
-
resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" }, "fresh-preflight": { type: "boolean" } },
|
|
102
|
+
resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" }, reauthor: { type: "string" }, "approve-below": { type: "string" }, "fresh-preflight": { type: "boolean" } },
|
|
102
103
|
supervise: { detach: { type: "boolean" }, interval: { type: "string" } },
|
|
103
104
|
cancel: {},
|
|
104
105
|
preflight: { static: { type: "boolean" }, json: { type: "boolean" }, "time-verification": { type: "boolean" } },
|
|
@@ -106,7 +107,7 @@ export const COMMAND_OPTIONS = {
|
|
|
106
107
|
status: { json: { type: "boolean" } },
|
|
107
108
|
report: { json: { type: "boolean" } },
|
|
108
109
|
findings: {},
|
|
109
|
-
doctor: { cwd: { type: "string" }, json: { type: "boolean" }, discover: { type: "boolean" } },
|
|
110
|
+
doctor: { cwd: { type: "string" }, json: { type: "boolean" }, discover: { type: "boolean" }, env: { type: "boolean" } },
|
|
110
111
|
models: { probe: { type: "boolean" }, json: { type: "boolean" } },
|
|
111
112
|
"bulk-read": { question: { type: "string" }, paths: { type: "string", multiple: true }, json: { type: "boolean" } },
|
|
112
113
|
next: { cwd: { type: "string" }, json: { type: "boolean" } },
|
|
@@ -116,6 +117,7 @@ export const COMMAND_OPTIONS = {
|
|
|
116
117
|
prune: { cwd: { type: "string" }, parked: { type: "boolean" }, json: { type: "boolean" } },
|
|
117
118
|
setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
|
|
118
119
|
init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
|
|
120
|
+
uninstall: { "dry-run": { type: "boolean" }, force: { type: "boolean" }, yes: { type: "boolean" } },
|
|
119
121
|
metrics: METRICS_OPTIONS,
|
|
120
122
|
plan: {
|
|
121
123
|
campaign: { type: "string" },
|
|
@@ -163,12 +165,13 @@ function parseCli(argv, quiet = false) {
|
|
|
163
165
|
if (command === "init" && parsed.positionals.length !== 0) return null;
|
|
164
166
|
if (command === "migrate" && parsed.positionals.length !== 0) return null;
|
|
165
167
|
if (command === "prune" && parsed.positionals.length !== 0) return null;
|
|
168
|
+
if (command === "uninstall" && parsed.positionals.length !== 0) return null;
|
|
166
169
|
// `plan --resolve <plan-dir>` (R9) takes no spec positional: everything a
|
|
167
170
|
// fresh `plan` reads from it and its flags is already on the contested
|
|
168
171
|
// plan.json the run it resumes wrote.
|
|
169
172
|
if (command === "plan" && typeof parsed.values.resolve === "string" && parsed.values.resolve !== "") {
|
|
170
173
|
if (parsed.positionals.length !== 0) return null;
|
|
171
|
-
} else if (command !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && command !== "migrate" && command !== "prune" && parsed.positionals.length !== 1) return null;
|
|
174
|
+
} else if (command !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && command !== "migrate" && command !== "prune" && command !== "uninstall" && parsed.positionals.length !== 1) return null;
|
|
172
175
|
return {
|
|
173
176
|
command,
|
|
174
177
|
target: parsed.positionals[0],
|
|
@@ -178,12 +181,15 @@ function parseCli(argv, quiet = false) {
|
|
|
178
181
|
|
|
179
182
|
/**
|
|
180
183
|
* The retry-in-place options of a `resume` invocation, validated before any
|
|
181
|
-
* lock is taken.
|
|
184
|
+
* lock is taken. `--reauthor <node-id>` widens one `context_missing` node's
|
|
185
|
+
* frozen packet from a bounded discovery pass and re-dispatches it once the
|
|
186
|
+
* widening validates and clears the approval threshold; `--approve-below`
|
|
187
|
+
* overrides that threshold exactly as `plan`'s flag of the same name does.
|
|
182
188
|
*
|
|
183
189
|
* @param {Record<string, unknown>} values
|
|
184
|
-
* @returns {{node?: string, reconcile?: string, answer?: {node: string, path: string}}}
|
|
190
|
+
* @returns {{node?: string, reconcile?: string, answer?: {node: string, path: string}, reauthor?: {node: string, discover: (context: {round: number, node: import("./contract/index.mjs").ValidatedNode, state: NodeSnapshot, missingContext: string[], findings: string[]}) => import("./contract/scope-findings.mjs").ReauthorAdditions, approveBelow?: string}}}
|
|
185
191
|
*/
|
|
186
|
-
function resumeOptionsOf(values) {
|
|
192
|
+
export function resumeOptionsOf(values) {
|
|
187
193
|
const node = typeof values.node === "string" && values.node ? values.node : undefined;
|
|
188
194
|
const reconcile = typeof values.reconcile === "string" && values.reconcile ? values.reconcile : undefined;
|
|
189
195
|
const answer = answerOf(values.answer);
|
|
@@ -193,7 +199,48 @@ function resumeOptionsOf(values) {
|
|
|
193
199
|
if (node && answer && node !== answer.node) {
|
|
194
200
|
throw new Error(`--answer ${answer.node} conflicts with --node ${node}`);
|
|
195
201
|
}
|
|
196
|
-
|
|
202
|
+
const reauthor = typeof values.reauthor === "string" && values.reauthor ? values.reauthor : undefined;
|
|
203
|
+
// `--reauthor` names the node it widens the same way `--node` names the node
|
|
204
|
+
// it retries: a different target in each would widen one node and narrow the
|
|
205
|
+
// retry to another, so the two must agree when both are given. `--answer`
|
|
206
|
+
// and `--reauthor` are two mutually exclusive repairs of the same refusal.
|
|
207
|
+
if (reauthor && node && reauthor !== node) {
|
|
208
|
+
throw new Error(`--reauthor ${reauthor} conflicts with --node ${node}`);
|
|
209
|
+
}
|
|
210
|
+
if (reauthor && answer) {
|
|
211
|
+
throw new Error(`--reauthor ${reauthor} cannot be combined with --answer ${answer.node}`);
|
|
212
|
+
}
|
|
213
|
+
const approveBelow = typeof values["approve-below"] === "string" && values["approve-below"] ? values["approve-below"] : undefined;
|
|
214
|
+
if (approveBelow !== undefined && !APPROVE_BELOW_VALUES.has(approveBelow)) {
|
|
215
|
+
throw new Error(`--approve-below must be one of standard, high, none: ${approveBelow}`);
|
|
216
|
+
}
|
|
217
|
+
if (approveBelow !== undefined && !reauthor) {
|
|
218
|
+
throw new Error("--approve-below requires --reauthor");
|
|
219
|
+
}
|
|
220
|
+
const reauthorOptions = reauthor
|
|
221
|
+
? { node: reauthor, discover: discoverReauthorAdditions, ...(approveBelow === undefined ? {} : { approveBelow }) }
|
|
222
|
+
: undefined;
|
|
223
|
+
return { node, reconcile, answer, ...(reauthorOptions === undefined ? {} : { reauthor: reauthorOptions }) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The approval thresholds `reauthorApproved` accepts, mirroring the planning pipeline's set. */
|
|
227
|
+
const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The discovery pass `resume --reauthor` spends its hard round budget on: the
|
|
231
|
+
* refusal already names the files the worker could not reach, so the bounded
|
|
232
|
+
* proposal widens the frozen packet with exactly those entries. `validateContract`
|
|
233
|
+
* still decides whether the widening closed scope and `reauthorWriteConflicts`
|
|
234
|
+
* whether it invaded another node's writes; an inadmissible proposal is refused
|
|
235
|
+
* and never applied. The engine owns the budget, the recording and the resume;
|
|
236
|
+
* this is only the CLI's discovery seam.
|
|
237
|
+
*
|
|
238
|
+
* @param {{missingContext: string[]}} context
|
|
239
|
+
* @returns {import("./contract/scope-findings.mjs").ReauthorAdditions}
|
|
240
|
+
*/
|
|
241
|
+
export function discoverReauthorAdditions({ missingContext }) {
|
|
242
|
+
const files = [...missingContext];
|
|
243
|
+
return { readFiles: files, writeFiles: files };
|
|
197
244
|
}
|
|
198
245
|
|
|
199
246
|
/**
|
|
@@ -255,6 +302,7 @@ async function main(argv) {
|
|
|
255
302
|
cwd: typeof values.cwd === "string" ? values.cwd : undefined,
|
|
256
303
|
json: values.json === true,
|
|
257
304
|
discover: values.discover === true,
|
|
305
|
+
env: values.env === true,
|
|
258
306
|
});
|
|
259
307
|
if (!ok) process.exitCode = 1;
|
|
260
308
|
return;
|
|
@@ -311,6 +359,16 @@ async function main(argv) {
|
|
|
311
359
|
});
|
|
312
360
|
return;
|
|
313
361
|
}
|
|
362
|
+
if (command === "uninstall") {
|
|
363
|
+
process.exitCode = await uninstallCommand({
|
|
364
|
+
dryRun: values["dry-run"] === true,
|
|
365
|
+
force: values.force === true,
|
|
366
|
+
yes: values.yes === true,
|
|
367
|
+
env: process.env,
|
|
368
|
+
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
314
372
|
if (command === "migrate") {
|
|
315
373
|
const result = migrateRunState(resolve(typeof values.cwd === "string" ? values.cwd : "."), { home: faberunHome(process.env) });
|
|
316
374
|
if (!result.moved) {
|
|
@@ -387,6 +445,8 @@ async function main(argv) {
|
|
|
387
445
|
...(resumeOptions.node ? ["--node", resumeOptions.node] : []),
|
|
388
446
|
...(resumeOptions.reconcile ? ["--reconcile", resumeOptions.reconcile] : []),
|
|
389
447
|
...(resumeOptions.answer ? ["--answer", `${resumeOptions.answer.node}=${resumeOptions.answer.path}`] : []),
|
|
448
|
+
...(resumeOptions.reauthor ? ["--reauthor", resumeOptions.reauthor.node] : []),
|
|
449
|
+
...(resumeOptions.reauthor?.approveBelow ? ["--approve-below", resumeOptions.reauthor.approveBelow] : []),
|
|
390
450
|
...(values["fresh-preflight"] === true ? ["--fresh-preflight"] : []),
|
|
391
451
|
];
|
|
392
452
|
const child = detachSelf("resume", target, extraArgs);
|
|
@@ -10,3 +10,33 @@ export const RESERVED_ARTICLES = [
|
|
|
10
10
|
"references/workflow.md",
|
|
11
11
|
"references/handoffs.md",
|
|
12
12
|
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The decisions that belong to the repository owner alone, never to a worker
|
|
16
|
+
* or a judge. Both prompts read this one list: the worker refuses an uncovered
|
|
17
|
+
* decision with `blocked_context` instead of choosing it, and the judge fails a
|
|
18
|
+
* diff that takes one. Kept here beside the reserved articles because it is the
|
|
19
|
+
* same kind of data -- a boundary no contract may cross -- and every layer that
|
|
20
|
+
* needs it can read it without pulling in the validator.
|
|
21
|
+
*/
|
|
22
|
+
export const RESERVED_OWNER_DECISIONS = [
|
|
23
|
+
"license",
|
|
24
|
+
"pricing",
|
|
25
|
+
"branding",
|
|
26
|
+
"publication",
|
|
27
|
+
"third-party data",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The reserved owner decisions a packet's `decisions` does not cover. Coverage
|
|
32
|
+
* is textual on purpose: a packet covers one by naming it in a decision
|
|
33
|
+
* (`license: MIT`, `pricing lives in the billing service`), and case does not
|
|
34
|
+
* matter.
|
|
35
|
+
*
|
|
36
|
+
* @param {string[]} decisions
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
export function uncoveredReservedOwnerDecisions(decisions) {
|
|
40
|
+
const covered = (decisions ?? []).join("\n").toLowerCase();
|
|
41
|
+
return RESERVED_OWNER_DECISIONS.filter((decision) => !covered.includes(decision));
|
|
42
|
+
}
|