faberun 0.27.0 → 0.29.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 (83) hide show
  1. package/README.md +9 -3
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +31 -4
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/plan.mjs +56 -5
  18. package/src/cli/setup.mjs +5 -0
  19. package/src/cli/skills.mjs +18 -7
  20. package/src/cli/uninstall.mjs +542 -0
  21. package/src/cli/walkthrough.mjs +462 -0
  22. package/src/cli.mjs +72 -8
  23. package/src/contract/articles.mjs +30 -0
  24. package/src/contract/definition-of-done.mjs +164 -0
  25. package/src/contract/index.mjs +8 -5
  26. package/src/contract/judge-independence.mjs +18 -0
  27. package/src/contract/runtime.mjs +20 -6
  28. package/src/contract/scope-findings.mjs +120 -0
  29. package/src/contract/snapshot.mjs +19 -5
  30. package/src/contract/task-packet.mjs +49 -1
  31. package/src/contract/worker-result.mjs +37 -11
  32. package/src/engine/assignment.mjs +45 -0
  33. package/src/engine/detach.mjs +18 -0
  34. package/src/engine/gate.mjs +10 -2
  35. package/src/engine/judge-gate.mjs +24 -9
  36. package/src/engine/live-preflight.mjs +117 -18
  37. package/src/engine/prompts.mjs +58 -4
  38. package/src/engine/resume.mjs +119 -12
  39. package/src/engine/retry.mjs +11 -11
  40. package/src/engine/run-command.mjs +80 -4
  41. package/src/engine/run-identity.mjs +27 -14
  42. package/src/engine/runtime-discovery.mjs +15 -9
  43. package/src/engine/scheduler.mjs +8 -1
  44. package/src/engine/worker-env.mjs +179 -0
  45. package/src/harnesses/agy/index.mjs +11 -0
  46. package/src/harnesses/catalogue.mjs +29 -0
  47. package/src/harnesses/claude/index.mjs +98 -1
  48. package/src/harnesses/codex/index.mjs +15 -0
  49. package/src/harnesses/dsh/index.mjs +14 -0
  50. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  51. package/src/harnesses/fx/index.mjs +13 -0
  52. package/src/harnesses/index.mjs +21 -10
  53. package/src/harnesses/replay/index.mjs +12 -0
  54. package/src/harnesses/zcode/index.mjs +19 -0
  55. package/src/host/config.mjs +8 -2
  56. package/src/host/environment-report.mjs +85 -0
  57. package/src/host/home.mjs +27 -0
  58. package/src/host/install-registry.mjs +511 -0
  59. package/src/host/preflight.mjs +32 -9
  60. package/src/host/uninstall-roots.mjs +196 -0
  61. package/src/plan/freeze.mjs +2 -0
  62. package/src/plan/pipeline.mjs +56 -4
  63. package/src/plan/progress.mjs +101 -0
  64. package/src/plan/proof-check.mjs +27 -0
  65. package/src/plan/proof-scope.mjs +1 -1
  66. package/src/plan/repo-facts.mjs +129 -14
  67. package/src/plan/rounds.mjs +27 -0
  68. package/src/plan/spec.mjs +82 -6
  69. package/src/plan/template.mjs +9 -0
  70. package/src/repo/declared-paths.mjs +74 -6
  71. package/src/repo/integrate.mjs +1 -1
  72. package/src/repo/scope-closure.mjs +59 -0
  73. package/src/repo/signal-block.mjs +26 -0
  74. package/src/repo/signal.mjs +9 -24
  75. package/src/repo/workspace.mjs +189 -14
  76. package/src/repo/worktree-prune.mjs +13 -5
  77. package/src/repo/worktree.mjs +54 -0
  78. package/src/report/campaign-brief.mjs +4 -0
  79. package/src/report/message.mjs +37 -6
  80. package/src/report/progress.mjs +13 -2
  81. package/src/run/paths.mjs +14 -1
  82. package/src/run/usage.mjs +66 -1
  83. 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
- return { node, reconcile, answer };
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);
@@ -519,7 +579,11 @@ function advisoryToken() {
519
579
  function prunedLine(pruned) {
520
580
  if (!pruned.length) return "[prune] no finished, integrated run holds a worktree";
521
581
  const removed = pruned.reduce((sum, run) => sum + run.removed, 0);
522
- return `[prune] released ${removed} worktree${removed === 1 ? "" : "s"} of ${pruned.length} integrated run${pruned.length === 1 ? "" : "s"} · each kept under refs/faberun-archive/`;
582
+ const branches = pruned.reduce((sum, run) => sum + run.branches.length, 0);
583
+ const line = removed === 0
584
+ ? "[prune] an earlier prune already released every worktree of an integrated run"
585
+ : `[prune] released ${removed} worktree${removed === 1 ? "" : "s"} of ${pruned.length} integrated run${pruned.length === 1 ? "" : "s"} · each kept under refs/faberun-archive/`;
586
+ return branches === 0 ? line : `${line} · deleted ${branches} attempt branch${branches === 1 ? "" : "es"} already held by another ref`;
523
587
  }
524
588
 
525
589
  /**
@@ -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
+ }