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 +3 -1
- package/skills/faberun/SKILL.md +1 -0
- package/skills/faberun/references/spec-format.md +87 -0
- package/src/campaign/index.mjs +46 -3
- package/src/cli/brand.mjs +2 -0
- package/src/cli/campaign.mjs +3 -0
- package/src/cli/contract.mjs +2 -0
- package/src/cli/manual.mjs +343 -0
- package/src/cli/seat.mjs +2 -0
- package/src/cli/skills.mjs +2 -0
- package/src/cli/spec.mjs +119 -0
- package/src/cli.mjs +3 -1
- package/src/contract/index.mjs +1 -1
- package/src/contract/snapshot.mjs +7 -1
- package/src/engine/dispatch.mjs +26 -1
- package/src/plan/freeze.mjs +97 -0
- package/src/plan/repo-facts.mjs +148 -0
- package/src/plan/routing.mjs +200 -0
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/spec.mjs +320 -0
- package/src/report/final.mjs +3 -2
- package/src/report/render.mjs +8 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "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": {
|
package/skills/faberun/SKILL.md
CHANGED
|
@@ -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.
|
package/src/campaign/index.mjs
CHANGED
|
@@ -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>] ...",
|
package/src/cli/campaign.mjs
CHANGED
|
@@ -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;
|
package/src/cli/contract.mjs
CHANGED
|
@@ -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
package/src/cli/skills.mjs
CHANGED