faberun 0.6.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,8 @@
27
27
  "check": "for f in bin/*.mjs .claude/hooks/*.mjs src/*.mjs src/*/*.mjs src/*/*/*.mjs evals/*.mjs test/*.mjs test/*/*.mjs; do node --check \"$f\" || exit 1; done",
28
28
  "typecheck": "tsc",
29
29
  "test": "node --test test/*.test.mjs test/*/*.test.mjs",
30
+ "docs": "node src/cli/manual.mjs --write",
31
+ "docs:check": "node src/cli/manual.mjs --check",
30
32
  "prepare": "husky"
31
33
  },
32
34
  "devDependencies": {
@@ -10,6 +10,7 @@ Read [rules](references/rules.md) first.
10
10
 
11
11
  | Action | Read |
12
12
  | --- | --- |
13
+ | Write or validate a spec | [spec-format](references/spec-format.md) |
13
14
  | Author a contract (fallback) | [contract](references/contract.md), [engineering](references/engineering.md) |
14
15
  | Launch and resume | [workflow](references/workflow.md), [operations](references/operations.md) |
15
16
  | Dispatch a node | [handoffs](references/handoffs.md) |
@@ -0,0 +1,87 @@
1
+ # Spec format reference
2
+
3
+ Format version `1`. A spec is the free-form input the operator hands the
4
+ planner; this is the structured shape it validates against
5
+ (`faberun spec validate`, no model invoked). A document without the front
6
+ matter below is classified `legacy` and accepted, not rejected — the
7
+ validator says so explicitly, so old campaign records keep working.
8
+
9
+ ## Front matter
10
+
11
+ ```yaml
12
+ ---
13
+ id: kebab-case-campaign-id
14
+ title: "Human-readable title"
15
+ version: 1.1.0
16
+ status: draft
17
+ date: 2026-09-17
18
+ owner: Author Name
19
+ target: org/repo
20
+ baseline: <git sha the spec was measured against>
21
+ ---
22
+ ```
23
+
24
+ `id`, `title`, `version`, `status`, `date`, `owner`, `target`, `baseline` are
25
+ required. `derived_from` and `followed_by` are optional cross-references to
26
+ other spec ids (a prior spec this one revises, or the campaign meant to
27
+ follow it).
28
+
29
+ ## Sections
30
+
31
+ Mandatory, in order: **Intent**, **Requirements**, **Non-goals**. The
32
+ reference proposal
33
+ (`docs/campaigns/spec-format-and-planning-stages/spec/PROPOSAL.md`) writes
34
+ these as `Intenção`, `Requisitos`, `Não-objetivos` — the section role is what
35
+ matters, not the language of the heading text.
36
+
37
+ - **Intent** — prose: why this work, what problem, what it unblocks.
38
+ - **Requirements** — one `### R<n>. <title>` block per requirement (see
39
+ below).
40
+ - **Non-goals** — a bullet list of what this spec explicitly excludes, so a
41
+ planner never infers scope from silence.
42
+
43
+ Optional sections, any subset, any order after Non-goals:
44
+
45
+ - **Constraints** — bullets binding every requirement at once (e.g. "no node
46
+ runs the full suite").
47
+ - **Success criteria** — a table with at least a `Baseline` column, so
48
+ validation can catch a metric nobody measured before claiming a delta.
49
+ - **Risks** — a table of risk / impact / mitigation.
50
+
51
+ ## Requirement shape
52
+
53
+ ```markdown
54
+ ### R7. Repo facts are deterministic and carry measured duration
55
+
56
+ - **statement:** the target repo inventory is generated without invoking a
57
+ model, is identical across two runs at the same HEAD, and every candidate
58
+ verification command carries a duration measured by
59
+ `preflight --time-verification`.
60
+ - **proof:** command: node --test --test-name-pattern="repo facts"
61
+ ```
62
+
63
+ `R<n>` is a stable id — never renumbered once referenced elsewhere (a
64
+ comparative arm, a follow-up spec). `statement` is the testable claim.
65
+ `proof` is exactly one of:
66
+
67
+ - `command: <shell command>` — re-run it, exit zero proves the requirement.
68
+ - `path: <repo-relative path>` — the file or directory must exist.
69
+ - `judgment: true` — no deterministic check; a reviewer decides.
70
+
71
+ A requirement may add its own `- **constraints:** ...` line for a rule
72
+ scoped to it alone, distinct from the spec-wide Constraints section.
73
+
74
+ ## What `faberun spec validate` checks
75
+
76
+ Deterministic, no model call. Rejects:
77
+
78
+ - a requirement without a stable id, or without a `proof` line;
79
+ - a spec with no Non-goals section;
80
+ - a Success criteria table row with no Baseline value;
81
+ - a `target` or `baseline` that does not resolve to a real commit.
82
+
83
+ These are **advisory** by default — recorded as findings, spec still
84
+ validates — and become **blocking** under `--strict-traceability`, which
85
+ fails validation on any of the above. A `legacy`-class document (no front
86
+ matter) is exempt from every check above; it is accepted and labeled, never
87
+ scored against these rules.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ copyFileSync,
2
3
  existsSync,
3
4
  mkdirSync,
4
5
  readFileSync,
@@ -9,7 +10,7 @@ import { join, resolve } from "node:path";
9
10
  import { writeJsonAtomic } from "../run/store.mjs";
10
11
  import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
11
12
  import { promoteRun } from "../repo/integrate.mjs";
12
- import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
13
+ import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, JOURNAL_FILE, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
13
14
  import { readCampaign } from "./record.mjs";
14
15
  import { appendJournal, normalizeText, readJournalForDedupe } from "./journal.mjs";
15
16
  import { readProjectionState } from "./projection.mjs";
@@ -122,7 +123,7 @@ export function resolveCampaign(runsDir, campaignId) {
122
123
  /**
123
124
  * @param {string} campaignPath
124
125
  * @param {{at?: string, eventId?: string}} options
125
- * @returns {{path: string, campaign: Campaign}}
126
+ * @returns {{path: string, campaign: Campaign, ledgerFiles: string[]}}
126
127
  */
127
128
  export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
128
129
  requireTimestamp(at, "at");
@@ -131,10 +132,52 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
131
132
  if (!readJournalForDedupe(campaignPath).some((entry) => entry.type === "retrospective")) {
132
133
  throw new Error(`campaign ${campaign.id} has no recorded retrospective; record one with note --kind retrospective before close`);
133
134
  }
135
+ const repoRoot = resolve(campaignPath, "..", "..", "..");
136
+ const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
134
137
  const closed = /** @type {Campaign} */ ({ ...campaign, status: "closed", closedAt: at, updatedAt: at });
135
138
  writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
136
139
  appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
137
- return { path: campaignPath, campaign: closed };
140
+ return { path: campaignPath, campaign: closed, ledgerFiles };
141
+ }
142
+
143
+ /**
144
+ * Copy a campaign's journal, record and each linked run's usage into
145
+ * `<repoRoot>/docs/campaigns/<id>/ledger/` so the comparative arm of the
146
+ * planner has a session-side baseline even after `.runs/` (gitignored) is
147
+ * pruned. Nothing in this tree redacts token counts, costs or operator notes
148
+ * before this point, so the copy is verbatim; the pre-commit secret scan is
149
+ * the guard against anything that should not land in git.
150
+ *
151
+ * Idempotent: re-running it (a second `close` on an already-closed campaign
152
+ * cannot reach this, but a direct call can) overwrites the same destination
153
+ * files rather than duplicating them. A linked run without a `usage.jsonl`
154
+ * (never launched, or pruned) is skipped rather than thrown.
155
+ *
156
+ * @param {string} campaignPath
157
+ * @param {string} repoRoot
158
+ * @returns {string[]}
159
+ */
160
+ export function preserveCampaignLedger(campaignPath, repoRoot) {
161
+ const campaign = readCampaign(campaignPath);
162
+ const runsDir = resolve(campaignPath, "..", "..");
163
+ const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
164
+ mkdirSync(ledgerDir, { recursive: true });
165
+ const written = [];
166
+ for (const name of [JOURNAL_FILE, CAMPAIGN_FILE]) {
167
+ const source = join(campaignPath, name);
168
+ if (!existsSync(source)) continue;
169
+ const destination = join(ledgerDir, name);
170
+ copyFileSync(source, destination);
171
+ written.push(destination);
172
+ }
173
+ for (const runId of campaign.linkedRunIds) {
174
+ const source = join(runsDir, runId, "usage.jsonl");
175
+ if (!existsSync(source)) continue;
176
+ const destination = join(ledgerDir, `${runId}.usage.jsonl`);
177
+ copyFileSync(source, destination);
178
+ written.push(destination);
179
+ }
180
+ return written;
138
181
  }
139
182
 
140
183
  /**
package/src/cli/brand.mjs CHANGED
@@ -195,6 +195,8 @@ export function renderUsage() {
195
195
  "next [--cwd <dir>] [--json]",
196
196
  "bulk-read --question <text> --paths <a,b,c> [--json]",
197
197
  "contract validate <contract.json>",
198
+ "spec validate <file> [--strict-traceability] [--json]",
199
+ "spec scaffold <path> [--id <id>]",
198
200
  "metrics <campaign-id> [--cwd <dir>] [--json]",
199
201
  "campaign <init|watch|attach|note|resolve|close|supervise|show|list|sync|ack> ...",
200
202
  "seat <start|attach|status|stop> [<campaign-id>] [--cwd <dir>] ...",
@@ -447,6 +447,7 @@ function close(campaignId, values) {
447
447
  const closed = closeCampaign(path, { eventId: values.eventId ?? randomUUID() });
448
448
  renderHandoff(path, runsDir);
449
449
  process.stdout.write(`[campaign] ${closed.campaign.id} closed\n`);
450
+ process.stdout.write(`[campaign] ledger · docs/campaigns/${closed.campaign.id}/ledger · ${closed.ledgerFiles.length} files\n`);
450
451
  if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
451
452
  }
452
453
 
@@ -728,3 +729,5 @@ function usage() {
728
729
  );
729
730
  process.exitCode = 2;
730
731
  }
732
+
733
+ export default OPERATION_OPTIONS;
@@ -65,3 +65,5 @@ function usage() {
65
65
  process.stderr.write("usage: faberun contract validate <contract.json>\n");
66
66
  process.exitCode = 2;
67
67
  }
68
+
69
+ export default OPERATION_OPTIONS;
@@ -0,0 +1,343 @@
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
+ import SPEC_OPERATIONS from "./spec.mjs";
18
+
19
+ /** @typedef {{type: "string"|"boolean", multiple?: boolean}} FlagSpec */
20
+ /** @typedef {{flags?: Record<string, FlagSpec>, operations?: Record<string, Record<string, FlagSpec>>}} VerbSurface */
21
+ /** @typedef {{verbs: Record<string, VerbSurface>}} Surface */
22
+
23
+ const MANUAL_PATH = fileURLToPath(new URL("../../docs/COMMANDS.md", import.meta.url));
24
+
25
+ /**
26
+ * `campaign`, `seat`, `contract`, `skills` and `spec` are dispatched before
27
+ * `COMMAND_OPTIONS` is ever consulted (`cli.mjs` routes them by `argv[0]`), so
28
+ * they carry no flags of their own — only the operations their own module
29
+ * declares. Their top-level `## faberun <verb>` section is therefore never
30
+ * regenerated; it is hand-authored overview prose, preserved verbatim.
31
+ *
32
+ * @type {Record<string, Record<string, Record<string, FlagSpec>>>}
33
+ */
34
+ const CONTAINER_OPERATIONS = {
35
+ campaign: CAMPAIGN_OPERATIONS,
36
+ seat: SEAT_OPERATIONS,
37
+ contract: CONTRACT_OPERATIONS,
38
+ skills: SKILLS_OPERATIONS,
39
+ spec: SPEC_OPERATIONS,
40
+ };
41
+
42
+ /**
43
+ * The real command surface, read from the same option tables `cli.mjs`
44
+ * parses argv against. `supervise campaign` is `campaign.mjs`'s `supervise`
45
+ * operation reached through a second spelling (`cli.mjs` routes
46
+ * `argv = ["supervise", "campaign", …]` into `campaignCli`), so it shares that
47
+ * operation's flags rather than declaring its own.
48
+ *
49
+ * @returns {Surface}
50
+ */
51
+ export function collectSurface() {
52
+ /** @type {Record<string, VerbSurface>} */
53
+ const verbs = {};
54
+ for (const [verb, flags] of Object.entries(COMMAND_OPTIONS)) verbs[verb] = { flags };
55
+ if (verbs.supervise) verbs.supervise.operations = { campaign: CAMPAIGN_OPERATIONS.supervise };
56
+ for (const [verb, operations] of Object.entries(CONTAINER_OPERATIONS)) verbs[verb] = { operations };
57
+ return { verbs };
58
+ }
59
+
60
+ const VERB_HEADING = /^## faberun ([a-z][a-z-]*)$/u;
61
+ const TABLE_HEADER = "| Flag | Value | Effect | Default |";
62
+ const TABLE_SEPARATOR = "| --- | --- | --- | --- |";
63
+ /** The start of a flag token in a synopsis line: `--flag` or `[--flag`. */
64
+ const FLAG_TOKEN = /\[?--/u;
65
+
66
+ /**
67
+ * Regenerate the derivable parts of a command manual. A verb absent from
68
+ * `surface` is dropped; one present in `surface` but absent from `current` is
69
+ * appended as a skeleton section.
70
+ *
71
+ * @param {string} current
72
+ * @param {Surface} surface
73
+ * @returns {string}
74
+ */
75
+ export function renderManual(current, surface) {
76
+ const lines = current.split("\n");
77
+ /** @type {string[]} */
78
+ const output = [];
79
+ const seenVerbs = new Set();
80
+ let i = 0;
81
+ while (i < lines.length) {
82
+ const match = VERB_HEADING.exec(lines[i]);
83
+ if (!match) {
84
+ output.push(lines[i]);
85
+ i += 1;
86
+ continue;
87
+ }
88
+ const verb = match[1];
89
+ let end = i + 1;
90
+ while (end < lines.length && !/^## /u.test(lines[end])) end += 1;
91
+ const entry = surface.verbs[verb];
92
+ if (entry) {
93
+ output.push(...renderVerbBlock(verb, lines.slice(i, end), entry));
94
+ seenVerbs.add(verb);
95
+ }
96
+ i = end;
97
+ }
98
+ for (const [verb, entry] of Object.entries(surface.verbs)) {
99
+ if (!seenVerbs.has(verb)) output.push(...renderVerbBlock(verb, [`## faberun ${verb}`], entry));
100
+ }
101
+ return output.join("\n");
102
+ }
103
+
104
+ /**
105
+ * @param {string} verb
106
+ * @param {string[]} block
107
+ * @param {VerbSurface} entry
108
+ * @returns {string[]}
109
+ */
110
+ function renderVerbBlock(verb, block, entry) {
111
+ const heading = block[0] ?? `## faberun ${verb}`;
112
+ const { body, operationBlocks } = splitOperations(verb, block.slice(1));
113
+ const renderedBody = entry.flags
114
+ ? renderFlaggedBody(`faberun ${verb}`, body, entry.flags)
115
+ : body.length
116
+ ? body
117
+ : renderFlaggedBody(`faberun ${verb}`, [], {});
118
+ const renderedOperations = renderOperations(verb, operationBlocks, entry.operations ?? {});
119
+ return [heading, ...renderedBody, ...renderedOperations];
120
+ }
121
+
122
+ /**
123
+ * Splits a verb's body into the part before its first `### faberun <verb>
124
+ * <op>` heading and the operation sub-blocks that follow, each running to the
125
+ * next `### ` heading.
126
+ *
127
+ * @param {string} verb
128
+ * @param {string[]} lines
129
+ * @returns {{body: string[], operationBlocks: {op: string, block: string[]}[]}}
130
+ */
131
+ function splitOperations(verb, lines) {
132
+ const opHeading = new RegExp(`^### faberun ${verb} ([a-z][a-z-]*)$`, "u");
133
+ const firstOpIndex = lines.findIndex((line) => opHeading.test(line));
134
+ if (firstOpIndex === -1) return { body: lines, operationBlocks: [] };
135
+ const body = lines.slice(0, firstOpIndex);
136
+ /** @type {{op: string, block: string[]}[]} */
137
+ const operationBlocks = [];
138
+ let i = firstOpIndex;
139
+ while (i < lines.length) {
140
+ const match = opHeading.exec(lines[i]);
141
+ if (!match) break;
142
+ let end = i + 1;
143
+ while (end < lines.length && !/^###? /u.test(lines[end])) end += 1;
144
+ operationBlocks.push({ op: match[1], block: lines.slice(i, end) });
145
+ i = end;
146
+ }
147
+ return { body, operationBlocks };
148
+ }
149
+
150
+ /**
151
+ * @param {string} verb
152
+ * @param {{op: string, block: string[]}[]} operationBlocks
153
+ * @param {Record<string, Record<string, FlagSpec>>} operations
154
+ * @returns {string[]}
155
+ */
156
+ function renderOperations(verb, operationBlocks, operations) {
157
+ const output = [];
158
+ const seen = new Set();
159
+ for (const { op, block } of operationBlocks) {
160
+ if (!Object.hasOwn(operations, op)) continue;
161
+ output.push(block[0] ?? `### faberun ${verb} ${op}`, ...renderFlaggedBody(`faberun ${verb} ${op}`, block.slice(1), operations[op]));
162
+ seen.add(op);
163
+ }
164
+ for (const [op, flags] of Object.entries(operations)) {
165
+ if (seen.has(op)) continue;
166
+ output.push(`### faberun ${verb} ${op}`, ...renderFlaggedBody(`faberun ${verb} ${op}`, [], flags));
167
+ }
168
+ return output;
169
+ }
170
+
171
+ /**
172
+ * Regenerates a section's synopsis fence and flag table in place; every
173
+ * other line is untouched.
174
+ *
175
+ * @param {string} prefix
176
+ * @param {string[]} body
177
+ * @param {Record<string, FlagSpec>} flags
178
+ * @returns {string[]}
179
+ */
180
+ function renderFlaggedBody(prefix, body, flags) {
181
+ const positional = extractPositional(prefix, body);
182
+ const synopsis = renderSynopsis(prefix, positional, flags);
183
+ const fence = findFence(body, "```text");
184
+ const withSynopsis = fence
185
+ ? [...body.slice(0, fence.start), "```text", synopsis, "```", ...body.slice(fence.end + 1)]
186
+ : ["```text", synopsis, "```", ...body];
187
+ return replaceFlagTable(withSynopsis, flags);
188
+ }
189
+
190
+ /**
191
+ * The positional placeholder a synopsis names, kept verbatim from the
192
+ * current text (including its own brackets, when optional) — everything
193
+ * before the first flag token.
194
+ *
195
+ * @param {string} prefix
196
+ * @param {string[]} body
197
+ * @returns {string}
198
+ */
199
+ function extractPositional(prefix, body) {
200
+ const fence = findFence(body, "```text");
201
+ if (!fence) return "";
202
+ const inner = body[fence.start + 1] ?? "";
203
+ if (!inner.startsWith(prefix)) return "";
204
+ const remainder = inner.slice(prefix.length).trim();
205
+ const flagToken = FLAG_TOKEN.exec(remainder);
206
+ return flagToken ? remainder.slice(0, flagToken.index).trim() : remainder;
207
+ }
208
+
209
+ /**
210
+ * @param {string} prefix
211
+ * @param {string} positional
212
+ * @param {Record<string, FlagSpec>} flags
213
+ * @returns {string}
214
+ */
215
+ function renderSynopsis(prefix, positional, flags) {
216
+ const parts = [prefix];
217
+ if (positional) parts.push(positional);
218
+ for (const [name, spec] of Object.entries(flags)) {
219
+ if (spec.type === "boolean") parts.push(`[--${name}]`);
220
+ else if (spec.multiple) parts.push(`[--${name} <a>...]`);
221
+ else parts.push(`[--${name} <value>]`);
222
+ }
223
+ return parts.join(" ");
224
+ }
225
+
226
+ /**
227
+ * @param {string[]} lines
228
+ * @param {string} opener
229
+ * @returns {{start: number, end: number}|null}
230
+ */
231
+ function findFence(lines, opener) {
232
+ const start = lines.indexOf(opener);
233
+ if (start === -1) return null;
234
+ let end = start + 1;
235
+ while (end < lines.length && lines[end] !== "```") end += 1;
236
+ return { start, end };
237
+ }
238
+
239
+ /**
240
+ * @param {string[]} lines
241
+ * @param {Record<string, FlagSpec>} flags
242
+ * @returns {string[]}
243
+ */
244
+ function replaceFlagTable(lines, flags) {
245
+ const headerIndex = lines.indexOf(TABLE_HEADER);
246
+ const existingRows = headerIndex === -1 ? new Map() : parseRows(lines, headerIndex + 2);
247
+ const newRows = buildRows(flags, existingRows);
248
+ if (headerIndex === -1) {
249
+ const fenceEnd = lines.indexOf("```");
250
+ const insertAt = fenceEnd === -1 ? lines.length : fenceEnd + 1;
251
+ return [...lines.slice(0, insertAt), TABLE_HEADER, TABLE_SEPARATOR, ...newRows, ...lines.slice(insertAt)];
252
+ }
253
+ let rowsEnd = headerIndex + 2;
254
+ while (rowsEnd < lines.length && lines[rowsEnd].startsWith("|")) rowsEnd += 1;
255
+ return [...lines.slice(0, headerIndex), TABLE_HEADER, TABLE_SEPARATOR, ...newRows, ...lines.slice(rowsEnd)];
256
+ }
257
+
258
+ /**
259
+ * @param {string[]} lines
260
+ * @param {number} start
261
+ * @returns {Map<string, {value: string, effect: string, default: string}>}
262
+ */
263
+ function parseRows(lines, start) {
264
+ /** @type {Map<string, {value: string, effect: string, default: string}>} */
265
+ const map = new Map();
266
+ let i = start;
267
+ while (i < lines.length && lines[i].startsWith("|")) {
268
+ const cells = lines[i].trim().replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => cell.trim());
269
+ if (cells.length === 4) map.set(flagKeyOf(cells[0]), { value: cells[1], effect: cells[2], default: cells[3] });
270
+ i += 1;
271
+ }
272
+ return map;
273
+ }
274
+
275
+ /** @param {string} cell @returns {string} */
276
+ function flagKeyOf(cell) {
277
+ const match = /`--([a-z-]+)`/u.exec(cell);
278
+ return match ? match[1] : "—";
279
+ }
280
+
281
+ /**
282
+ * @param {Record<string, FlagSpec>} flags
283
+ * @param {Map<string, {value: string, effect: string, default: string}>} existingRows
284
+ * @returns {string[]}
285
+ */
286
+ function buildRows(flags, existingRows) {
287
+ const names = Object.keys(flags);
288
+ if (names.length === 0) {
289
+ const existing = existingRows.get("—");
290
+ return [existing ? `| — | ${existing.value} | ${existing.effect} | ${existing.default} |` : "| — | — | No flags. | — |"];
291
+ }
292
+ return names.map((name) => {
293
+ const existing = existingRows.get(name);
294
+ const value = existing ? existing.value : "<value>";
295
+ const effect = existing ? existing.effect : "";
296
+ const fallback = existing ? existing.default : "—";
297
+ return `| \`--${name}\` | ${value} | ${effect} | ${fallback} |`;
298
+ });
299
+ }
300
+
301
+ /**
302
+ * A minimal, dependency-free diff summary: every line index where the two
303
+ * texts disagree, capped so a large rewrite does not flood the console.
304
+ *
305
+ * @param {string} current
306
+ * @param {string} next
307
+ * @returns {string}
308
+ */
309
+ function diffSummary(current, next) {
310
+ const a = current.split("\n");
311
+ const b = next.split("\n");
312
+ const max = Math.max(a.length, b.length);
313
+ /** @type {string[]} */
314
+ const lines = [];
315
+ for (let i = 0; i < max && lines.length < 40; i += 1) {
316
+ if (a[i] !== b[i]) lines.push(`line ${i + 1}:\n- ${a[i] ?? "<eof>"}\n+ ${b[i] ?? "<eof>"}`);
317
+ }
318
+ return lines.join("\n");
319
+ }
320
+
321
+ /**
322
+ * @param {string[]} argv
323
+ * @returns {void}
324
+ */
325
+ function main(argv) {
326
+ const mode = argv[0];
327
+ if (mode !== "--write" && mode !== "--check") {
328
+ process.stderr.write("usage: manual.mjs --write|--check\n");
329
+ process.exitCode = 2;
330
+ return;
331
+ }
332
+ const current = readFileSync(MANUAL_PATH, "utf8");
333
+ const next = renderManual(current, collectSurface());
334
+ if (next === current) return;
335
+ if (mode === "--write") {
336
+ writeFileSync(MANUAL_PATH, next);
337
+ return;
338
+ }
339
+ process.stderr.write(`docs/COMMANDS.md is out of date; run \`npm run docs\`.\n${diffSummary(current, next)}\n`);
340
+ process.exitCode = 1;
341
+ }
342
+
343
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) main(process.argv.slice(2));
package/src/cli/seat.mjs CHANGED
@@ -137,3 +137,5 @@ function usage() {
137
137
  process.stderr.write("usage: faberun seat <start|attach|status|stop|switch> [<campaign-id>] [--cwd <dir>] ...\n");
138
138
  process.exitCode = 2;
139
139
  }
140
+
141
+ export default OPERATION_OPTIONS;
@@ -403,3 +403,5 @@ function usage() {
403
403
  process.stderr.write("usage: faberun skills <list|install|register> [<name>...] [--target <dir>] [--global] [--force] [--copy] [--harness <a,b>] [--json]\n");
404
404
  process.exitCode = 2;
405
405
  }
406
+
407
+ export default OPERATION_OPTIONS;