liteagents 2.15.2 → 2.17.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 (38) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/README.md +8 -7
  3. package/package.json +2 -2
  4. package/packages/ampcode/AGENT.md +2 -2
  5. package/packages/ampcode/agents/quality-assurance.md +1 -1
  6. package/packages/ampcode/commands/docs-builder/docs-builder.cjs +2382 -0
  7. package/packages/ampcode/commands/docs-builder.md +787 -223
  8. package/packages/ampcode/commands/remember/AGENT_RULES.md +13 -43
  9. package/packages/ampcode/commands/remember/{friction.js → friction.cjs} +211 -99
  10. package/packages/ampcode/commands/remember.md +208 -29
  11. package/packages/claude/CLAUDE.md +3 -3
  12. package/packages/claude/commands/docs-builder/docs-builder.cjs +2382 -0
  13. package/packages/claude/commands/docs-builder.md +873 -0
  14. package/packages/claude/commands/remember/AGENT_RULES.md +10 -40
  15. package/packages/claude/commands/remember/{friction.js → friction.cjs} +208 -96
  16. package/packages/claude/commands/remember.md +208 -29
  17. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package-lock.json +9 -9
  18. package/packages/droid/AGENTS.md +2 -2
  19. package/packages/droid/commands/docs-builder/docs-builder.cjs +2382 -0
  20. package/packages/droid/commands/docs-builder.md +787 -223
  21. package/packages/droid/commands/remember/AGENT_RULES.md +13 -43
  22. package/packages/droid/commands/remember/{friction.js → friction.cjs} +211 -99
  23. package/packages/droid/commands/remember.md +208 -29
  24. package/packages/droid/droids/quality-assurance.md +1 -1
  25. package/packages/opencode/AGENTS.md +2 -2
  26. package/packages/opencode/agent/quality-assurance.md +1 -1
  27. package/packages/opencode/command/docs-builder/docs-builder.cjs +2382 -0
  28. package/packages/opencode/command/docs-builder.md +787 -223
  29. package/packages/opencode/command/remember/AGENT_RULES.md +13 -43
  30. package/packages/opencode/command/remember/{friction.js → friction.cjs} +211 -99
  31. package/packages/opencode/command/remember.md +208 -29
  32. package/packages/opencode/opencode.jsonc +2 -6
  33. package/packages/subagentic-manual.md +31 -32
  34. package/packages/ampcode/commands/docs-builder/templates.md +0 -601
  35. package/packages/claude/skills/docs-builder/SKILL.md +0 -309
  36. package/packages/claude/skills/docs-builder/references/templates.md +0 -601
  37. package/packages/droid/commands/docs-builder/templates.md +0 -601
  38. package/packages/opencode/command/docs-builder/templates.md +0 -601
@@ -0,0 +1,2382 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // docs-builder — every mechanical step of the pipeline. Vanilla Node, zero deps, NO MODEL.
4
+ // The model is used for exactly two things (propose themes, write pages); everything else
5
+ // lives here, because bookkeeping done by a script is 100% and done by a model is 27%.
6
+ //
7
+ // scan <file.md...> -> outline.json (Layer 1)
8
+ // validate <outline.json> <labels.json> -> PASS/FAIL (Layer 2 gate)
9
+ // plan <outline.json> <labels.json> -> task-<theme>.json per page (resumes)
10
+ // index-flat -> index.md (the ONE index: whole
11
+ // corpus, no labels needed, rebuilt on
12
+ // every reorg AND after every split)
13
+ // search <outline.json> <query words...> -> ranked sections (BM25, zero deps)
14
+ // archive <src.md> [dest.md] -> verified MOVE into docs/archive/
15
+ // ledger -> record the current state of docs/
16
+ // due -> what changed since the ledger, and how much
17
+ // lint <file.md...> -> lint.json (declared-only checks)
18
+ // discover [root] -> reorg-plan.json (enriches + PROPOSES a
19
+ // bucket per row via `suggested`; NEVER
20
+ // moves, NEVER decides `bucket`)
21
+ // apply-reorg [plan.json] -> executes an ALREADY-CLASSIFIED plan
22
+ // (refuses if any row's `bucket` is
23
+ // empty), then re-scans the WHOLE corpus
24
+ // (product/ + logs/ + archive/) into
25
+ // outline.json — search's only database
26
+ // reorg -> discover, STOPS for the classification
27
+ // interview if any `bucket` is unfilled,
28
+ // else apply-reorg + lint, plus `due`'s
29
+ // drift summary if a ledger stamp exists
30
+ // — the single front door
31
+ // cleanup <file.md> -> ONE named file: cost estimate, scan, a
32
+ // heading-SHAPE report (cleanup-shape.json)
33
+ // -- then STOPS, awaiting the interview.
34
+ // (the ONLY entry point to the split pipeline)
35
+ // cleanup-apply <file.md> <outline.json> <labels.json>
36
+ // -> post-approval half: refuses unless
37
+ // labels.json has one core:true theme,
38
+ // else plan -> (pages, written by the
39
+ // model, outside this script) -> once all
40
+ // pages exist, archive + rebuild index
41
+ //
42
+ // Env: REPO (default cwd), OUT (output path), INDEX (default docs/index.md, validate's link
43
+ // check — the same file index-flat writes), PAGES (default docs/wiki, validate's citations +
44
+ // plan), TASKS (default docs/.docs-builder/tasks, validate's citations reader-side match for
45
+ // plan's OUT), N (search count, default 10), OVERSIZED_LINES (default 500, discover's
46
+ // oversized ceiling).
47
+
48
+ // Extension is `.cjs`, not `.js`, ON PURPOSE. Installed project-locally into a repo whose
49
+ // package.json declares "type": "module", a `.js` file is loaded as an ES module and every
50
+ // `require` below throws before the first line of work. `.cjs` pins CommonJS regardless of
51
+ // the host project. Found the hard way: bareloop is such a project.
52
+ const fs = require('fs'), path = require('path'), crypto = require('crypto');
53
+ const { execFileSync } = require('child_process');
54
+
55
+ const REPO = process.env.REPO || process.cwd();
56
+ const clean = s => s.replace(/\s+/g, ' ').trim();
57
+ // Source .md paths are repo-relative; JSON artifacts the pipeline itself produced are
58
+ // cwd-relative. Keeping these separate stops `plan` looking for outline.json inside the
59
+ // repo being documented.
60
+ const repoPath = f => path.isAbsolute(f) ? f : path.join(REPO, f);
61
+ const read = f => fs.readFileSync(repoPath(f), 'utf8');
62
+ const die = m => { console.error(m); process.exit(1); };
63
+ // Guarded chokepoint for every JSON pipeline artifact this script reads. Used to be TWO
64
+ // readers: a guarded one (2 callers) and 5 bare `JSON.parse(fs.readFileSync(...))` sites
65
+ // that dumped a raw node stack trace on a hand-edited or truncated file. Core THROWS, never
66
+ // exits (same split as doArchive()/archive() below) so rewriteArchivedPath() can catch a
67
+ // malformed-JSON failure and report it alongside "the git mv already succeeded" instead of
68
+ // a bare die(). `parseJSONFile` is the die-on-throw convenience wrapper most callers want.
69
+ // `sha()` reads raw bytes for hashing, a different job, and stays outside this.
70
+ const parseJSONFileOrThrow = f => {
71
+ try { return JSON.parse(fs.readFileSync(f, 'utf8')); }
72
+ catch (e) { throw new Error(`malformed JSON in ${f}: ${e.message}`); }
73
+ };
74
+ const parseJSONFile = f => {
75
+ try { return parseJSONFileOrThrow(f); }
76
+ catch (e) { die(e.message); }
77
+ };
78
+ const readArtifactJSON = f => {
79
+ if (!fs.existsSync(f)) die(`no such file: ${f} — did an earlier pipeline step not run yet?`);
80
+ return parseJSONFile(f);
81
+ };
82
+
83
+ // ---------------------------------------------------------------- shared parsing
84
+
85
+ // Headings inside ``` or ~~~ fences are not headings.
86
+ function fenceMask(lines) {
87
+ const mask = new Array(lines.length).fill(false);
88
+ let open = false, marker = null;
89
+ lines.forEach((ln, i) => {
90
+ const t = ln.trimStart();
91
+ if (t.startsWith('```') || t.startsWith('~~~')) {
92
+ const m = t.slice(0, 3);
93
+ if (!open) { open = true; marker = m; mask[i] = true; }
94
+ else if (m === marker) { mask[i] = true; open = false; marker = null; }
95
+ else mask[i] = true;
96
+ } else mask[i] = open;
97
+ });
98
+ return mask;
99
+ }
100
+
101
+ // Fence-awareness used to be FOUR implementations: this mask, snippet()'s own lone
102
+ // `startsWith('```')` check (caught the marker but let CODE inside it leak into a snippet as
103
+ // prose), sentences()'s own regex strip, and checkCitations/checkLinks doing none at all — so
104
+ // a page documenting the citation/link syntax INSIDE a fence got its own example flagged as
105
+ // a real violation. One mechanism: mask with fenceMask(), drop the masked lines.
106
+ function stripFences(text) {
107
+ const lines = text.split('\n');
108
+ const mask = fenceMask(lines);
109
+ return lines.filter((_, i) => !mask[i]).join('\n');
110
+ }
111
+
112
+ // Same mask, a different use: `text.replace(regex, replacer)` but skipping any line inside a
113
+ // fenced code block entirely, so a match never touches CODE. Every regex this is used with
114
+ // (INLINE_LINK_RE, REF_LINK_RE, the exact repo-rooted path matcher in rewriteLinks) excludes
115
+ // whitespace/newline from what it captures, so a match can never span two lines — line-at-a-
116
+ // time masking is exact, not an approximation. MEASURED, real: `INLINE_LINK_RE` used to run
117
+ // fence-blind and rewrote `](cleanAction.args)` — a JS property-access expression inside a
118
+ // fenced ASCII-diagram code sample, not a markdown link at all — into a broken link target.
119
+ // A fenced block is not the only way a doc shows code. FIELD BUG (real, reproduced on a
120
+ // fresh repo the day fence-awareness shipped): an INLINE span, `map[key](arg)`, came out of
121
+ // a reorg as `map[key](../arg)` — the identical corruption one syntax down, because masking
122
+ // stopped at fences. A span is delimited by a matching backtick run on the SAME line (CommonMark
123
+ // allows spans to wrap, but a wrapped span cannot be confused for a link target by any regex
124
+ // here, all of which are single-line), so the same line-at-a-time treatment is exact here too.
125
+ const CODE_SPAN_RE = /(`+)(?:(?!\1)[\s\S])*?\1/g;
126
+
127
+ // Splits one line into alternating [outside, code, outside, code, ...] segments. Odd indices
128
+ // are code spans and are never handed to a replacer.
129
+ function codeSpanSegments(line) {
130
+ const segs = [];
131
+ let last = 0;
132
+ CODE_SPAN_RE.lastIndex = 0;
133
+ for (let m; (m = CODE_SPAN_RE.exec(line)) !== null;) {
134
+ segs.push(line.slice(last, m.index), m[0]);
135
+ last = m.index + m[0].length;
136
+ }
137
+ segs.push(line.slice(last));
138
+ return segs;
139
+ }
140
+
141
+ // `spans` says whether an inline `code span` is also off-limits. The two callers deliberately
142
+ // differ:
143
+ // - the RELATIVE-link passes (INLINE_LINK_RE / REF_LINK_RE) INFER a link from `](...)`
144
+ // syntax, so a span holding `map[key](arg)` is code being misread as a link. spans: true.
145
+ // - the EXACT repo-rooted path matcher matches a literal, unambiguous path. A backticked
146
+ // `docs/GUIDE.md` in prose is how docs render a filename inline — it is a real reference
147
+ // to the moved file, and NOT rewriting it leaves a dead reference (asserted by the
148
+ // move-repair tests since v2). spans: false. A FENCE still means code for both: there the
149
+ // path is part of a snippet the reader copies verbatim.
150
+ function replaceOutsideFences(text, regex, replacer, { spans = true } = {}) {
151
+ const lines = text.split('\n');
152
+ const mask = fenceMask(lines);
153
+ // A `^`-anchored regex (REF_LINK_RE) may only ever match at a real line start, so it is
154
+ // applied to the FIRST segment alone — segment 2 onward begins mid-line, and letting `^`
155
+ // re-anchor there would rewrite a target that is not a reference definition at all.
156
+ const anchored = regex.source.startsWith('^');
157
+ return lines.map((line, i) => {
158
+ if (mask[i]) return line;
159
+ if (!spans) return line.replace(regex, replacer);
160
+ const segs = codeSpanSegments(line);
161
+ if (segs.length === 1) return line.replace(regex, replacer);
162
+ return segs.map((seg, j) => {
163
+ if (j % 2 === 1) return seg; // a code span: never a rewrite target
164
+ if (anchored && j !== 0) return seg;
165
+ return seg.replace(regex, replacer);
166
+ }).join('');
167
+ }).join('\n');
168
+ }
169
+
170
+ const ID_RE = /^([A-Z]{1,4}\d{1,4}(?:[-–][A-Z]?\d{1,4})?)\b/;
171
+
172
+ // THE KEY. The model echoes this string back verbatim; the validator checks that same
173
+ // string. One function, so the two can never disagree — the POC scored "86/86 byte-exact"
174
+ // against keys the prompt had silently truncated at 110 chars while the source headings
175
+ // ran longer, which is only a pass because both sides shared the same truncation by luck.
176
+ // Uniqueness is GUARANTEED here, not assumed: bareloop's PRD has 0 collisions, but a repo
177
+ // with "## Cache Invalidation" in three files has three.
178
+ const KEY_WIDTH = 110;
179
+ // The `${r.file} :: ` prefix is ALWAYS applied, never conditional on batch size. MEASURED
180
+ // bug: scanning one file alone vs. alongside a second file used to produce different keys
181
+ // for the same heading ("The core mappings" vs "docs/00-context/CYBERNETICS.md :: The core
182
+ // mappings"), so a labels.json made from a single-doc cleanup silently stopped matching the
183
+ // same file's key the moment it was rescanned as part of a corpus-wide reconcile. Key format
184
+ // must not depend on scan batch size.
185
+ function makeKeys(records) {
186
+ const seen = new Map();
187
+ for (const r of records) {
188
+ // .trim() is load-bearing: truncating at a fixed width lands mid-space often enough
189
+ // (9 of 86 headings on the bareloop PRD), and no model will faithfully echo back a key
190
+ // with a trailing space. Never ask a model to reproduce something it cannot see.
191
+ const head = (r.id || (r.h2.length > KEY_WIDTH ? r.h2.slice(0, KEY_WIDTH) : r.h2)).trim();
192
+ const base = `${r.file} :: ${head}`;
193
+ const n = (seen.get(base) || 0) + 1;
194
+ seen.set(base, n);
195
+ r.key = n === 1 ? base : `${base} #${n}`;
196
+ if (n > 1) console.error(`WARN: key collision disambiguated -> ${r.key}`);
197
+ }
198
+ return records;
199
+ }
200
+
201
+ // `mask` is fenceMask(lines), computed once per file in scan() and shared with snippet().
202
+ function headings(lines, mask) {
203
+ const h1 = (lines.find((l, i) => !mask[i] && l.startsWith('# ')) || '').slice(2).trim();
204
+ const heads = [];
205
+ lines.forEach((l, i) => {
206
+ if (mask[i]) return;
207
+ const m = l.match(/^(#{2,4})\s+(.+)$/);
208
+ if (m) heads.push({ lvl: m[1].length, text: clean(m[2]), line: i + 1 });
209
+ });
210
+ return { h1, heads };
211
+ }
212
+
213
+ // First two prose lines under a heading. `mask[i]` now excludes fence CODE lines too, not
214
+ // just the opening marker — the old `startsWith('```')` check let fenced content leak in.
215
+ function snippet(lines, mask, from, to, cap) {
216
+ const out = [];
217
+ for (let i = from; i < to && out.length < 2; i++) {
218
+ if (mask[i]) continue;
219
+ const t = lines[i].trim();
220
+ if (!t || t.startsWith('#') || /^[|>-]{3,}$/.test(t)) continue;
221
+ out.push(t);
222
+ }
223
+ return clean(out.join(' ')).slice(0, cap);
224
+ }
225
+
226
+ // ---------------------------------------------------------------- scan (Layer 1)
227
+
228
+ function scan(files) {
229
+ if (!files.length) die('usage: docs-builder.cjs scan <file.md...>');
230
+ const records = [];
231
+ for (const f of files) {
232
+ const lines = read(f).split('\n');
233
+ const mask = fenceMask(lines);
234
+ const { h1, heads } = headings(lines, mask);
235
+ const h2s = heads.filter(h => h.lvl === 2);
236
+ h2s.forEach((h2, k) => {
237
+ const s = h2.line;
238
+ const e = k + 1 < h2s.length ? h2s[k + 1].line - 1 : lines.length;
239
+ const kids = heads.filter(h => h.lvl >= 3 && h.line > s && h.line <= e);
240
+ // Every H3 carries its OWN start/end so a page writer can read it alone.
241
+ const h3 = kids.map((c, ci) => ({
242
+ t: c.t || c.text, lvl: c.lvl, s: c.line,
243
+ e: ci + 1 < kids.length ? kids[ci + 1].line - 1 : e
244
+ }));
245
+ const idm = h2.text.match(ID_RE);
246
+ records.push({
247
+ h1, file: f, h2: h2.text, id: idm ? idm[1] : null, s, e,
248
+ lines: e - s + 1,
249
+ chars: lines.slice(s - 1, e).join('\n').length,
250
+ snip: snippet(lines, mask, s, h3.length ? h3[0].s - 1 : e, 300),
251
+ h3
252
+ });
253
+ });
254
+ }
255
+ makeKeys(records);
256
+ const out = {
257
+ generated: new Date().toISOString(), repo: REPO, files,
258
+ totals: {
259
+ records: records.length,
260
+ h3Rows: records.reduce((a, r) => a + r.h3.length, 0),
261
+ withId: records.filter(r => r.id).length,
262
+ truncatedKeys: records.filter(r => !r.id && r.h2.length > KEY_WIDTH).length
263
+ },
264
+ records
265
+ };
266
+ write(out, 'outline.json');
267
+ console.log(JSON.stringify(out.totals, null, 1));
268
+ // Say the contract out loud so a caller cannot get it wrong.
269
+ console.log('key: echo records[].key back VERBATIM. Never emit a positional index.');
270
+ }
271
+
272
+ // ---------------------------------------------------------------- validate (Layer 2 gate)
273
+
274
+ // labels.json: { themes: [{name, gloss}], labels: [{key, theme}] }
275
+ // This is the gate that catches the POC A failure class: positional drift producing
276
+ // dropped, shifted and duplicated keys inside confident-looking output.
277
+ function keyOf(r) {
278
+ if (!r.key) die('outline.json has no records[].key — re-run `docs-builder.cjs scan`');
279
+ return r.key;
280
+ }
281
+
282
+ function loadPair(outlineF, labelsF) {
283
+ const o = readArtifactJSON(outlineF), l = readArtifactJSON(labelsF);
284
+ if (!Array.isArray(o.records)) die('outline.json has no records[]');
285
+ if (!Array.isArray(l.labels)) die('labels.json has no labels[]');
286
+ // Without a theme list the "none off-list" check silently passes anything. A gate that
287
+ // quietly stops checking is worse than no gate.
288
+ if (!Array.isArray(l.themes) || !l.themes.length)
289
+ die('labels.json has no themes[] — the off-list check cannot run. Emit the propose '
290
+ + 'pass output alongside the labels.');
291
+ return [o, l];
292
+ }
293
+
294
+ // (a) every outline record's source file must still exist. Catches an outline gone stale
295
+ // after a move that bypassed `archive` (Change 2 keeps `archive` itself in sync).
296
+ function checkPaths(o) {
297
+ return [...new Set(o.records.map(r => r.file))].filter(f => !fs.existsSync(repoPath(f)));
298
+ }
299
+
300
+ // (b) every markdown link inside the (one, whole-corpus) index must resolve to a real file.
301
+ // Loud skip, not a silent pass, when that file is missing — same law as the themes[] guard in
302
+ // loadPair() above: a gate that quietly stops checking is worse than no gate.
303
+ //
304
+ // v3 scope change (2026-08-24): the themed per-split index (docs/wiki-index.md) is gone —
305
+ // see the removal note where its `index()` function used to be. This now checks the SAME
306
+ // single index index-flat writes (`docs/index.md` by default), and checks EVERY link in it,
307
+ // not just ones under a PAGES-relative prefix: the old scoping existed only because the
308
+ // themed index's links were ALWAYS PAGES-relative by construction; the one index links into
309
+ // product/, logs/, archive/ and PAGES/ all in the same file, so a prefix-scoped check would
310
+ // silently stop covering most of it. This also means a core-theme page — settled 2026-08-23,
311
+ // docs-builder-v3-spec.md "cleanup": lives in its own document's original directory, not
312
+ // PAGES — is checked exactly like any other row, with no special case needed here at all.
313
+ function checkLinks() {
314
+ const rel = process.env.INDEX || 'docs/index.md';
315
+ if (!fs.existsSync(repoPath(rel))) { console.error(`LOUD-SKIP: links check did not run — no ${rel}`); return { checked: false, bad: [] }; }
316
+ const text = stripFences(fs.readFileSync(repoPath(rel), 'utf8'));
317
+ const dir = path.dirname(rel);
318
+ // Old regex required the capture to end `.md)` literally, so `(x.md#anchor)` never matched
319
+ // — a broken link went unchecked. And `%20` in a correct link false-positived against the
320
+ // real (unencoded) path. Fix: strip `#anchor` before the `.md` filter, decode before the
321
+ // existence check. Absolute/external targets (a URL, a leading `/`) are never in scope —
322
+ // this only ever checks relative links, the only kind indexRow() ever writes.
323
+ const links = new Set([...text.matchAll(/\]\(([^()\s]+)\)/g)]
324
+ .map(m => decodeURIComponent(m[1].split('#')[0]))
325
+ .filter(l => l.endsWith('.md') && !/^[a-z][a-z0-9+.-]*:/i.test(l) && !l.startsWith('/')));
326
+ const bad = [...links].filter(link => !fs.existsSync(repoPath(path.join(dir, link))));
327
+ return { checked: true, bad };
328
+ }
329
+
330
+ // (c)/(d) citations. Format `(<file>:<start>-<end>)` or `(<file>:<line>)`, pinned in
331
+ // docs-builder.md step 5. A page may only cite inside its OWN task's source ranges — anything
332
+ // else (wrong file, out-of-range, ambiguous basename) is the exact failure class `plan`'s
333
+ // per-page context isolation exists to prevent, so it is a gate, not a proposal. Uncited
334
+ // sections (d) are the mirror check — flagged, never blocking (see docstring at call site).
335
+ const CITE_RE = /\(([\w./-]+\.\w+):(\d+)(?:-(\d+))?\)/g;
336
+
337
+ // `plan` writes task-*.json into `process.env.OUT || <default>`; checkCitations used to
338
+ // hardcode the default only, so a `plan` run against a custom OUT left it silently checking
339
+ // whatever stale content still sat at the default path instead of LOUD-SKIPping. Fixed with
340
+ // a reader-side var of its own (TASKS), not by reading OUT directly here: OUT is already
341
+ // doValidate's own var for `write(res, 'validate.json')`, and MEASURED, reusing it made
342
+ // `write()` crash (EISDIR) the moment OUT pointed at an existing directory. Same shape as
343
+ // INDEX/OUT for index.md/checkLinks — writer and reader share one default, each through its
344
+ // own var — so a caller must pass TASKS= to match a non-default `plan` OUT=, same as it
345
+ // already has to pass INDEX= to match a non-default `index` OUT=.
346
+ // A function, not a top-level const: ARTIFACTS itself is declared further down the file (near
347
+ // `write()`), and every OTHER site that reads it the same way (e.g. rewriteArchivedPath above)
348
+ // only ever does so from inside a function body, run after the whole module has loaded.
349
+ function tasksDirDefault() { return path.join(ARTIFACTS, 'tasks'); }
350
+
351
+ function checkCitations() {
352
+ const pagesDir = process.env.PAGES || 'docs/wiki';
353
+ const tasksDir = process.env.TASKS || tasksDirDefault();
354
+ if (!fs.existsSync(repoPath(pagesDir)) || !fs.existsSync(tasksDir)) {
355
+ console.error(`LOUD-SKIP: citations check did not run — missing ${pagesDir} or ${tasksDir}`);
356
+ return { checked: false, violations: [], uncited: [] };
357
+ }
358
+ const violations = [], uncited = [];
359
+ for (const pageFile of fs.readdirSync(repoPath(pagesDir)).filter(f => f.endsWith('.md'))) {
360
+ const taskF = path.join(tasksDir, `task-${pageFile.slice(0, -3)}.json`);
361
+ if (!fs.existsSync(taskF)) { console.error(`WARN: no task file for ${pageFile} — citations not checked`); continue; }
362
+ // Per-page isolation: a truncated task-*.json — exactly what a crashed page-writer leaves
363
+ // behind — must not stop every OTHER page's citations from being checked, and must not
364
+ // stop validate.json from being written at all. A file this broken IS a real gate failure
365
+ // (silently skipping it would hide a genuine problem), so it counts as a violation rather
366
+ // than a WARN-and-skip.
367
+ let task;
368
+ try { task = parseJSONFileOrThrow(taskF); }
369
+ catch (e) {
370
+ violations.push({ page: pageFile, cite: '(task file)', reason: `malformed task file ${taskF}: ${e.message}` });
371
+ continue;
372
+ }
373
+ const byFile = new Map();
374
+ for (const sec of task.sections || []) {
375
+ if (!byFile.has(sec.file)) byFile.set(sec.file, []);
376
+ byFile.get(sec.file).push({ s: sec.s, e: sec.e });
377
+ }
378
+ const text = stripFences(fs.readFileSync(repoPath(path.join(pagesDir, pageFile)), 'utf8'));
379
+ const cited = [];
380
+ for (const m of text.matchAll(CITE_RE)) {
381
+ const raw = m[1], s = +m[2], e = m[3] ? +m[3] : +m[2];
382
+ const tag = `${raw}:${m[2]}${m[3] ? '-' + m[3] : ''}`;
383
+ // MEASURED: a reversed range like `(CYBERNETICS.md:97-28)` passed silently — `s >= r.s
384
+ // && e <= r.e` below can hold even with s > e, since neither half alone catches it.
385
+ if (s < 1 || e < 1 || s > e) {
386
+ violations.push({ page: pageFile, cite: tag, reason: 'invalid line range (must be 1-based, start <= end)' });
387
+ continue;
388
+ }
389
+ const bases = [...byFile.keys()].filter(f => path.basename(f) === path.basename(raw));
390
+ if (!byFile.has(raw) && bases.length > 1) {
391
+ violations.push({ page: pageFile, cite: tag, reason: `ambiguous basename across this page's sources: ${bases.join(', ')}` });
392
+ continue;
393
+ }
394
+ const file = byFile.has(raw) ? raw : bases[0];
395
+ if (!file) { violations.push({ page: pageFile, cite: tag, reason: "file not among this page's sources" }); continue; }
396
+ const ranges = byFile.get(file);
397
+ if (!ranges.some(r => s >= r.s && e <= r.e)) {
398
+ violations.push({ page: pageFile, cite: tag, reason: `outside allowed ranges ${ranges.map(r => `${r.s}-${r.e}`).join(', ')}` });
399
+ continue;
400
+ }
401
+ cited.push({ file, s, e });
402
+ }
403
+ for (const sec of task.sections || [])
404
+ if (!cited.some(c => c.file === sec.file && c.s <= sec.e && c.e >= sec.s))
405
+ uncited.push({ page: pageFile, file: sec.file, h2: sec.h2 });
406
+ }
407
+ return { checked: true, violations, uncited };
408
+ }
409
+
410
+ // `docs/.docs-builder/failures.json` — a LIVE count of current gate failures, keyed
411
+ // `<check>:<target>`, not a graveyard: a key that stops failing is deleted. Never called
412
+ // for uncited sections — that check is propose-only by design, never a failure count.
413
+ function reconcileFailures(ledger, check, targets, detailOf) {
414
+ const prefix = `${check}:`, now = new Date().toISOString();
415
+ for (const key of Object.keys(ledger))
416
+ if (key.startsWith(prefix) && !targets.has(key.slice(prefix.length))) delete ledger[key];
417
+ for (const t of targets) {
418
+ const e = ledger[prefix + t] || (ledger[prefix + t] = { count: 0, firstSeen: now });
419
+ e.count++; e.lastSeen = now; e.lastDetail = detailOf(t);
420
+ }
421
+ }
422
+
423
+ // `docs/log.md` is documented (Layout, Mode 3) as append-only — `## [DATE] operation |
424
+ // description` — but nothing wrote it before this.
425
+ function logOp(op, desc) {
426
+ const rel = 'docs/log.md';
427
+ const f = path.join(REPO, rel);
428
+ fs.mkdirSync(path.dirname(f), { recursive: true });
429
+ fs.appendFileSync(f, `## [${new Date().toISOString().slice(0, 10)}] ${op} | ${desc}\n`);
430
+ // FIELD BUG (privcloud, real first run): the commit recipe omitted this file — which THIS
431
+ // RUN had just created, holding this run's own audit lines. Following the recipe verbatim
432
+ // committed a reorg and left its log untracked. Same class as omitting the index.
433
+ noteGenerated(rel);
434
+ }
435
+
436
+ // Core THROWS-free / exit-free — same split as doArchive()/archive() and gitOrThrow()/git()
437
+ // above, for the same reason: `reconcile` calls this IN-PROCESS and must survive a FAIL
438
+ // verdict to still run index + lint, which no caller can do once `process.exit` has fired.
439
+ // `validate()` below is the CLI-facing wrapper that turns the verdict into an exit code for a
440
+ // single direct invocation — its own hard-gate behaviour (exit 1 on FAIL) is UNCHANGED.
441
+ function doValidate(outlineF, labelsF) {
442
+ const [o, l] = loadPair(outlineF, labelsF);
443
+ const expect = new Map(o.records.map(r => [keyOf(r), r]));
444
+ const themes = new Set((l.themes || []).map(t => t.name));
445
+ const seen = new Map();
446
+ const invented = [], offTheme = [], dupes = [];
447
+ for (const row of l.labels) {
448
+ if (!expect.has(row.key)) { invented.push(row.key); continue; }
449
+ if (seen.has(row.key)) dupes.push(row.key); else seen.set(row.key, row.theme);
450
+ if (themes.size && !themes.has(row.theme)) offTheme.push(`${row.key} -> ${row.theme}`);
451
+ }
452
+ const missing = [...expect.keys()].filter(k => !seen.has(k));
453
+ const covered = [...seen.keys()].reduce((a, k) => a + expect.get(k).lines, 0);
454
+ const total = o.records.reduce((a, r) => a + r.lines, 0);
455
+ const missingFiles = checkPaths(o);
456
+ const links = checkLinks();
457
+ const citations = checkCitations();
458
+ // Uncited sections are FLAG ONLY — deliberately excluded from `pass`, per docs-builder.md.
459
+ const pass = !invented.length && !offTheme.length && !dupes.length && !missing.length
460
+ && !missingFiles.length && !links.bad.length && !citations.violations.length;
461
+ const res = {
462
+ sections: o.records.length, labels: l.labels.length,
463
+ invented, offTheme, dupes, missing,
464
+ linesCovered: covered, linesTotal: total,
465
+ paths: { missingFiles },
466
+ links,
467
+ citations: { checked: citations.checked, violations: citations.violations, uncited: citations.uncited },
468
+ verdict: pass ? 'PASS' : 'FAIL'
469
+ };
470
+ console.log(JSON.stringify({
471
+ ...res, invented: invented.length, offTheme: offTheme.length,
472
+ dupes: dupes.length, missing: missing.length, missingFiles: missingFiles.length,
473
+ badLinks: links.bad.length, citationViolations: citations.violations.length,
474
+ uncitedSections: citations.uncited.length
475
+ }, null, 1));
476
+ if (!pass) {
477
+ for (const [k, v] of [['invented', invented], ['off-theme', offTheme],
478
+ ['duplicate', dupes], ['missing', missing],
479
+ ['missing source file', missingFiles], ['bad index link', links.bad],
480
+ ['citation violation', citations.violations.map(v => `${v.page} (${v.cite}) — ${v.reason}`)]])
481
+ if (v.length) console.error(`\n${k} (${v.length}):\n ` + v.slice(0, 20).join('\n '));
482
+ }
483
+ if (citations.uncited.length)
484
+ console.error(`\nuncited sections (${citations.uncited.length}, flag only — does not affect verdict):\n `
485
+ + citations.uncited.slice(0, 20).map(u => `${u.page}: ${u.file} — ${u.h2}`).join('\n '));
486
+ const ledgerF = path.join(ARTIFACTS, 'failures.json');
487
+ const failLedger = fs.existsSync(ledgerF) ? parseJSONFile(ledgerF) : {};
488
+ reconcileFailures(failLedger, 'paths', new Set(missingFiles), () => 'missing source file');
489
+ // `checked: false` = LOUD-SKIPPED, not passed — reconciling against [] then would delete
490
+ // real prior failures the check never actually re-ran to confirm fixed.
491
+ if (links.checked) reconcileFailures(failLedger, 'links', new Set(links.bad), () => 'broken index link');
492
+ if (citations.checked) {
493
+ const failingPages = new Set(citations.violations.map(v => v.page));
494
+ reconcileFailures(failLedger, 'citations', failingPages, t => citations.violations
495
+ .filter(v => v.page === t).map(v => `(${v.cite}) ${v.reason}`).join('; '));
496
+ }
497
+ fs.mkdirSync(path.dirname(ledgerF), { recursive: true });
498
+ fs.writeFileSync(ledgerF, JSON.stringify(failLedger, null, 1));
499
+ // Recurrence, not severity, decides this — message ONLY, never the verdict or exit code.
500
+ for (const [key, e] of Object.entries(failLedger))
501
+ if (e.count >= 3) console.error(`STRUCTURAL (${e.count}x since ${e.firstSeen.slice(0, 10)}): `
502
+ + `${key} — likely not a one-off. Stop retrying, escalate to a human.`);
503
+ write(res, 'validate.json');
504
+ logOp('validate', `${res.verdict} — ${missingFiles.length + links.bad.length + citations.violations.length} gate failure(s)`);
505
+ return res;
506
+ }
507
+
508
+ function validate(outlineF, labelsF) {
509
+ if (!outlineF || !labelsF) die('usage: docs-builder.cjs validate <outline.json> <labels.json>');
510
+ const res = doValidate(outlineF, labelsF);
511
+ process.exit(res.verdict === 'PASS' ? 0 : 1);
512
+ }
513
+
514
+ // ---------------------------------------------------------------- plan (page writer inputs)
515
+
516
+ // A checkpoint you do not validate is not a checkpoint. MEASURED the hard way: a page
517
+ // writer that died on a 429 left the error string as the page body, and `plan` reported
518
+ // "all pages written — nothing to do" while two themes (2,238 source lines) had no page at
519
+ // all. Existence is not completion — a resumable step must be able to tell a finished
520
+ // artifact from the wreckage of a failed one.
521
+ const MIN_PAGE_LINES = 10;
522
+ function pageStatus(file) {
523
+ if (!fs.existsSync(file)) return 'TODO';
524
+ const txt = fs.readFileSync(file, 'utf8');
525
+ const lines = txt.split('\n');
526
+ const hasFrontmatter = lines[0].trim() === '---' && lines.slice(1).some(l => l.trim() === '---');
527
+ return hasFrontmatter && lines.length >= MIN_PAGE_LINES ? 'done' : 'PARTIAL';
528
+ }
529
+
530
+ function slugOf(t) {
531
+ return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'page';
532
+ }
533
+
534
+ // Two different theme names can slug identically ("A/B testing" and "A-B testing" both give
535
+ // `a-b-testing`), which silently overwrote one theme's task file and pointed two index
536
+ // entries at one page — a whole page of content vanishing with no error. Slugs are assigned
537
+ // once, for all themes together, and disambiguated on collision.
538
+ function slugMap(themes) {
539
+ const used = new Map(), out = new Map();
540
+ for (const t of themes) {
541
+ const base = slugOf(t);
542
+ const n = (used.get(base) || 0) + 1;
543
+ used.set(base, n);
544
+ const slug = n === 1 ? base : `${base}-${n}`;
545
+ if (n > 1) console.error(`WARN: theme slug collision — "${t}" -> ${slug}`);
546
+ out.set(t, slug);
547
+ }
548
+ return out;
549
+ }
550
+
551
+ // Cost law measured over 10 pages, R^2 = 0.96. 42% of the write bill is per-page fixed. One
552
+ // function so `plan` (real page count, from labels.json) and `cleanup` (a 1-page floor,
553
+ // printed before any grouping has happened) can never disagree on the formula itself.
554
+ function writeCostEstimate(pages, lines) { return pages * 0.083 + lines / 1000 * 0.200; }
555
+
556
+ // settled 2026-08-23 (docs-builder-v3-spec.md, "cleanup" — "Everything cleanup produces is
557
+ // a new file"): the core theme's page carries the ORIGINAL file's basename, not a slugified
558
+ // theme name. Exactly one theme may claim it — two would mean two pages both wanting to own
559
+ // the source's identity. Zero is fine: most callers of `plan` (tests, and any labels.json
560
+ // hand-written without a split in mind) never set one at all.
561
+ function coreThemeName(themes) {
562
+ const cores = (themes || []).filter(t => t.core === true);
563
+ if (cores.length > 1)
564
+ die(`labels.json marks ${cores.length} themes core:true (${cores.map(t => t.name).join(', ')}) `
565
+ + '— exactly one theme may be core (docs-builder-v3-spec.md, "cleanup").');
566
+ return cores[0] ? cores[0].name : null;
567
+ }
568
+
569
+ // Shared by `plan` and `index` so the page `plan` writes and the link `index` prints can
570
+ // never name the core theme two different ways. Every other theme keeps the existing
571
+ // collision-safe slugOf() behaviour (slugMap); only the core theme, if any, is overridden to
572
+ // the source file's own basename.
573
+ function buildThemeSlugs(names, l, o) {
574
+ const coreName = coreThemeName(l.themes);
575
+ const slugs = slugMap(names.filter(n => n !== coreName));
576
+ if (coreName) {
577
+ const bases = [...new Set(o.records.map(r => r.file))].map(f => path.basename(f));
578
+ if (new Set(bases).size !== 1)
579
+ die('a core theme requires an outline scanned from exactly one source file, but this '
580
+ + `outline covers ${new Set(bases).size} (${[...new Set(bases)].join(', ')}) — core `
581
+ + "naming only makes sense for cleanup's one-file split.");
582
+ const coreSlug = bases[0].replace(/\.md$/, '');
583
+ if ([...slugs.values()].includes(coreSlug))
584
+ console.error(`WARN: core page name "${coreSlug}" collides with another theme's slug`);
585
+ slugs.set(coreName, coreSlug);
586
+ }
587
+ return slugs;
588
+ }
589
+
590
+ function group(o, l) {
591
+ const by = new Map(o.records.map(r => [keyOf(r), r]));
592
+ const g = new Map();
593
+ for (const row of l.labels) {
594
+ const r = by.get(row.key); if (!r) continue;
595
+ if (!g.has(row.theme)) g.set(row.theme, []);
596
+ g.get(row.theme).push(r);
597
+ }
598
+ for (const v of g.values()) v.sort((a, b) => a.file.localeCompare(b.file) || a.s - b.s);
599
+ return g;
600
+ }
601
+
602
+ // A half-finished Mode 1 split: the model wrote one or more DONE pages under PAGES/, but
603
+ // `archive` was never run on the source — so the same content now sits at its original path
604
+ // AND in docs/wiki/, which the doc calls "duplication, not cleanup". Derived entirely from
605
+ // artifacts that already exist (outline.json's records[].file, labels.json's theme
606
+ // assignment, and the same slug/pageStatus logic `plan` already uses to report a page
607
+ // "done") — no new state file, because the finished page already IS the checkpoint. Flag
608
+ // only, never a gate: callers print this as a WARNING and never change their exit code on it.
609
+ function unarchivedSplits(o, l, pages, slugs) {
610
+ const g = [...group(o, l)];
611
+ const bySrc = new Map();
612
+ for (const [theme, recs] of g) {
613
+ const slug = slugs.get(theme);
614
+ if (pageStatus(path.join(REPO, pages, `${slug}.md`)) !== 'done') continue;
615
+ for (const f of new Set(recs.map(r => r.file))) {
616
+ if (!bySrc.has(f)) bySrc.set(f, new Set());
617
+ bySrc.get(f).add(slug);
618
+ }
619
+ }
620
+ const flagged = [];
621
+ for (const [file, slugSet] of bySrc)
622
+ if (fs.existsSync(repoPath(file)) && !file.startsWith('docs/archive/'))
623
+ flagged.push({ file, pages: [...slugSet] });
624
+ return flagged;
625
+ }
626
+
627
+ function warnUnarchivedSplits(o, l, pages, slugs) {
628
+ for (const w of unarchivedSplits(o, l, pages, slugs))
629
+ console.error(`WARN: half-finished split — ${w.file} has finished page(s) `
630
+ + `(${w.pages.join(', ')}) in ${pages}/, but the source is still at ${w.file}. `
631
+ + `Run \`archive ${w.file}\` to finish the split.`);
632
+ }
633
+
634
+ function plan(outlineF, labelsF) {
635
+ if (!outlineF || !labelsF) die('usage: docs-builder.cjs plan <outline.json> <labels.json>');
636
+ const [o, l] = loadPair(outlineF, labelsF);
637
+ const gloss = new Map((l.themes || []).map(t => [t.name, t.gloss || '']));
638
+ const dir = process.env.OUT || tasksDirDefault();
639
+ fs.mkdirSync(dir, { recursive: true });
640
+ const grouped = [...group(o, l)];
641
+ const slugs = buildThemeSlugs(grouped.map(([t]) => t), l, o);
642
+ // Resume is not advice, it is behaviour: a page already written in PAGES is reported
643
+ // `done` and left out of the cost estimate, so re-running `plan` after a crash relaunches
644
+ // only what is missing. A cleanup that dies halfway and cannot resume is worse than a
645
+ // slow one.
646
+ const pages = process.env.PAGES || 'docs/wiki';
647
+ const rows = [];
648
+ for (const [theme, recs] of grouped) {
649
+ const slug = slugs.get(theme);
650
+ const task = {
651
+ theme, slug, gloss: gloss.get(theme) || '',
652
+ sources: [...new Set(recs.map(r => r.file))],
653
+ n: recs.length,
654
+ lines: recs.reduce((a, r) => a + r.lines, 0),
655
+ chars: recs.reduce((a, r) => a + r.chars, 0),
656
+ sections: recs.map(r => ({ file: r.file, h2: r.h2, s: r.s, e: r.e, lines: r.lines, sub: r.h3.length }))
657
+ };
658
+ fs.writeFileSync(path.join(dir, `task-${slug}.json`), JSON.stringify(task, null, 1));
659
+ rows.push({ theme: slug, sections: task.n, lines: task.lines,
660
+ status: pageStatus(path.join(REPO, pages, `${slug}.md`)) });
661
+ }
662
+ const todo = rows.filter(r => r.status !== 'done');
663
+ const partial = rows.filter(r => r.status === 'PARTIAL');
664
+ if (partial.length)
665
+ console.error(`WARN: ${partial.length} page(s) exist but are not a finished page `
666
+ + '(no frontmatter, or too short) — they will be rewritten: '
667
+ + partial.map(r => r.theme).join(', '));
668
+ warnUnarchivedSplits(o, l, pages, slugs);
669
+ const tot = todo.reduce((a, r) => a + r.lines, 0);
670
+ console.table(rows);
671
+ if (todo.length < rows.length)
672
+ console.log(`resuming: ${rows.length - todo.length} of ${rows.length} pages already in ${pages}/`);
673
+ // Returned, not just printed: `cleanup-apply` (below) needs `todo` to know whether it may
674
+ // move on to archive+index, or must stop and wait for the model to write more pages.
675
+ if (!todo.length) { console.log('all pages written — nothing to do.'); return { rows, todo, pages }; }
676
+ const est = writeCostEstimate(todo.length, tot);
677
+ console.log(`pages to write: ${todo.length} lines: ${tot} est. write cost: $${est.toFixed(2)} (mid tier)`);
678
+ if (todo.length > 3) console.log('launch page writers 3 at a time; each finished page is a checkpoint — re-run `plan` to resume.');
679
+ return { rows, todo, pages };
680
+ }
681
+
682
+ // ---------------------------------------------------------------- (themed index — removed)
683
+
684
+ // REMOVED 2026-08-24 (user decision, explicit spec: "ONE index... rewritten on every reorg...
685
+ // also runs after splitting"). This used to be a SECOND index — a themed, per-split view at
686
+ // `docs/wiki-index.md`, written by a subcommand called `index` — sitting alongside index-
687
+ // flat's whole-corpus `docs/index.md`. It was never asked for, and it was the direct cause of
688
+ // three separate defects: (1) an early version of it defaulted to the SAME file index-flat
689
+ // writes, so the last writer silently clobbered the other's rows; (2) even split apart into
690
+ // its own file, its own `scan` step still clobbered outline.json across concurrent splits
691
+ // (see inFlightSplit(), which now refuses that instead); (3) its slug-based lookup
692
+ // (slugOf()'s lowercasing) could report an existing page as "pending" for a heading like
693
+ // "RLM_PRD" whose slug and file didn't round-trip. index-flat's whole-corpus `docs/index.md`
694
+ // already covers everything this indexed (verified: it lists every PAGES/ page, at whatever
695
+ // grain it exists in `## Product`) at the row-count grain MEASURED to actually help (16 rows
696
+ // fine, 97 won, 364 lost — indexing every PAGES page a second time at section grain was
697
+ // already trending toward the losing arm). `docs-builder.cjs search` remains the fallback
698
+ // once a corpus outgrows a flat index — see its own comment below.
699
+
700
+ // ---------------------------------------------------------------- index-flat (no labels)
701
+
702
+ // v3: ONE index for the WHOLE corpus, three sections — `## Product`, `## Logs`, `## Archive`.
703
+ // `search` reads outline.json, never index.md, so index.md is purely a human/agent map.
704
+ //
705
+ // docs/index.md is THIS function's file, and only this function's: `index-flat` (called
706
+ // directly, and from `apply-reorg`/`cleanup-apply`) is the sole writer of the default OUT
707
+ // path. A real defect on bareloop is why that line is load-bearing, not decoration: a themed
708
+ // `index` subcommand used to write a SECOND index (`docs/wiki-index.md`) that briefly
709
+ // defaulted to this SAME file — a PRD split's 7-row themed index silently overwrote the
710
+ // 37-row whole-corpus map the moment it ran, 30 files vanishing from a file that still
711
+ // claimed completeness. Rather than keep giving the two index files their own defaults
712
+ // forever, the themed one was removed outright 2026-08-24 (see the removal note where its
713
+ // `index()` function used to live) — `docs/index.md` is now the ONE index there is to
714
+ // collide with.
715
+ //
716
+ // `## Product` covers three things, using the SAME partition scanWholeCorpus() already
717
+ // established (wholeCorpusFiles()) — not a fourth enumeration of the corpus:
718
+ // - every file under docs/product/
719
+ // - every page under PAGES (docs/wiki by default), if any exist
720
+ // - every doc still sitting in place elsewhere in the corpus — e.g. an oversized file
721
+ // apply-reorg deliberately left untouched, since splitting spends real model budget and
722
+ // must never fire unprompted (see docs-builder-v3-spec.md, "The three rules").
723
+ // `## Archive` is one row per file under docs/archive/.
724
+ //
725
+ // Nothing in this pipeline prunes archive/ — the one command that used to (it was the only
726
+ // destructive one in the whole tool) was removed outright; pruning is just `git rm`, the
727
+ // user's own call — so left alone it only grows. ARCHIVE_WARN_ROWS below is a console-only
728
+ // tripwire, never a prune, never a collapse, never a delete.
729
+ const ARCHIVE_WARN_ROWS = 100; // stated default, not measured — see docs-builder-v3-spec.md
730
+
731
+ function indexRow(rel, dest) {
732
+ const text = read(rel);
733
+ const h1 = (text.split('\n').find(l => l.startsWith('# ')) || '').slice(2).trim();
734
+ const lines = text.split('\n').length;
735
+ // Read from INSIDE index.md, so the link must resolve relative to index.md's own
736
+ // directory, not the repo root.
737
+ const relLink = path.relative(path.dirname(dest), repoPath(rel)).split(path.sep).join('/');
738
+ return `- [${h1 || path.basename(rel)}](${relLink}) — ${lines} lines\n`;
739
+ }
740
+
741
+ function renderSection(title, rows) {
742
+ let s = `## ${title}\n\n`;
743
+ s += rows.length ? rows.map(r => r.row).join('') : '_(none)_\n';
744
+ return s + '\n';
745
+ }
746
+
747
+ function indexFlat() {
748
+ const archiveRel = 'docs/archive/';
749
+ const logsRel = 'docs/logs/';
750
+ const corpus = wholeCorpusFiles(); // product/, logs/, archive/, and anything left in place
751
+ const archiveFiles = corpus.filter(f => f.startsWith(archiveRel));
752
+ const logsFiles = corpus.filter(f => f.startsWith(logsRel));
753
+ const productFiles = corpus.filter(f => !f.startsWith(archiveRel) && !f.startsWith(logsRel));
754
+
755
+ const pagesRel = process.env.PAGES || 'docs/wiki';
756
+ const pagesAbs = repoPath(pagesRel);
757
+ const pageFiles = fs.existsSync(pagesAbs)
758
+ ? fs.readdirSync(pagesAbs).filter(f => f.endsWith('.md'))
759
+ .map(f => path.join(pagesRel, f).split(path.sep).join('/'))
760
+ : [];
761
+
762
+ if (!productFiles.length && !logsFiles.length && !archiveFiles.length && !pageFiles.length) {
763
+ console.log('nothing to index — run `discover` + `apply-reorg` first.');
764
+ return;
765
+ }
766
+
767
+ const outRel = process.env.OUT || 'docs/index.md';
768
+ const dest = repoPath(outRel);
769
+ const productRows = [...productFiles, ...pageFiles].sort()
770
+ .map(f => ({ file: f, row: indexRow(f, dest) }));
771
+ const logsRows = logsFiles.sort().map(f => ({ file: f, row: indexRow(f, dest) }));
772
+ const archiveRows = archiveFiles.sort().map(f => ({ file: f, row: indexRow(f, dest) }));
773
+
774
+ let s = '# Index\n\n';
775
+ // Unconditional — not gated on row count, unlike ARCHIVE_WARN_ROWS below: a reader should
776
+ // reach for `search` on instinct, not only once a corpus is already large.
777
+ s += '> Search this corpus instead of reading it whole: `/docs-builder search <query words>`\n\n';
778
+ s += '**Completeness guarantee:** every file under `docs/product/`, every page under '
779
+ + `\`${pagesRel}/\` (if any), every doc left in place after a reorg, every file under `
780
+ + '`docs/logs/`, and every file under `docs/archive/` appears in exactly one row below.\n\n';
781
+ s += '_Generated by `docs-builder.cjs index-flat` — the ONE index, rebuilt every reorg and '
782
+ + 'after every split (`cleanup-apply`). No theme grouping, no model call. Never '
783
+ + 'hand-edit._\n\n';
784
+ s += renderSection('Product', productRows);
785
+ s += renderSection('Logs', logsRows);
786
+ s += renderSection('Archive', archiveRows);
787
+ const total = productRows.length + logsRows.length + archiveRows.length;
788
+ s += `---\n\nTotal: ${total} row(s) — ${productRows.length} product, `
789
+ + `${logsRows.length} logs, ${archiveRows.length} archive.\n`;
790
+
791
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
792
+ fs.writeFileSync(dest, s);
793
+ noteGenerated(outRel);
794
+ console.log(`wrote ${dest}: ${total} rows (${productRows.length} product, `
795
+ + `${logsRows.length} logs, ${archiveRows.length} archive)`);
796
+ logOp('index-flat', `${total} row(s) (${productRows.length} product, `
797
+ + `${logsRows.length} logs, ${archiveRows.length} archive)`);
798
+
799
+ // Console-only tripwire. Nothing in this run prunes archive/ automatically — pruning is
800
+ // `git rm`, the user's own call (see the ARCHIVE_WARN_ROWS comment above). This never
801
+ // prunes, never collapses the section, never deletes; it only warns.
802
+ if (archiveRows.length > ARCHIVE_WARN_ROWS)
803
+ console.log(`WARN: archive/ is ${archiveRows.length} rows and growing — nothing prunes it `
804
+ + `automatically.\n Review ${outRel} ## Archive and \`git rm\` what you no longer `
805
+ + 'need.');
806
+ }
807
+
808
+ // ---------------------------------------------------------------- search (BM25, zero deps)
809
+
810
+ // The fallback once a corpus outgrows a flat index-md's row ceiling (MEASURED against the
811
+ // now-removed themed index: 16 rows fine, 97 rows won, 364 rows lost): a reader who can't
812
+ // hold the whole index in one read needs ranked results instead. This reuses outline.json
813
+ // (already on disk from `scan` — no second index to build or drift)
814
+ // and scores with plain BM25 over each section's own text. No SQLite, no external search
815
+ // tool: at doc-corpus scale (tens to low hundreds of sections) a linear scan in vanilla JS
816
+ // is sub-millisecond, so a database buys nothing here — see dependency hierarchy in
817
+ // AGENT_RULES.md, vanilla language before stdlib before external.
818
+ const BM25_K1 = 1.5, BM25_B = 0.75;
819
+
820
+ const tokenize = s => (s.toLowerCase().match(/[a-z0-9]+/g) || []);
821
+
822
+ // The 300-char `snip` on each record is enough to tell sections apart for theme
823
+ // classification (its designed job) but starves search: it's only the prose BEFORE the
824
+ // first H3, often empty, and never reaches an H3's own body. MEASURED the hard way — a
825
+ // query for words that only appear inside an H3's body (not its title, not the H2's lead-in)
826
+ // ranked the right H2 record near-last, buried under 7 sibling H3 titles. Fix: read the
827
+ // FULL section body straight from source using the s/e line numbers scan() already recorded,
828
+ // one read per file (cached), same repo-relative resolution the rest of the script uses.
829
+ function bm25Rank(records, queryText, n) {
830
+ const bodyCache = new Map();
831
+ const bodyOf = r => {
832
+ if (!bodyCache.has(r.file)) {
833
+ // A stale outline.json can name a file that has since moved or been deleted (exactly
834
+ // what validate's `paths` check exists to catch). Skip it with a named warning rather
835
+ // than dying on a raw ENOENT stack halfway through a search.
836
+ try { bodyCache.set(r.file, read(r.file).split('\n')); }
837
+ catch { console.error(`WARN: ${r.file} is gone — skipped (re-run \`scan\`)`); bodyCache.set(r.file, null); }
838
+ }
839
+ if (!bodyCache.get(r.file)) return '';
840
+ return bodyCache.get(r.file).slice(r.s - 1, r.e).join(' ');
841
+ };
842
+ const docs = records.map(r => ({ r, tokens: tokenize(bodyOf(r)) }));
843
+ docs.forEach(d => { d.len = d.tokens.length; });
844
+ const N = docs.length;
845
+ const avgdl = docs.reduce((a, d) => a + d.len, 0) / (N || 1);
846
+ const df = new Map();
847
+ for (const d of docs) for (const t of new Set(d.tokens)) df.set(t, (df.get(t) || 0) + 1);
848
+ const qTerms = [...new Set(tokenize(queryText))];
849
+ // +1 inside the log keeps IDF non-negative for a term that appears in every section —
850
+ // the textbook Robertson-Sparck-Jones form can go negative there, which would let a
851
+ // common word actively PENALIZE a match instead of just contributing nothing.
852
+ const idf = new Map(qTerms.map(t => {
853
+ const nt = df.get(t) || 0;
854
+ return [t, Math.log((N - nt + 0.5) / (nt + 0.5) + 1)];
855
+ }));
856
+ const scored = docs.map(d => {
857
+ const tf = new Map();
858
+ for (const t of d.tokens) tf.set(t, (tf.get(t) || 0) + 1);
859
+ let score = 0;
860
+ for (const t of qTerms) {
861
+ const f = tf.get(t) || 0;
862
+ if (!f) continue;
863
+ score += idf.get(t) * (f * (BM25_K1 + 1)) / (f + BM25_K1 * (1 - BM25_B + BM25_B * d.len / avgdl));
864
+ }
865
+ return { score, r: d.r };
866
+ });
867
+ return scored.filter(s => s.score > 0).sort((a, b) => b.score - a.score).slice(0, n);
868
+ }
869
+
870
+ function search(outlineF, queryWords) {
871
+ if (!outlineF || !queryWords.length)
872
+ die('usage: docs-builder.cjs search <outline.json> <query words...>');
873
+ const o = readArtifactJSON(outlineF);
874
+ if (!Array.isArray(o.records) || !o.records.length) die('outline.json has no records[]');
875
+ const query = queryWords.join(' ');
876
+ const n = Math.trunc(+process.env.N);
877
+ const hits = bm25Rank(o.records, query, n > 0 ? n : 10);
878
+ if (!hits.length) { console.log(`no matches for "${query}"`); return; }
879
+ console.table(hits.map(h => ({
880
+ score: h.score.toFixed(2), file: h.r.file, lines: `${h.r.s}-${h.r.e}`,
881
+ h2: h.r.h2.length > 70 ? h.r.h2.slice(0, 67) + '...' : h.r.h2
882
+ })));
883
+ console.log(`top ${hits.length} of ${o.records.length} sections for "${query}". `
884
+ + 'Open the file at the given line range yourself — this ranks, it does not read for you.');
885
+ }
886
+
887
+ // ---------------------------------------------------------------- archive (a real move)
888
+
889
+ // The original is NEVER rewritten and NEVER edited — but it does not stay where it was
890
+ // either, or the cleanup leaves the same content in three places (old path, archive, and
891
+ // the synthesised pages). Verified move: hash first, `git mv` so history follows, hash
892
+ // again. Whether it can later be pruned is the user's own call — `git rm` — not this
893
+ // pipeline's; the command that used to do that was removed outright (see the ARCHIVE_WARN_ROWS
894
+ // comment in indexFlat() above).
895
+ const sha = f => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
896
+
897
+ // Both endpoints of every move must resolve INSIDE the repo. REPO may be relative (the test
898
+ // harness runs with REPO='.'), so resolve both sides before comparing — a `path.join` alone
899
+ // happily produces `../secret.txt` and reports no error.
900
+ // REPRODUCED 2026-08-24: a `"file": "../secret.txt"` row in reorg-plan.json reached doArchive
901
+ // through applyReorg -> moveDoc. `git mv` refused it (source outside the work tree), and the
902
+ // copy+unlink FALLBACK below then did exactly what it says — copied that file into the repo
903
+ // and unlinked the original. A traversal in a plan row is not hypothetical: plan rows pass
904
+ // through a model-driven classification interview, on corpora cloned from elsewhere.
905
+ // A string check alone is not enough: path.resolve() does NOT dereference symlinks, so
906
+ // `docs/evil.md -> /etc/passwd` passes confinement and the copy+unlink fallback then reads
907
+ // through the link and writes its TARGET's bytes into the repo. REPRODUCED 2026-08-24:
908
+ // `archive docs/evil-link.md docs/archive/evil.md` exited 0 having copied /etc/passwd in.
909
+ // Same threat model as the traversal above — a planted link in a cloned corpus needs nobody
910
+ // to type anything. So: resolve the string first (catches `../x` on a path that does not
911
+ // exist yet, which realpath cannot), then realpath what actually exists and re-check.
912
+ // The DESTINATION is checked as a string only — it is not supposed to exist yet, and its
913
+ // parent is created by doArchive itself.
914
+ function confined(p, what, { deref = false } = {}) {
915
+ const inside = (root, a) => {
916
+ const rel = path.relative(root, a);
917
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
918
+ };
919
+ const root = path.resolve(REPO);
920
+ const abs = path.resolve(root, p); // absolute p wins; relative p joins onto REPO
921
+ if (!inside(root, abs))
922
+ throw new Error(`refusing to ${what} outside the repo: ${p}`);
923
+ if (!deref) return abs;
924
+ let real;
925
+ try { real = fs.realpathSync(abs); }
926
+ catch { return abs; } // does not exist yet — the caller reports that
927
+ // Compare real against a REAL root: the repo itself may sit under a symlinked path
928
+ // (/tmp -> /private/tmp on macOS, and mkdtemp under it), which would otherwise read as
929
+ // an escape for every legitimate file.
930
+ if (!inside(fs.realpathSync(root), real))
931
+ throw new Error(`refusing to ${what} outside the repo: ${p} is a symlink to ${real}`);
932
+ return real;
933
+ }
934
+
935
+ // Core logic THROWS, never exits — so a caller doing many moves in a loop (apply-reorg)
936
+ // can catch one bad file and keep going. `archive()` below is the CLI-facing wrapper that
937
+ // turns a throw into a `die()` for a single direct invocation.
938
+ //
939
+ // Both guards below live HERE, at the chokepoint, and not at the call sites that happen to
940
+ // need them today. That is the whole point of moveDoc being the one path a doc moves
941
+ // through: a guard placed at a caller is a guard the next caller forgets. PROTECTED_NAMES
942
+ // used to be enforced in walkMd and cleanup only — so `archive README.md` moved the repo's
943
+ // README into docs/archive/ without a word (REPRODUCED 2026-08-24). Those two call-site
944
+ // checks are kept: they fail earlier and with a message aimed at what the user actually ran.
945
+ function doArchive(src, dest) {
946
+ const s = confined(src, 'move a doc from', { deref: true });
947
+ if (!fs.existsSync(s)) throw new Error(`no such file: ${src}`);
948
+ if (PROTECTED_NAMES.has(path.basename(src)))
949
+ throw new Error(`refusing to move ${src}: ${path.basename(src)} is an entry-point/contract `
950
+ + 'doc (README, CLAUDE.md, CHANGELOG, the index, the log, ...) and is never moved, at '
951
+ + 'any depth — every human and agent reads it first.');
952
+ const rel = dest || path.join('docs/archive', path.basename(src));
953
+ const d = confined(rel, 'move a doc to');
954
+ if (fs.existsSync(d)) throw new Error(`refusing to overwrite ${rel}`);
955
+ const before = sha(s), size = fs.statSync(s).size;
956
+ fs.mkdirSync(path.dirname(d), { recursive: true });
957
+ let how = 'git mv';
958
+ try { execFileSync('git', ['-C', REPO, 'mv', src, rel], { stdio: 'pipe' }); }
959
+ catch { how = 'copy+unlink'; fs.copyFileSync(s, d); fs.unlinkSync(s); }
960
+ if (!fs.existsSync(d)) throw new Error('FAIL: destination missing after move');
961
+ const after = sha(d);
962
+ if (before !== after) throw new Error(`FAIL: content changed in transit (${before} != ${after})`);
963
+ if (fs.existsSync(s)) throw new Error(`FAIL: original still present at ${src}`);
964
+ return { rel, size, sha: before, how };
965
+ }
966
+
967
+ // A `git mv` moves the file but not the pipeline's memory of it: outline.json and
968
+ // labels.json both embed the OLD path (in `records[].file`, and — since Change 1 made the
969
+ // `<file> :: ` prefix unconditional — inside every `records[].key` / `labels[].key` too), so
970
+ // a `git mv` alone silently invalidates every key the moved file's sections ever had.
971
+ // Rewrite is EXACT-match only (a whole `file` field, or the `<oldPath> :: ` key prefix) —
972
+ // never a substring replace, which could corrupt an unrelated path that merely contains this
973
+ // one as a substring. Missing artifacts are not an error: `archive` is documented as usable
974
+ // standalone, before `scan` has ever run.
975
+ function rewriteArchivedPath(oldPath, newPath) {
976
+ // Collects its notes instead of printing them. It runs INSIDE moveDoc, before the caller
977
+ // has printed the move itself, so printing here put the follow-up above the thing it
978
+ // followed. `archive` replays these after its header; `apply-reorg` counts them instead,
979
+ // because per-file chatter across N moves drowns the summary.
980
+ const totals = { outline: 0, labels: 0, messages: [] };
981
+ const log = m => totals.messages.push(m);
982
+ const oldPrefix = `${oldPath} :: `, newPrefix = `${newPath} :: `;
983
+ const outlineF = path.join(ARTIFACTS, 'outline.json');
984
+ if (!fs.existsSync(outlineF)) log('outline.json: not present — skipped');
985
+ else {
986
+ const o = parseJSONFileOrThrow(outlineF);
987
+ let files = 0, recFiles = 0, keys = 0;
988
+ if (Array.isArray(o.files)) o.files = o.files.map(f => f === oldPath ? (files++, newPath) : f);
989
+ for (const r of (o.records || [])) {
990
+ if (r.file === oldPath) { r.file = newPath; recFiles++; }
991
+ if (typeof r.key === 'string' && r.key.startsWith(oldPrefix)) { r.key = newPrefix + r.key.slice(oldPrefix.length); keys++; }
992
+ }
993
+ if (!files && !recFiles && !keys) log(`outline.json: no references to ${oldPath} — nothing to update`);
994
+ else {
995
+ fs.writeFileSync(outlineF, JSON.stringify(o, null, 1));
996
+ log(`outline.json: updated files[] x${files}, records[].file x${recFiles}, records[].key x${keys}`);
997
+ totals.outline = files + recFiles + keys;
998
+ }
999
+ }
1000
+ const labelsF = path.join(ARTIFACTS, 'labels.json');
1001
+ if (!fs.existsSync(labelsF)) log('labels.json: not present — skipped');
1002
+ else {
1003
+ const l = parseJSONFileOrThrow(labelsF);
1004
+ let keys = 0;
1005
+ for (const row of (l.labels || []))
1006
+ if (typeof row.key === 'string' && row.key.startsWith(oldPrefix)) { row.key = newPrefix + row.key.slice(oldPrefix.length); keys++; }
1007
+ if (!keys) log(`labels.json: no references to ${oldPath} — nothing to update`);
1008
+ else {
1009
+ fs.writeFileSync(labelsF, JSON.stringify(l, null, 1));
1010
+ log(`labels.json: updated labels[].key x${keys}`);
1011
+ totals.labels = keys;
1012
+ }
1013
+ }
1014
+ return totals;
1015
+ }
1016
+
1017
+ // `apply-reorg` is the only command in this pipeline that changes a doc's PATH, so it is the
1018
+ // only one that can break an inbound link. Both the old and the new path are in hand at the
1019
+ // moment of the move: this is an exact mechanical swap, not an inferred one — which is why it
1020
+ // is safe to do here even though the *inferred* dangling-reference lint was cut outright
1021
+ // (1/27 precision). Bounded on purpose: git-tracked text files only; never CHANGELOG.md or
1022
+ // log.md (append-only history — a record of where a file WAS is not a broken link); never the
1023
+ // pipeline's own JSON (rewriteArchivedPath owns those); never a file RESIDENT under
1024
+ // docs/archive/ (same rationale, one directory further — see isRewriteExempt below).
1025
+ const LINK_EXTS = new Set(['.md', '.js', '.cjs', '.mjs', '.json', '.yml', '.yaml']);
1026
+ const LINK_SKIP = /(^|\/)(CHANGELOG\.md|log\.md)$/;
1027
+
1028
+ // docs/archive/ exists to hold frozen originals — its whole purpose is a record of where a
1029
+ // file WAS, exactly the CHANGELOG.md/log.md rationale above, one directory further. A file
1030
+ // RESIDENT under it (REORG_DEST.archive, not a literal — one constant, so a future path
1031
+ // change can't desync this from where `apply-reorg` actually moves files) is never a rewrite
1032
+ // TARGET: not an inbound link inside it, and — the edge case that bites during a reorg, when
1033
+ // a run moves many files INTO archive in one pass — not its OWN outbound links either, so a
1034
+ // doc landing in archive comes out byte-identical to what it carried in (a pure git rename,
1035
+ // R100). This does NOT stop other files' links TO an archived path from being rewritten
1036
+ // (rewriteLinks' exact-path and relative-link passes below still walk every other file) —
1037
+ // only archive-resident files are exempt from being edited themselves.
1038
+ //
1039
+ // "Resident under archive/" alone is a moment-in-time test, and apply-reorg moves its plan's
1040
+ // rows ONE AT A TIME — MEASURED in the wild: row A (bucket product) moves first and its
1041
+ // sweep edits row B's CURRENT (pre-move) content, because B (bucket archive) is still sitting
1042
+ // at its OLD path at that instant and so reads as NOT resident yet. B then moves into archive
1043
+ // one iteration later, carrying A's edit in with it — frozen-on-arrival in name only. The fix
1044
+ // is to test where a file WILL BE by the end of THIS run, not only where it is right now:
1045
+ // plannedArchiveSrc holds the pre-move path of every row apply-reorg's plan already commits
1046
+ // to bucket:'archive', set once before its move loop starts (below). `archive` (the
1047
+ // standalone, single-file path) has no plan — it doesn't need one, since the one file it
1048
+ // moves is already covered by the resident check the instant its own git mv lands, before
1049
+ // rewriteLinks ever runs for it.
1050
+ let plannedArchiveSrc = new Set();
1051
+ // One predicate, called from the one place rewriteLinks() loops over candidate files, so the
1052
+ // exemption can never desync across callers the way moveDoc's follow-ups almost did.
1053
+ function isRewriteExempt(f) {
1054
+ return LINK_SKIP.test(f) || f.startsWith(REORG_DEST.archive + '/') || plannedArchiveSrc.has(f);
1055
+ }
1056
+
1057
+ // A real corpus (astral-sh/uv) cross-links its docs with RELATIVE paths — `../concepts/x.md`,
1058
+ // `./tools.md`, `guides/install.md` — never the repo-rooted form the exact-path match above
1059
+ // looks for. A move that only fixes repo-rooted links leaves every one of those dead. Scope
1060
+ // is deliberately narrow: only inside actual markdown link syntax (`](target)` or a
1061
+ // reference-style `]: target` definition), never bare prose — "tools.md" on its own is too
1062
+ // ambiguous to touch safely. FENCE-AWARE (via rewriteRelativeLinks -> replaceOutsideFences,
1063
+ // same mask as everywhere else in the file): a fenced code sample showing this exact syntax,
1064
+ // e.g. `](cleanAction.args)` inside a JS snippet, is source CODE, not a markdown link — this
1065
+ // used to rewrite it into a broken link target anyway (MEASURED, real: it happened inside a
1066
+ // real doc's ASCII-diagram code fence). The exact-path matcher just below shares the same
1067
+ // fence-aware treatment now, for the same reason — see its own comment.
1068
+ const INLINE_LINK_RE = /\]\(([^()\s]+)\)/g;
1069
+ const REF_LINK_RE = /^(\s{0,3}\[[^\]]+\]:[ \t]*)(\S+)/gm;
1070
+ const isRelativeTarget = t => !/^([a-z][a-z0-9+.-]*:)|^[#/]/i.test(t);
1071
+ const splitFragment = t => { const i = t.indexOf('#'); return i === -1 ? [t, ''] : [t.slice(0, i), t.slice(i)]; };
1072
+
1073
+ // Rewrites every RELATIVE markdown link target in `text` via `transform(pathPart)`, which
1074
+ // returns the new repo-relative path-part (forward-slash, no fragment) or null/unchanged to
1075
+ // leave the link alone. `./` is kept on the new target only if the ORIGINAL had it —
1076
+ // path.relative() never produces one on its own, so blindly adding it back would put a
1077
+ // prefix on links that never had one.
1078
+ function rewriteRelativeLinks(text, transform) {
1079
+ let n = 0;
1080
+ const build = (pathPart, frag) => {
1081
+ const next = transform(pathPart);
1082
+ if (next == null || next === pathPart) return null;
1083
+ n++;
1084
+ return (pathPart.startsWith('./') && !next.startsWith('.') ? './' + next : next) + frag;
1085
+ };
1086
+ let out = replaceOutsideFences(text, INLINE_LINK_RE, (full, target) => {
1087
+ if (!isRelativeTarget(target)) return full;
1088
+ const [pathPart, frag] = splitFragment(target);
1089
+ if (!pathPart) return full;
1090
+ const rewritten = build(pathPart, frag);
1091
+ return rewritten == null ? full : `](${rewritten})`;
1092
+ });
1093
+ out = replaceOutsideFences(out, REF_LINK_RE, (full, prefix, target) => {
1094
+ if (!isRelativeTarget(target)) return full;
1095
+ const [pathPart, frag] = splitFragment(target);
1096
+ if (!pathPart) return full;
1097
+ const rewritten = build(pathPart, frag);
1098
+ return rewritten == null ? full : `${prefix}${rewritten}`;
1099
+ });
1100
+ return { text: out, n };
1101
+ }
1102
+
1103
+ function rewriteLinks(oldPath, newPath) {
1104
+ const esc = oldPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1105
+ // Exact-path match. The lookbehind stops `xdocs/A.md` and `./docs/A.md` counting as this
1106
+ // path; the lookahead stops `docs/A.md.bak` and `docs/A.md-old`, while still allowing a
1107
+ // sentence-final `docs/A.md.` — a plain substring replace corrupts all four.
1108
+ //
1109
+ // Scope decision (same hazard class as INLINE_LINK_RE above, resolved the same way): this
1110
+ // is an EXACT repo-rooted path, not an inferred one, so rewriting a real path fragment
1111
+ // shown inside a fenced code sample is still often desirable (e.g. a doc's own worked
1112
+ // example literally naming `docs/A.md`). But a path fragment inside code — `require('docs/
1113
+ // A.md')`, a CLI invocation, a JSON snippet — is exactly the same hazard the relative-link
1114
+ // fix above exists to prevent: unlike a markdown link target, this regex isn't scoped to
1115
+ // link syntax at all, so it is the MORE exposed of the two, not the less. Consistency wins:
1116
+ // both passes now share the one fence-aware mechanism (replaceOutsideFences), so a fence
1117
+ // means the same thing everywhere in this file — code inside it is never a rewrite target.
1118
+ const re = new RegExp(`(?<![\\w./-])${esc}(?![\\w-]|\\.[A-Za-z0-9])`, 'g');
1119
+ // Returns what it did; prints nothing. `archive` and `apply-reorg` format their output
1120
+ // differently, and a helper that prints straight to stdout cannot be reused by both.
1121
+ const result = { total: 0, files: [], skipped: null };
1122
+ // `archive` is documented as usable STANDALONE, and doArchive already falls back to
1123
+ // copy+unlink outside a git repo — so "there is no git repo here" means there are no
1124
+ // tracked files to rewrite, which is a SKIP, not a failed follow-up. Reporting it as a
1125
+ // failure made a fully successful `archive` exit 2 and tell the user to hand-fix something
1126
+ // that had never broken. A REAL git failure inside a real repo still throws.
1127
+ let candidates;
1128
+ try {
1129
+ // TRACKED PLUS UNTRACKED-BUT-NOT-IGNORED. FIELD BUG (real, reproduced): this listed
1130
+ // tracked files only, so every page a split had just written — brand new, never added —
1131
+ // was invisible to the rewriter. Relocating the core page out of PAGES then left its
1132
+ // sibling pages pointing at a path that no longer existed. A doc on disk with a broken
1133
+ // link is broken whether or not git happens to know about it yet. `--exclude-standard`
1134
+ // keeps .gitignore'd paths (build output, vendored trees) out, same as before.
1135
+ const tracked = gitOrThrow(['ls-files'], 'listing tracked files');
1136
+ const untracked = gitOrThrow(['ls-files', '--others', '--exclude-standard'],
1137
+ 'listing untracked files');
1138
+ candidates = [...new Set((tracked + '\n' + untracked).split('\n'))];
1139
+ } catch (e) {
1140
+ if (!/not a git repository/i.test(e.message)) throw e;
1141
+ result.skipped = 'inbound links: not a git repository — nothing tracked to rewrite';
1142
+ return result;
1143
+ }
1144
+ for (const f of candidates) {
1145
+ if (!f || !LINK_EXTS.has(path.extname(f))) continue;
1146
+ if (isRewriteExempt(f) || f.startsWith('docs/.docs-builder/')) continue;
1147
+ let text;
1148
+ try { text = fs.readFileSync(repoPath(f), 'utf8'); } catch { continue; }
1149
+ let n = 0;
1150
+ let out = replaceOutsideFences(text, re, () => (n++, newPath), { spans: false });
1151
+ if (path.extname(f) === '.md') {
1152
+ let rel;
1153
+ if (f === newPath) {
1154
+ // This is the file that JUST moved. Its OWN relative links were authored to resolve
1155
+ // from its OLD directory — re-express each one from its NEW directory so it still
1156
+ // resolves to the exact same target, whether or not that target ever moves too. This
1157
+ // is what makes ordering irrelevant when a link's source AND its target both move in
1158
+ // the same apply-reorg run: whichever moves first, this keeps ITS outbound link
1159
+ // correct as of ITS OWN move, so the other file's move (whenever it happens) finds a
1160
+ // link that already resolves correctly against this file's current directory.
1161
+ const oldDir = path.posix.dirname(oldPath), newDir = path.posix.dirname(newPath);
1162
+ rel = rewriteRelativeLinks(out, pathPart =>
1163
+ path.posix.relative(newDir, path.posix.normalize(path.posix.join(oldDir, pathPart))));
1164
+ } else {
1165
+ const dir = path.posix.dirname(f);
1166
+ rel = rewriteRelativeLinks(out, pathPart => {
1167
+ const resolved = path.posix.normalize(path.posix.join(dir, pathPart));
1168
+ return resolved === oldPath ? path.posix.relative(dir, newPath) : null;
1169
+ });
1170
+ }
1171
+ out = rel.text; n += rel.n;
1172
+ }
1173
+ if (!n) continue;
1174
+ fs.writeFileSync(repoPath(f), out);
1175
+ result.files.push({ file: f, n });
1176
+ result.total += n;
1177
+ }
1178
+ return result;
1179
+ }
1180
+
1181
+ // THE single path through which a doc changes location. Every follow-up a move requires
1182
+ // lives here, and nowhere else.
1183
+ //
1184
+ // It exists because the same defect shipped three times running: a follow-up was added to
1185
+ // one caller and missed by the other. Round 1 fixed repo-vs-cwd path resolution; round 4
1186
+ // found eight more sites of it. Round 4 fixed the artifact key-sync inside `archive`, but
1187
+ // `apply-reorg` had never called it at all. Adding link rewriting to `apply-reorg` then left
1188
+ // `archive` behind in exactly the same way. The two callers differ ONLY in how they report —
1189
+ // never in what a move entails — so reporting is the parameter and the follow-up list is not.
1190
+ //
1191
+ // THROWS only if the move itself failed, in which case nothing on disk has changed. A
1192
+ // follow-up that fails is collected in `failures` instead, so it can never be mistaken for a
1193
+ // failed move: the file HAS moved, and telling the caller to retry would be wrong.
1194
+ function moveDoc(src, dest) {
1195
+ const r = doArchive(src, dest);
1196
+ const out = { ...r, artifacts: 0, artifactNotes: [], links: 0, linkFiles: [], failures: [] };
1197
+ try {
1198
+ const t = rewriteArchivedPath(src, r.rel);
1199
+ out.artifacts = t.outline + t.labels;
1200
+ out.artifactNotes = t.messages;
1201
+ } catch (e) { out.failures.push(`syncing outline/labels failed: ${e.message}`); }
1202
+ try {
1203
+ const l = rewriteLinks(src, r.rel);
1204
+ out.links = l.total; out.linkFiles = l.files;
1205
+ if (l.skipped) out.artifactNotes.push(l.skipped);
1206
+ } catch (e) { out.failures.push(`rewriting inbound links failed: ${e.message}`); }
1207
+ return out;
1208
+ }
1209
+
1210
+ // Crash-isolated closing advisory, same spirit as the config-file injection in apply-reorg
1211
+ // below: a failure here must never make a moved file look unmoved. moveDoc's `git mv`
1212
+ // STAGES the rename immediately (that's what preserves history) but nothing else in this
1213
+ // tool's output ever said so — confirmed TWICE in the wild, in two different repos, where
1214
+ // another session's `git add -A` / `git commit -a` silently absorbed the staged renames
1215
+ // into an unrelated commit. The link rewrites moveDoc also makes are UNSTAGED and touch
1216
+ // files outside docs/ too, so the two must land in ONE commit — never `-- docs` alone,
1217
+ // which would commit moved files without their repaired inbound links (a broken tree).
1218
+ //
1219
+ // FIXED 2026-08-24: this used to recommend `git add -u && git commit`. `git add -u` stages
1220
+ // EVERY tracked modification in the tree, not just this run's — the exact absorption hazard
1221
+ // this whole advisory exists to warn about, printed as the recipe. MEASURED, real: an
1222
+ // operator read it, recognised the hazard, refused the recipe and staged by explicit path
1223
+ // instead. The recipe now names only what THIS run actually touched: the moved path(s)
1224
+ // (`movedPaths` — git mv usually stages these already, but the copy+unlink fallback used
1225
+ // outside a git repo does not, so they're listed explicitly rather than assumed) plus every
1226
+ // file whose inbound links were rewritten (`linkFiles`). Skipped entirely when nothing moved:
1227
+ // no noise on a no-op re-run.
1228
+ // FIELD BUG (real, reproduced): this used to PRINT immediately, from wherever it was called
1229
+ // — so one `cleanup-apply` emitted several separate recipes (archive's, then the core-page
1230
+ // relocation's), each naming only its own step. An operator running the last one printed
1231
+ // staged the core page and silently dropped the archive move. It now ACCUMULATES across the
1232
+ // whole run and prints exactly once, from the dispatcher.
1233
+ //
1234
+ // FIELD BUG (real, reproduced): the recipe also named files at their PRE-move path — a file
1235
+ // whose inbound links were rewritten and which then moved itself was listed at a path that no
1236
+ // longer exists. `git add` is atomic: one stale pathspec makes the whole command exit 128 and
1237
+ // stage NOTHING, so following the printed recipe committed nothing at all. flush() drops
1238
+ // anything not on disk (it moved; its destination is already in `moved`).
1239
+ const RUN = { moved: [], links: [], generated: [] };
1240
+ const noteMoved = (...paths) => RUN.moved.push(...paths);
1241
+ const noteLinks = files => RUN.links.push(...files);
1242
+ // Files this run CREATED or REWROTE that no move produced — the rebuilt index, the config
1243
+ // file's pointer block, the pages a split wrote. Omitting them meant following the recipe
1244
+ // committed a reorg with no index and no pointer.
1245
+ const noteGenerated = (...paths) => RUN.generated.push(...paths);
1246
+
1247
+ function flushCommitAdvisory() {
1248
+ if (!RUN.moved.length) return;
1249
+ try {
1250
+ const onDisk = f => { try { return fs.existsSync(repoPath(f)); } catch { return false; } };
1251
+ const moveSet = Array.from(new Set(RUN.moved)).filter(onDisk);
1252
+ const linkSet = Array.from(new Set(RUN.links)).filter(onDisk);
1253
+ const genSet = Array.from(new Set(RUN.generated)).filter(onDisk);
1254
+ const allFiles = Array.from(new Set([...moveSet, ...linkSet, ...genSet]));
1255
+ // The failure mode this must break: an operator reads `git status`, sees only the
1256
+ // STAGED block (the smaller, docs-shaped half), and scopes their commit to `docs/` —
1257
+ // silently dropping every link repair outside it. A bare count doesn't fight that
1258
+ // ("35 link rewrites" still reads as "docs stuff"); naming the actual non-docs
1259
+ // locations does. Derived from moveDoc's own linkFiles paths — no hardcoded dir names.
1260
+ // Count FILES outside docs/, but list the distinct top-level LOCATIONS. Reporting the
1261
+ // location count instead understates the trap: 19 files across 6 dirs printed as "6"
1262
+ // reads as a rounding error rather than most of the change set.
1263
+ const outsideFiles = linkSet.filter(f => f.split('/')[0] !== 'docs');
1264
+ const outsideDocs = Array.from(new Set(outsideFiles.map(f => f.split('/')[0]))).sort();
1265
+ console.log(`\n${moveSet.length} rename(s) this run (git mv stages these automatically; `
1266
+ + 'the copy+unlink fallback used outside a git repo does not)');
1267
+ console.log(linkSet.length
1268
+ ? `${linkSet.length} link rewrite(s) UNSTAGED` + (outsideFiles.length
1269
+ ? `, ${outsideFiles.length} outside docs/: ${outsideDocs.join(', ')}`
1270
+ : ' (all inside docs/)')
1271
+ : 'no inbound-link rewrites this run.');
1272
+ console.log('A blanket `git add -A` / `git add -u` / `git commit -a` would ALSO absorb any');
1273
+ console.log('unrelated in-flight work in the tree — this tool never suggests one (it does');
1274
+ console.log('NOT auto-commit either: you may want these moves bundled with other work).');
1275
+ console.log('Stage exactly what this run touched (renames + link rewrites), then commit:');
1276
+ const quote = f => `'${f.replace(/'/g, `'\\''`)}'`;
1277
+ const SHOWN = 20;
1278
+ if (allFiles.length <= SHOWN) {
1279
+ console.log(` git add -- ${allFiles.map(quote).join(' ')}`);
1280
+ } else {
1281
+ const listRel = path.join(ARTIFACTS, 'commit-files.txt');
1282
+ fs.mkdirSync(path.dirname(repoPath(listRel)), { recursive: true });
1283
+ fs.writeFileSync(repoPath(listRel), allFiles.join('\n') + '\n');
1284
+ console.log(` ${allFiles.length} files touched — full list written to ${listRel}`);
1285
+ console.log(` git add -- ${allFiles.slice(0, SHOWN).map(quote).join(' ')} # + `
1286
+ + `${allFiles.length - SHOWN} more, see ${listRel}`);
1287
+ console.log(` cat ${listRel} | xargs git add --`);
1288
+ }
1289
+ console.log(' git commit -m "docs: reorg"');
1290
+ } catch (e) {
1291
+ console.error(` WARN could not print the commit advisory: ${e.message}`);
1292
+ }
1293
+ }
1294
+
1295
+ // Core THROWS on a follow-up failure, never exits (same split as doArchive()/archive() and
1296
+ // gitOrThrow()/git()). cleanupApply() calls this IN-PROCESS and still has work to do after it
1297
+ // — relocating the core page and rebuilding docs/index.md — so it must be able to catch the
1298
+ // failure and say what its own run is skipping. A bare process.exit(2) in here killed that
1299
+ // run mid-flight and printed only archive's message, naming neither of the two skipped steps.
1300
+ function archiveOrThrow(src, dest) {
1301
+ // A throw and a `failures` entry mean different things and must be reported differently:
1302
+ // a throw means NOTHING moved and retrying is correct; a failure means the file DID move
1303
+ // and telling the user to re-run `archive` would be actively wrong. MEASURED: a malformed
1304
+ // outline.json used to crash here with a bare stack trace naming neither the artifact nor
1305
+ // the fact that the file had already moved.
1306
+ // Move failure propagates as a plain throw (no `followUpFailed`): NOTHING moved, retrying
1307
+ // is correct. archive() below turns it back into die()'s clean exit-1 message for the CLI.
1308
+ const r = moveDoc(src, dest);
1309
+ console.log(`archived ${src} -> ${r.rel} ${r.size} bytes sha256 ${r.sha.slice(0, 16)} MATCH (${r.how})`);
1310
+ for (const m of r.artifactNotes) console.log(` ${m}`);
1311
+ for (const { file, n } of r.linkFiles) console.log(` ${file}: ${n} link(s) -> ${r.rel}`);
1312
+ // Logged BEFORE the exit-2 branch below: the move itself SUCCEEDED in both branches, and
1313
+ // docs/log.md is the record of what moved. Logging only on the clean path left the one case
1314
+ // a human most needs to find later — a move whose follow-up failed — absent from the log.
1315
+ logOp('archive', `${src} -> ${r.rel}`
1316
+ + (r.links ? `, ${r.links} link(s) rewritten` : '')
1317
+ + (r.failures.length ? `, FOLLOW-UP FAILED: ${r.failures.join('; ')}` : ''));
1318
+ noteMoved(r.rel); noteLinks(r.linkFiles.map(x => x.file));
1319
+ if (r.failures.length) {
1320
+ const e = new Error(`the move above SUCCEEDED — ${src} is now at ${r.rel}. But ${r.failures.join('; ')}\n`
1321
+ + `Fix that, then re-run \`scan\` (and redo labels) — do NOT re-run \`archive\` for `
1322
+ + `${src}, it has already moved.`);
1323
+ e.followUpFailed = true; // the move LANDED; only a follow-up failed. See archive() below.
1324
+ throw e;
1325
+ }
1326
+ }
1327
+
1328
+ function archive(src, dest) {
1329
+ if (!src) die('usage: docs-builder.cjs archive <src.md> [dest.md]');
1330
+ try { archiveOrThrow(src, dest); }
1331
+ catch (e) {
1332
+ if (!e.followUpFailed) die(e.message); // nothing moved — exit 1, retry `archive`
1333
+ // Exit 2, not 1: 1 means "nothing moved, retry `archive`" and this is the OPPOSITE —
1334
+ // the file DID move and re-running `archive` would be wrong, exactly as the message says.
1335
+ // A caller branching on exit code alone must be able to tell these two outcomes apart.
1336
+ console.error(e.message);
1337
+ flushCommitAdvisory();
1338
+ process.exit(2);
1339
+ }
1340
+ }
1341
+
1342
+ // ---------------------------------------------------------------- ledger + due
1343
+
1344
+ // git IS the diff engine. The ledger stores only what git cannot: WHEN we last
1345
+ // consolidated. Everything else -- new / moved / changed-and-by-how-much / deleted --
1346
+ // is derived from `git diff -M`, so it can never drift out of sync with the tree.
1347
+ const LEDGER = 'docs/.docs-builder/ledger.json'; // == path.join(ARTIFACTS,'ledger.json')
1348
+ const DUE_THRESHOLD = 5;
1349
+
1350
+ // One guarded entry point for git. Two things it must never do: dump a Node stack trace at
1351
+ // the user, and truncate on a large repo (execFileSync defaults to a 1 MB buffer, which
1352
+ // `ls-files` can exceed on a big tree).
1353
+ // Core THROWS; the wrapper below turns that into process.exit for top-level callers. The
1354
+ // split is load-bearing, not tidiness: `die` runs process.exit, which NO try/catch can
1355
+ // intercept. A caller that must SURVIVE a git failure — rewriteLinks, running mid-loop in
1356
+ // `apply-reorg` after files have already moved — has to call gitOrThrow, or one bad git
1357
+ // invocation kills the run partway through and prints neither a summary nor a log line.
1358
+ function gitOrThrow(args, what) {
1359
+ try {
1360
+ return execFileSync('git', ['-C', REPO, ...args],
1361
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
1362
+ } catch (e) {
1363
+ const msg = (e.stderr || '').toString().trim().split('\n')[0] || e.message;
1364
+ throw new Error(`git failed while ${what}: ${msg}`);
1365
+ }
1366
+ }
1367
+ function git(args, what) {
1368
+ try { return gitOrThrow(args, what); }
1369
+ catch (e) { die(e.message); }
1370
+ }
1371
+
1372
+ function docFiles() {
1373
+ return git(['ls-files', 'docs/'], 'listing tracked docs').split('\n')
1374
+ .filter(f => f.endsWith('.md') && !f.startsWith('docs/.docs-builder/'));
1375
+ }
1376
+
1377
+ function ledger() {
1378
+ const head = git(['rev-parse', 'HEAD'], 'reading HEAD (is this a git repo?)');
1379
+ const docs = docFiles().map(f => ({
1380
+ path: f,
1381
+ lines: read(f).split('\n').length,
1382
+ sha256: sha(path.join(REPO, f)).slice(0, 16)
1383
+ }));
1384
+ const out = { sha: head, at: new Date().toISOString(),
1385
+ docs: docs.sort((a, b) => a.path.localeCompare(b.path)) };
1386
+ const dest = path.join(REPO, process.env.OUT || LEDGER);
1387
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1388
+ fs.writeFileSync(dest, JSON.stringify(out, null, 1));
1389
+ console.log(`ledger: ${docs.length} docs / ${docs.reduce((a, d) => a + d.lines, 0)} lines @ ${head.slice(0, 8)}`);
1390
+ }
1391
+
1392
+ function due() {
1393
+ const f = path.join(REPO, process.env.OUT || LEDGER);
1394
+ if (!fs.existsSync(f)) {
1395
+ console.log('no ledger yet — run `docs-builder.cjs ledger` to start tracking. NOT due.');
1396
+ return;
1397
+ }
1398
+ const L = parseJSONFile(f);
1399
+ const known = new Map(L.docs.map(d => [d.path, d]));
1400
+ // -M turns a delete+add pair into a rename, which is what makes "moved" distinguishable
1401
+ // from "deleted and rewritten". Without it every move looks like a total rewrite.
1402
+ // A rebase, amend or GC can leave the stamped SHA unreachable. That is a re-stamp
1403
+ // situation, not a crash.
1404
+ try {
1405
+ execFileSync('git', ['-C', REPO, 'cat-file', '-e', `${L.sha}^{commit}`], { stdio: 'ignore' });
1406
+ } catch {
1407
+ die(`ledger SHA ${L.sha.slice(0, 8)} is not in this repository (rebased, amended or \n`
1408
+ + 'garbage-collected). Re-stamp with `docs-builder.cjs ledger`.');
1409
+ }
1410
+ const raw = git(['diff', '--numstat', '-M', `${L.sha}..HEAD`, '--', 'docs/'],
1411
+ 'diffing docs against the ledger SHA');
1412
+ const rows = [], accountedFor = new Set();
1413
+ for (const line of raw ? raw.split('\n') : []) {
1414
+ const [add, del, ...pathBits] = line.split('\t');
1415
+ const p = pathBits.join('\t');
1416
+ // Parse the rename form FIRST. git writes it as `docs/{old.md => new.md}`, which does
1417
+ // not end in `.md` — filtering on the raw path silently drops every move.
1418
+ const ren = p.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
1419
+ const was = ren ? `${ren[1]}${ren[2]}${ren[4]}`
1420
+ : p.includes(' => ') ? p.split(' => ')[0] : null;
1421
+ const now = ren ? `${ren[1]}${ren[3]}${ren[4]}`
1422
+ : p.includes(' => ') ? p.split(' => ')[1] : p;
1423
+ if (!now.endsWith('.md') || now.startsWith('docs/.docs-builder/')) continue;
1424
+ if (was) accountedFor.add(was);
1425
+ accountedFor.add(now);
1426
+ const a = add === '-' ? null : +add, d = del === '-' ? null : +del;
1427
+ const prev = known.get(was || now);
1428
+ const gone = !fs.existsSync(path.join(REPO, now));
1429
+ let kind, detail;
1430
+ if (gone) { kind = 'deleted'; detail = prev ? `was ${prev.lines} lines` : ''; }
1431
+ else if (a === null) { kind = 'binary'; detail = ''; }
1432
+ else if (was && !a && !d) { kind = 'moved'; detail = `from ${was}`; }
1433
+ else if (was) { kind = 'moved+changed'; detail = `from ${was}, +${a}/-${d}`; }
1434
+ else if (!prev) { kind = 'new'; detail = `${a} lines`; }
1435
+ else {
1436
+ kind = 'changed';
1437
+ const pct = prev.lines ? Math.round((a + d) / prev.lines * 100) : 0;
1438
+ detail = `+${a}/-${d} of ${prev.lines} lines (~${pct}%)`;
1439
+ }
1440
+ rows.push({ doc: now, kind, detail });
1441
+ }
1442
+ // A doc the ledger knew, gone from the tree, and not explained by any diff row above.
1443
+ for (const g of L.docs)
1444
+ if (!accountedFor.has(g.path) && !fs.existsSync(path.join(REPO, g.path)))
1445
+ rows.push({ doc: g.path, kind: 'deleted', detail: `was ${g.lines} lines` });
1446
+
1447
+ if (!rows.length) { console.log(`docs unchanged since ${L.sha.slice(0, 8)}. NOT due.`); return; }
1448
+ console.table(rows);
1449
+ const n = rows.length;
1450
+ console.log(n >= DUE_THRESHOLD
1451
+ ? `${n} docs changed since ${L.sha.slice(0, 8)} (threshold ${DUE_THRESHOLD}) — REORG IS DUE.`
1452
+ : `${n} doc(s) changed since ${L.sha.slice(0, 8)} (threshold ${DUE_THRESHOLD}). Not due yet.`);
1453
+ }
1454
+
1455
+ // ---------------------------------------------------------------- lint (declared only)
1456
+
1457
+ // Every term here is something a doc SAYS ABOUT ITSELF. Nothing is inferred from
1458
+ // similarity. MEASURED: declared 100% precision, inferred 4-25%.
1459
+ // `invalidat\w*` was REMOVED after it matched the ordinary heading "Cache Invalidation"
1460
+ // 3x in a second repo — 3 false positives to buy 2 true ones. Precision over recall.
1461
+ const SUP = /\b(recurred|superseded|supersedes|withdrawn|retracted|refuted|obsolete|replaced by|deprecat\w*|was wrong|turned out to be false)\b/i;
1462
+ const ID_ANY = /\b([A-Z]{1,4}\d{1,4})\b/g;
1463
+
1464
+ function sentences(t) {
1465
+ return stripFences(t).replace(/\|/g, ' ')
1466
+ .split(/(?<=[.!?])\s+|\n\s*\n/)
1467
+ .map(x => x.replace(/[*_`>#-]/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase())
1468
+ .filter(x => x.length >= 80);
1469
+ }
1470
+
1471
+ function lint(files) {
1472
+ if (!files.length) die('usage: docs-builder.cjs lint <file.md...>');
1473
+ const sections = [];
1474
+ for (const f of files) {
1475
+ const lines = read(f).split('\n');
1476
+ const mask = fenceMask(lines);
1477
+ let cur = null;
1478
+ const close = i => { if (cur) { cur.e = i; cur.body = lines.slice(cur.s, i).join('\n'); } };
1479
+ lines.forEach((ln, i) => {
1480
+ if (mask[i]) return;
1481
+ const m = ln.match(/^(#{1,3})\s+(.*)$/);
1482
+ if (!m) return;
1483
+ close(i);
1484
+ const idm = m[2].match(ID_RE);
1485
+ cur = { file: f, heading: clean(m[2]), id: idm ? idm[1] : null,
1486
+ qid: idm ? f + '#' + idm[1] : null, s: i + 1, e: null, body: '' };
1487
+ sections.push(cur);
1488
+ });
1489
+ close(lines.length);
1490
+ }
1491
+
1492
+ // (1) supersession the doc declares about itself — heading grain is the shippable one
1493
+ const supersession = sections.filter(s => SUP.test(s.heading))
1494
+ .map(s => ({ file: s.file, line: s.s, heading: s.heading.slice(0, 160) }));
1495
+ const supersessionInBody = sections.filter(s => !SUP.test(s.heading) && SUP.test(s.body)).length;
1496
+
1497
+ // (2) UNCITED — MUST be repo-wide. Scoped to the doc corpus it proposes deleting live
1498
+ // docs (measured: 2 false flags, both cited from a logs file and CHANGELOG.md).
1499
+ // Called "uncited", not "orphan", on purpose: uncited is a FACT, deletable is a
1500
+ // JUDGEMENT. bareloop's O2/O3/O4 are genuinely uncited and must NOT be removed —
1501
+ // they are the middle of a coherent O1-O5 series whose O1 is cited. PROPOSE ONLY.
1502
+ let repoFiles = [], uncited = [];
1503
+ try {
1504
+ repoFiles = execFileSync('git', ['-C', REPO, 'ls-files'], { encoding: 'utf8' })
1505
+ .split('\n').filter(Boolean);
1506
+ } catch { console.error('WARN: git ls-files failed — uncited check DISABLED (loud, not silent)'); }
1507
+ if (repoFiles.length) {
1508
+ const inbound = new Set();
1509
+ const own = new Set(files);
1510
+ for (const f of repoFiles) {
1511
+ if (own.has(f)) continue;
1512
+ let txt; try { txt = read(f); } catch { continue; }
1513
+ for (const m of txt.matchAll(ID_ANY)) inbound.add(m[1]);
1514
+ }
1515
+ const defined = sections.filter(s => s.id);
1516
+ for (const s of defined) {
1517
+ const citedInCorpus = sections.some(o => o !== s && new RegExp(`\\b${s.id}\\b`).test(o.body));
1518
+ if (!citedInCorpus && !inbound.has(s.id))
1519
+ uncited.push({ file: s.file, line: s.s, id: s.id, heading: s.heading.slice(0, 120) });
1520
+ }
1521
+ }
1522
+
1523
+ // (3) redundancy — shared VERBATIM sentences. 1/4 precision: PROPOSE ONLY, never act.
1524
+ const sentMap = {};
1525
+ sections.forEach((s, si) => {
1526
+ for (const sent of new Set(sentences(s.body))) {
1527
+ const h = crypto.createHash('sha1').update(sent).digest('hex').slice(0, 12);
1528
+ (sentMap[h] = sentMap[h] || { text: sent, at: [] }).at.push(si);
1529
+ }
1530
+ });
1531
+ const pairs = {};
1532
+ for (const h of Object.keys(sentMap)) {
1533
+ const at = [...new Set(sentMap[h].at)];
1534
+ if (at.length < 2) continue;
1535
+ for (let i = 0; i < at.length; i++) for (let j = i + 1; j < at.length; j++) {
1536
+ const k = at[i] + '|' + at[j];
1537
+ (pairs[k] = pairs[k] || { n: 0, chars: 0, sample: '' }).n++;
1538
+ pairs[k].chars += sentMap[h].text.length;
1539
+ if (!pairs[k].sample) pairs[k].sample = sentMap[h].text.slice(0, 150);
1540
+ }
1541
+ }
1542
+ const redundant = Object.entries(pairs).map(([k, v]) => {
1543
+ const [i, j] = k.split('|').map(Number);
1544
+ return { sharedSentences: v.n, sharedChars: v.chars, sample: v.sample,
1545
+ a: { f: sections[i].file, l: sections[i].s, h: sections[i].heading.slice(0, 80) },
1546
+ b: { f: sections[j].file, l: sections[j].s, h: sections[j].heading.slice(0, 80) } };
1547
+ }).sort((x, y) => y.sharedChars - x.sharedChars).slice(0, 40);
1548
+
1549
+ const out = {
1550
+ generated: new Date().toISOString(), repo: REPO, files,
1551
+ totals: { sections: sections.length, ids: sections.filter(s => s.id).length,
1552
+ repoFilesScanned: repoFiles.length },
1553
+ supersession, supersessionInBody, uncited, redundant,
1554
+ note: 'Every flag is a PROPOSAL, never an action. Only `supersession` is high enough '
1555
+ + 'precision to act on unreviewed (24/24 across 4 repos). `uncited` and `redundant` '
1556
+ + 'are surfaced for a human. Record confirmation in the file\'s own `verified:` frontmatter.'
1557
+ };
1558
+ write(out, 'lint.json');
1559
+ console.log(JSON.stringify({ ...out.totals, supersession: supersession.length,
1560
+ supersessionInBody, uncited: uncited.length, redundantPairs: redundant.length }, null, 1));
1561
+ }
1562
+
1563
+ // ---------------------------------------------------------------- discover + apply-reorg
1564
+ //
1565
+ // Mode 0: full-corpus reorg. v1 (the old skill) did this whole job by handing an agent a
1566
+ // file list and a prose rulebook ("KEEP/CONSOLIDATE/ARCHIVE", "when uncertain -> ARCHIVE")
1567
+ // and letting it read, judge and `mv` everything itself — the exact shape that measured
1568
+ // 27% correct on bookkeeping elsewhere in this pipeline. This does the same JOB with the
1569
+ // same discipline as the rest of the file: classification is mechanical and script-run;
1570
+ // only genuinely unclear cases are surfaced, never silently decided; nothing moves until
1571
+ // a human (or a caller) looks at the plan and asks for `apply-reorg`.
1572
+ //
1573
+ // NOT rebuilt here: v1's CONSOLIDATE (merge two docs' content into one). That is a content
1574
+ // rewrite, not a move — a different, higher-risk operation than anything measured so far.
1575
+ // Descoped on purpose, not dropped silently.
1576
+
1577
+ // Filename hints alone are WEAK — kept only because v1 used them and they don't false-flag
1578
+ // on real data (checked against 40 files across 4 repos). The dated-filename rule from v1
1579
+ // ("2024-01-15-x.md is stale") was tested and DROPPED: on a real corpus, dated filenames are
1580
+ // how current, un-stale design docs are named (`2026-07-28-p-palette-design.md`), so that
1581
+ // signal alone would file live specs into archive/. A dated name proves nothing about
1582
+ // staleness on its own.
1583
+ const ARCHIVE_FILENAME_RE = /^(REPORT|STATUS|SUMMARY|FIX_|PHASE_|SPRINT_|DRAFT|WIP|OLD|TEMP)[-_]/i;
1584
+ const ARCHIVE_PATH_RE = /(^|\/)(archive|old|reports?|phases?)\//i;
1585
+
1586
+ // STRONG signal: the doc says about ITSELF, in its own opening, that it is done.
1587
+ //
1588
+ // MEASURED, not assumed, and case-sensitivity is load-bearing. A case-INsensitive version
1589
+ // of this regex was tried first against a real, uncrafted corpus (bareloop's docs/) and
1590
+ // false-positived on real files: "Supersedes **nothing**" (negation), "this rung BUILDS
1591
+ // three frozen records" (describing an input, not itself), "archived spines" (data the doc
1592
+ // references, not the doc). Same failure species as the lint fix in §10 — a word that means
1593
+ // one thing in isolation matches unrelated prose. Restricting to the ALL-CAPS form fixes
1594
+ // every one of those, because this corpus's own convention (independently, not designed
1595
+ // around) SHOUTS a genuine status declaration — "Status: CLOSED", "(ARCHIVAL 2026-07-25,
1596
+ // before any number)" — while narrative mentions of the same word stay lowercase or Title
1597
+ // Case. FROZEN was in this list once and got dropped 2026-08-23: on bareloop's real corpus
1598
+ // (37 files) it caused ~10 of 12 archive calls to be false positives — in that corpus's own
1599
+ // convention FROZEN means "locked, do not edit, still current" (a live spec or
1600
+ // pre-registration), not "retired". That's the one failure this design promised never to
1601
+ // make, so the word is gone with no replacement heuristic — precision over recall.
1602
+ const ARCHIVAL_STATUS_RE = /\b(CLOSED|ARCHIVAL|ARCHIVED|SUPERSEDED|WITHDRAWN|RETRACTED|REFUTED|DEPRECATED)\b/;
1603
+
1604
+ // v3 reorg (docs-builder-v3-spec.md, "four buckets"): a fourth mechanical prior, `logs`.
1605
+ // Measured on bareloop's real product/ (27 files): 11 were experiment records (8 *-PREREG,
1606
+ // 2 *-LEARNINGS, others) sitting alongside 14 actual specs/designs — 41% of the bucket was
1607
+ // run history, not product. Same discipline as ARCHIVE_FILENAME_RE: case-sensitive, so a
1608
+ // SHOUTED token in the filename is a real author signal and lowercase prose elsewhere is
1609
+ // not. Unanchored (word-boundary, not prefix) — real filenames carry the token as a suffix
1610
+ // (`REUSE-PREPROBE-PREREG.md`), not always a prefix.
1611
+ const LOGS_FILENAME_RE = /\b(PREREG|LEARNINGS|REPORT|RESULTS|POSTMORTEM|RETRO)\b/;
1612
+
1613
+ // Never reorged, at ANY depth: the repo's entry-point/contract docs. Moving a README or a
1614
+ // CLAUDE.md into archive/ breaks the thing every human and agent reads first. Bare LICENSE /
1615
+ // NOTICE have no .md extension and are already excluded by walkMd's extension filter.
1616
+ const PROTECTED_NAMES = new Set([
1617
+ 'README.md', 'index.md', 'log.md',
1618
+ 'CHANGELOG.md', 'LICENSE.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'SECURITY.md',
1619
+ 'CLAUDE.md', 'AGENTS.md', 'AGENT.md',
1620
+ ]);
1621
+
1622
+ const DEFAULT_OVERSIZED_LINES = 500; // a starting default, UNMEASURED — see docs-builder.md
1623
+
1624
+ // A no-H1 file is not always an unknown doc: uv's real `docs/reference/contributing.md` is
1625
+ // two lines — `--8<-- "CONTRIBUTING.md"` (an mkdocs snippet include) — a live pointer, not
1626
+ // prose. Narrow on purpose, same precision-over-recall law as the rest of this classifier:
1627
+ // only a file whose ENTIRE non-blank content is 1-3 lines, and every one of those lines is
1628
+ // itself an include directive or a markdown link, counts. Anything else with no H1 — real
1629
+ // unclassifiable prose — still falls through to `review`.
1630
+ const INCLUDE_DIRECTIVE_RE = /^(-{2,}8<-{2,}|\{%\s*include\b|\{\{.*\}\}|<!--\s*include\b)/i;
1631
+ const MD_LINK_LINE_RE = /^\[[^\]]*\]\([^)]+\)$/;
1632
+ function isIncludeStub(lines) {
1633
+ const nonBlank = lines.map(l => l.trim()).filter(Boolean);
1634
+ if (!nonBlank.length || nonBlank.length > 3) return false;
1635
+ return nonBlank.every(l => INCLUDE_DIRECTIVE_RE.test(l) || MD_LINK_LINE_RE.test(l));
1636
+ }
1637
+
1638
+ // v3 reorg (docs-builder-v3-spec.md, "four buckets, and the model does the sorting"): this
1639
+ // no longer JUDGES — it ENRICHES and PROPOSES. `bucket` is gone from this function's output;
1640
+ // callers get `suggested`+`reason` (a prior the interview shows the model, never an
1641
+ // authority) plus `h1`/`snip` (reusing headings()/snippet()/fenceMask(), the same shared
1642
+ // parsers scan() already uses — no second extraction path) and `oversized` as a plain
1643
+ // boolean. Size used to BE a bucket (`oversized`), which left a file in a third state the
1644
+ // layout had no home for — LAYERS.md (958 lines) sat unsorted in 01-product/ for no reason
1645
+ // but its size. Oversized is now orthogonal to sorting: a product doc that's too big is
1646
+ // still a product doc.
1647
+ function classifyDoc(rel, text) {
1648
+ const lines = text.split('\n');
1649
+ const mask = fenceMask(lines);
1650
+ const { h1 } = headings(lines, mask);
1651
+ const snip = snippet(lines, mask, 0, lines.length, 200);
1652
+ const opening = lines.slice(0, 20).join(' ').slice(0, 2000);
1653
+ const ceiling = +process.env.OVERSIZED_LINES || DEFAULT_OVERSIZED_LINES;
1654
+ const oversized = lines.length > ceiling;
1655
+ const row = (suggested, reason) =>
1656
+ ({ file: rel, h1, snip, lines: lines.length, oversized, suggested, reason });
1657
+
1658
+ if (ARCHIVE_PATH_RE.test(rel))
1659
+ return row('archive', 'path already under archive/old/reports/phases');
1660
+ if (ARCHIVAL_STATUS_RE.test(opening))
1661
+ return row('archive', 'doc declares its own status in the opening (e.g. CLOSED, ARCHIVED, deprecated)');
1662
+ if (ARCHIVE_FILENAME_RE.test(path.basename(rel)))
1663
+ return row('archive', 'filename matches an archive-shaped pattern (weak signal, no content confirmation)');
1664
+ if (LOGS_FILENAME_RE.test(path.basename(rel)))
1665
+ return row('logs', 'filename matches an experiment-record pattern (PREREG/LEARNINGS/REPORT/RESULTS/POSTMORTEM/RETRO) — weak signal, no content confirmation');
1666
+ if (!h1) {
1667
+ if (isIncludeStub(lines))
1668
+ return row('product', 'include stub');
1669
+ // v3: `review` is gone as a bucket. A no-H1 file with no strong signal is not special —
1670
+ // it's just a row with an empty h1 the interview classifies like any other, same as
1671
+ // every row. The old special-casing defaulted this straight to archive in apply-reorg;
1672
+ // that default is gone with it — nothing moves until the interview says so.
1673
+ return row('product', 'no H1 — no strong signal, model decides');
1674
+ }
1675
+ return row('product', 'structured (has an H1), no archive/logs signal');
1676
+ }
1677
+
1678
+ function walkMd(dir, base, out) {
1679
+ for (const name of fs.readdirSync(dir, { withFileTypes: true })) {
1680
+ const abs = path.join(dir, name.name), rel = path.join(base, name.name);
1681
+ if (name.isDirectory()) {
1682
+ // Idempotent: never reclassify what discover/apply already placed. Any dot-dir is
1683
+ // machine/tool state (.git, .github, .claude, .factory, .opencode, .amp, .docs-builder)
1684
+ // and node_modules is vendored — moving a .md out of those is never wanted.
1685
+ if (name.name.startsWith('.') || name.name === 'node_modules') continue;
1686
+ if (['wiki', 'archive', 'product', 'logs'].includes(name.name)) continue;
1687
+ walkMd(abs, rel, out);
1688
+ } else if (name.isFile() && name.name.endsWith('.md')) {
1689
+ // Entry-point/contract docs are never subject to reorg, wherever they sit.
1690
+ if (PROTECTED_NAMES.has(name.name)) continue;
1691
+ out.push(rel);
1692
+ }
1693
+ }
1694
+ }
1695
+
1696
+ // v3: `discover` no longer classifies — it enriches and PROPOSES, then stops. Each row gets
1697
+ // `suggested`+`reason` (classifyDoc's mechanical prior) and an empty `bucket` for the
1698
+ // classification interview (docs-builder.md) to fill: feed the model this whole plan table
1699
+ // in one call, get a bucket+reason per row, show the user the result via AskUserQuestion,
1700
+ // only then run `apply-reorg`. Nothing here moves a file — same guarantee as before, now
1701
+ // enforced by apply-reorg refusing an empty bucket rather than by this function's caution.
1702
+ //
1703
+ // Re-running discover MUST NOT re-litigate a decision the interview already made — `reorg`
1704
+ // (the composed front door) calls discover() every time it runs, and its own contract is
1705
+ // "STOP if any bucket is empty, else apply". If discover blanked `bucket` on every call,
1706
+ // that second half could never be reached: the interview fills the plan, then the very next
1707
+ // `reorg` invocation would discover() its way right back to all-empty. So an existing plan's
1708
+ // already-classified rows carry their `bucket` forward for any file discover still sees —
1709
+ // discover's job is keeping the plan CURRENT (fresh suggested/h1/snip/lines), not re-asking a
1710
+ // question that's already been answered. Only a file discover has never classified before
1711
+ // (new, or reappeared after a manual revert) starts unclassified, same as day one. Carry-forward
1712
+ // only accepts a currently-VALID bucket — a legacy pre-v3 value (e.g. 'oversized', 'review')
1713
+ // is dropped, not carried, so it starts unclassified instead of failing apply-reorg's schema check.
1714
+ function discover(root) {
1715
+ const rootRel = root || 'docs';
1716
+ const rootAbs = path.join(REPO, rootRel);
1717
+ if (!fs.existsSync(rootAbs)) die(`no such directory: ${rootRel}`);
1718
+ const files = [];
1719
+ walkMd(rootAbs, rootRel, files);
1720
+ const planFile = path.join(ARTIFACTS, 'reorg-plan.json');
1721
+ const prevBuckets = new Map();
1722
+ if (fs.existsSync(planFile)) {
1723
+ try {
1724
+ const prev = parseJSONFileOrThrow(planFile);
1725
+ for (const row of (prev.rows || [])) if (VALID_BUCKETS.has(row.bucket)) prevBuckets.set(row.file, row.bucket);
1726
+ } catch (e) {
1727
+ console.error(`WARN: could not read the existing plan to preserve prior classifications `
1728
+ + `(${e.message}) — every row starts unclassified this run.`);
1729
+ }
1730
+ }
1731
+ const rows = files.map(rel =>
1732
+ ({ ...classifyDoc(rel, read(rel)), bucket: prevBuckets.get(rel) || '' }));
1733
+ const bySuggested = { product: 0, logs: 0, archive: 0 };
1734
+ for (const r of rows) bySuggested[r.suggested]++;
1735
+ const oversizedCount = rows.filter(r => r.oversized).length;
1736
+ write({ generated: new Date().toISOString(), root: rootRel, rows }, 'reorg-plan.json');
1737
+ // `bucket` is shown because it is the one column that says whether the interview has
1738
+ // happened. FIELD BUG (privcloud): without it, an operator who had just written buckets into
1739
+ // the plan had no confirmation from the table that their writes landed, and went back to
1740
+ // re-read the JSON by hand.
1741
+ console.table(rows.map(r => ({ file: r.file, h1: r.h1, suggested: r.suggested,
1742
+ bucket: r.bucket || '—', oversized: r.oversized, lines: r.lines })));
1743
+ console.log(JSON.stringify({ ...bySuggested, oversized: oversizedCount }, null, 1));
1744
+ // FIELD BUG (privcloud, real first run): this used to assert "`bucket` is empty"
1745
+ // unconditionally — including on a re-run where the buckets were filled and `apply-reorg`
1746
+ // then ran fine seconds later. Output that contradicts the state it just wrote is worse
1747
+ // than no output: it sent the operator back to read the JSON by hand to check their own
1748
+ // writes had persisted. Carry-forward (above) is exactly why a re-run can arrive here with
1749
+ // buckets already set, so this has to report what is actually in the plan.
1750
+ const filled = rows.filter(r => r.bucket).length;
1751
+ if (!filled) {
1752
+ console.log(`plan written to docs/.docs-builder/reorg-plan.json — every row's \`suggested\` `
1753
+ + 'is a PRIOR, not a verdict, and `bucket` is empty. Run the classification interview '
1754
+ + '(docs-builder.md): feed the model the plan, get bucket+reason per row, get the user\'s '
1755
+ + 'approval, then run `apply-reorg` — it refuses to run while any `bucket` is empty.');
1756
+ } else if (filled === rows.length) {
1757
+ console.log(`plan written to docs/.docs-builder/reorg-plan.json — all ${rows.length} row(s) `
1758
+ + 'already carry a `bucket`, carried forward from an earlier interview. Nothing further is '
1759
+ + 'needed before `apply-reorg`; edit the plan first only if you want to reclassify.');
1760
+ } else {
1761
+ console.log(`plan written to docs/.docs-builder/reorg-plan.json — ${filled} of ${rows.length} `
1762
+ + 'row(s) already carry a `bucket` (carried forward); the rest are still unclassified and '
1763
+ + '`apply-reorg` refuses to run while any is empty. Run the classification interview '
1764
+ + '(docs-builder.md) for the remainder, then re-run.');
1765
+ }
1766
+ if (oversizedCount)
1767
+ console.log(`\n${oversizedCount} file(s) are oversized — they still get sorted into a `
1768
+ + 'bucket like everything else; splitting stays separate and opt-in (`cleanup <file>` '
1769
+ + 'after they\'ve moved).');
1770
+ }
1771
+
1772
+ // MEASURED, real (bareloop, 37 docs): outline.json — the database `search` reads — held
1773
+ // records for only 12 files, because `scan` had only ever run over whatever a caller happened
1774
+ // to hand it (the files bound for a split). All 24 docs/product/ files had ZERO records, so
1775
+ // `search` was structurally blind to every one of them — not a ranking problem, a coverage
1776
+ // problem: a file with no records at all cannot rank.
1777
+ //
1778
+ // Fix, round 1: `apply-reorg` runs `scan` itself, once, over the WHOLE corpus, right after the
1779
+ // move — not before (moving changes paths, not content, so a pre-move scan would just be
1780
+ // redone) and not partially (scanning only the split-bound files IS the bug). Round 1 walked
1781
+ // only docs/product/ and docs/archive/ (the dirs apply-reorg moves files INTO) and that was
1782
+ // still incomplete: apply-reorg deliberately leaves `oversized` docs exactly where discover
1783
+ // found them — splitting spends model budget and must never fire unprompted — so on bareloop's
1784
+ // real corpus the 12 biggest, most-cited docs (PRD.md, FINDINGS.md, LAYERS.md, all oversized,
1785
+ // all left in place under their original subdirs) still had zero outline records. "12 of 37
1786
+ // searchable" narrowed to "24 of 37 searchable" — same bug, smaller miss.
1787
+ //
1788
+ // Fix, round 2: reuse discover's own walk. walkMd(docsRoot, 'docs', files) covers every file
1789
+ // discover would have classified, WHEREVER it now lives — including an oversized doc still
1790
+ // sitting at its pre-move path — because walkMd's per-child skip only fires on a directory
1791
+ // literally named 'wiki'/'archive'/'product', so this call never descends into docs/product/
1792
+ // or docs/archive/ (no duplicates) while still reaching every other subdir (the in-place
1793
+ // oversized files). The two explicit calls below then add back exactly what that root walk
1794
+ // skipped: the contents of docs/product/ and docs/archive/ themselves. Together the three
1795
+ // calls are a complete, non-overlapping partition of "every doc discover would classify, at
1796
+ // its final location" — not a fourth, independent enumeration.
1797
+ // Shared by `scan` (via scanWholeCorpus) and `index-flat`: the one partition of "every doc
1798
+ // discover would classify, at its final location" — product/, archive/, and anything left
1799
+ // in place elsewhere (e.g. an oversized doc apply-reorg deliberately didn't move). Do not
1800
+ // add a second, independent enumeration of the corpus — this is the one.
1801
+ // v3: `logs/` joins `product/` and `archive/` as a fourth explicit call, same reasoning —
1802
+ // walkMd's root walk skips it by literal name, so this adds back exactly what that skip left out.
1803
+ function wholeCorpusFiles() {
1804
+ const files = [];
1805
+ const docsRoot = path.join(REPO, 'docs');
1806
+ if (fs.existsSync(docsRoot)) walkMd(docsRoot, 'docs', files);
1807
+ const productDir = path.join(REPO, 'docs/product');
1808
+ const archiveDir = path.join(REPO, 'docs/archive');
1809
+ const logsDir = path.join(REPO, 'docs/logs');
1810
+ if (fs.existsSync(productDir)) walkMd(productDir, 'docs/product', files);
1811
+ if (fs.existsSync(archiveDir)) walkMd(archiveDir, 'docs/archive', files);
1812
+ if (fs.existsSync(logsDir)) walkMd(logsDir, 'docs/logs', files);
1813
+ // Defensive, same exclusion reconcile() already applies: a non-default PAGES dir nested
1814
+ // under product/ or archive/ (not caught by walkMd's bare 'wiki' name check) still must not
1815
+ // round-trip generated pages back into the outline.
1816
+ const pagesPrefix = (process.env.PAGES || 'docs/wiki').replace(/\/*$/, '/');
1817
+ return files.filter(f => !f.startsWith(pagesPrefix)).sort();
1818
+ }
1819
+
1820
+ function scanWholeCorpus() {
1821
+ const corpus = wholeCorpusFiles();
1822
+ if (!corpus.length) {
1823
+ console.log('\nscan: the docs/ corpus is empty — nothing to scan.');
1824
+ return 0;
1825
+ }
1826
+ console.log(`\n== scan (whole corpus: ${corpus.length} file(s)) ==`);
1827
+ scan(corpus);
1828
+ return corpus.length;
1829
+ }
1830
+
1831
+ const REORG_DEST = { product: 'docs/product', logs: 'docs/logs', archive: 'docs/archive' };
1832
+ const VALID_BUCKETS = new Set(Object.keys(REORG_DEST));
1833
+ // bucket values a PRE-v3 reorg-plan.json could hold — neither exists any more ('oversized'
1834
+ // was a bucket, now a boolean; 'review' is gone outright, see classifyDoc). Distinguishing
1835
+ // this from "the interview just hasn't run yet" (bucket === '') earns a different message:
1836
+ // re-running discover fixes a stale plan; filling `bucket` fixes a fresh one.
1837
+ const STALE_BUCKETS = new Set(['oversized', 'review']);
1838
+
1839
+ // Depth-first empty-dir sweep, scoped to directories a file actually moved OUT of this run.
1840
+ // `dirs` are absolute paths; `rootAbs` is never itself a candidate (walking stops there) so
1841
+ // the reorg root (docs/ by default) can never be removed even if every file under it moved.
1842
+ // Sorting by path-segment count (not string length) before removing is what makes "nested
1843
+ // empties collapse" correct: a child directory is always tested — and, if empty, removed —
1844
+ // before its parent gets its turn, so a parent that only became empty because its last child
1845
+ // dir was just removed still gets caught in the same pass.
1846
+ function collectEmptyDirs(rootAbs, dirs) {
1847
+ const candidates = new Set();
1848
+ for (const d of dirs) {
1849
+ let cur = d;
1850
+ while (cur.startsWith(rootAbs + path.sep)) { candidates.add(cur); cur = path.dirname(cur); }
1851
+ }
1852
+ const ordered = [...candidates].sort((a, b) =>
1853
+ b.split(path.sep).length - a.split(path.sep).length);
1854
+ const removed = [];
1855
+ for (const dir of ordered) {
1856
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
1857
+ fs.rmdirSync(dir);
1858
+ removed.push(dir);
1859
+ }
1860
+ }
1861
+ return removed;
1862
+ }
1863
+
1864
+ // Same job `/remember` step 5 does for MEMORY.md, applied to the docs map: a marker-wrapped
1865
+ // pointer block in the repo's agent config file so a session finds docs/index.md without
1866
+ // being told. A PLAIN backticked path, never an `@`-reference — `@docs/index.md` would
1867
+ // hot-load the whole index into every session, which is exactly what index-flat's own search
1868
+ // hint above exists to avoid. The block is static (never varies with row count), so a re-run
1869
+ // rewrites it to identical bytes. THROWS on failure — same throwing-core convention as
1870
+ // moveDoc() — so the caller (applyReorg) decides how to report it; this never exits the
1871
+ // process itself.
1872
+ //
1873
+ // The target FILENAME differs per tool even though this script is byte-identical across all
1874
+ // 4 packages: claude -> CLAUDE.md, droid -> AGENTS.md, ampcode -> AGENT.md, opencode ->
1875
+ // AGENTS.md. Same escape hatch as REPO/OUT/PAGES/INDEX/N elsewhere in this file — an env var,
1876
+ // so the packaged command docs can pass their own tool's filename without a code fork.
1877
+ // Default stays CLAUDE.md so the claude package needs no env var set at all.
1878
+ const DOCS_INDEX_START = '<!-- DOCS_INDEX:START -->';
1879
+ const DOCS_INDEX_END = '<!-- DOCS_INDEX:END -->';
1880
+ function docsIndexBlock() {
1881
+ return `${DOCS_INDEX_START}\n`
1882
+ + 'Docs map: `docs/index.md` — every doc in this project, with line counts.\n'
1883
+ + 'Too many rows to read whole? Search instead: `/docs-builder search <query words>`\n'
1884
+ + `${DOCS_INDEX_END}`;
1885
+ }
1886
+ function injectClaudeMdPointer() {
1887
+ const rel = process.env.CONFIG || 'CLAUDE.md';
1888
+ const f = repoPath(rel);
1889
+ const block = docsIndexBlock();
1890
+ noteGenerated(rel);
1891
+ const startRe = new RegExp(`${DOCS_INDEX_START}[\\s\\S]*?${DOCS_INDEX_END}`);
1892
+ if (!fs.existsSync(f)) { fs.writeFileSync(f, block + '\n'); return; }
1893
+ const cur = fs.readFileSync(f, 'utf8');
1894
+ if (startRe.test(cur)) fs.writeFileSync(f, cur.replace(startRe, block));
1895
+ else fs.writeFileSync(f, cur.replace(/\n*$/, '\n\n') + block + '\n');
1896
+ }
1897
+
1898
+ // v3 reorg (docs-builder-v3-spec.md, "four buckets"): the interview, not this function, does
1899
+ // the classifying — this only executes an ALREADY-approved plan. It refuses outright if any
1900
+ // row's `bucket` isn't one of the three real buckets: an empty bucket means the interview
1901
+ // hasn't happened, and a stale 'oversized'/'review' bucket means the plan predates this
1902
+ // version's schema. Oversized rows are no longer skipped — they move like everything else
1903
+ // (size decides splittable, not sorted) and come back as split candidates at their NEW path.
1904
+ function applyReorg(planFile) {
1905
+ const f = planFile || path.join(ARTIFACTS, 'reorg-plan.json');
1906
+ if (!fs.existsSync(f)) die(`no plan at ${planFile || 'docs/.docs-builder/reorg-plan.json'} — run \`discover\` first`);
1907
+ const plan = parseJSONFile(f);
1908
+ const unclassified = plan.rows.filter(r => !VALID_BUCKETS.has(r.bucket));
1909
+ if (unclassified.length) {
1910
+ const stale = plan.rows.some(r => STALE_BUCKETS.has(r.bucket));
1911
+ die(`refusing to apply: the classification interview has not happened `
1912
+ + `(${unclassified.length} of ${plan.rows.length} row(s) have no valid \`bucket\`).`
1913
+ + (stale
1914
+ ? ` This plan predates v3's four-bucket schema ('oversized'/'review' no longer `
1915
+ + 'exist as buckets) — re-run `discover` to regenerate it, then classify.'
1916
+ : ' Run the classification interview (docs-builder.md): fill every row\'s `bucket` '
1917
+ + '(product/logs/archive), get the user\'s approval, then re-run.'));
1918
+ }
1919
+ const results = { moved: 0, skipped: 0, artifactsSynced: 0, linksRewritten: 0,
1920
+ syncFailed: 0, dirsRemoved: 0, claudeMdUpdated: false };
1921
+ // Set once, up front, from the SAME plan the loop below reads row.file from — every row
1922
+ // this run already commits to bucket:'archive' is exempt from every rewrite the run makes,
1923
+ // from the very first move, not only once it has actually landed there. See
1924
+ // plannedArchiveSrc's definition next to isRewriteExempt for the ordering bug this closes.
1925
+ plannedArchiveSrc = new Set(plan.rows.filter(r => r.bucket === 'archive').map(r => r.file));
1926
+ const usedNames = new Map(); // collision guard, same defensive pattern as theme slugs
1927
+ const splitCandidates = []; // oversized rows, at their NEW path — ordered logs-last below
1928
+ const sourceDirs = [];
1929
+ const linkFilesTouched = []; // dedup'd by flushCommitAdvisory() at the end of the run
1930
+ const movedDestPaths = []; // every successful move's NEW path, same accumulator
1931
+ for (const row of plan.rows) {
1932
+ const destDir = REORG_DEST[row.bucket];
1933
+ let base = path.basename(row.file);
1934
+ const n = (usedNames.get(destDir + '/' + base) || 0) + 1;
1935
+ usedNames.set(destDir + '/' + base, n);
1936
+ if (n > 1) { const ext = path.extname(base); base = base.slice(0, -ext.length) + `-${n}` + ext; }
1937
+ // Only a failed MOVE skips the file. A failed follow-up is a warning on a file that has
1938
+ // already moved — counting it as skipped would be a lie, and stopping the loop would
1939
+ // strand the rest of the plan half-applied.
1940
+ let r;
1941
+ try {
1942
+ r = moveDoc(row.file, path.join(destDir, base));
1943
+ } catch (e) {
1944
+ console.error(`SKIP ${row.file}: ${e.message}`);
1945
+ results.skipped++;
1946
+ continue;
1947
+ }
1948
+ console.log(` ${row.file} -> ${r.rel}`);
1949
+ results.moved++;
1950
+ movedDestPaths.push(r.rel);
1951
+ results.artifactsSynced += r.artifacts;
1952
+ results.linksRewritten += r.links;
1953
+ sourceDirs.push(path.dirname(path.join(REPO, row.file)));
1954
+ if (row.oversized) splitCandidates.push({ file: r.rel, bucket: row.bucket, lines: row.lines });
1955
+ for (const { file, n } of r.linkFiles) { console.log(` ${file}: ${n} link(s) -> ${r.rel}`); linkFilesTouched.push(file); }
1956
+ for (const f of r.failures) {
1957
+ console.error(` WARN ${row.file} MOVED, but ${f}`);
1958
+ results.syncFailed++;
1959
+ }
1960
+ }
1961
+ // Only directories the moves THIS RUN emptied are candidates — never a dir this run never
1962
+ // touched, even if it happens to be empty already (that's not ours to remove).
1963
+ const rootAbs = path.join(REPO, plan.root || 'docs');
1964
+ const removedDirs = sourceDirs.length ? collectEmptyDirs(rootAbs, sourceDirs) : [];
1965
+ results.dirsRemoved = removedDirs.length;
1966
+ for (const dir of removedDirs)
1967
+ console.log(` removed empty dir: ${path.relative(REPO, dir).split(path.sep).join('/')}`);
1968
+ // Runs regardless of whether anything moved THIS run — apply-reorg is also the thing that
1969
+ // (re)builds outline.json for a corpus that already sat in docs/product/docs/archive/docs/logs
1970
+ // from a previous run, e.g. after a manual git mv or a re-run with nothing left to do.
1971
+ scanWholeCorpus();
1972
+ console.log(JSON.stringify(results, null, 1));
1973
+ if (splitCandidates.length) {
1974
+ // Ranked, logs last (spec §5): a prereg is a legitimate split target but rarely the best
1975
+ // NEXT one. Array.prototype.sort is stable in Node, so this only reorders logs to the
1976
+ // tail — it does not reshuffle the rest of the list.
1977
+ splitCandidates.sort((a, b) => (a.bucket === 'logs') - (b.bucket === 'logs'));
1978
+ console.log(`\n${splitCandidates.length} oversized doc(s) — never auto-split (that spends `
1979
+ + 'model budget); run `cleanup <file>` on each, by hand, one at a time:');
1980
+ for (const r of splitCandidates) console.log(` cleanup ${r.file} (${r.lines} lines)`);
1981
+ }
1982
+ // v3: apply-reorg writes docs/index.md itself — a reorg-only corpus ends up indexed
1983
+ // without a second command. Runs unconditionally — oversized docs are sorted like anything
1984
+ // else now, so this was never conditional on them.
1985
+ indexFlat();
1986
+ // Crash-isolated, same spirit as the moveDoc() follow-up failures collected above: a
1987
+ // failure to write the config file is a WARN, never a thrown error — it must not make an
1988
+ // already-moved file look unmoved or fail the run.
1989
+ const configName = process.env.CONFIG || 'CLAUDE.md';
1990
+ try {
1991
+ injectClaudeMdPointer();
1992
+ results.claudeMdUpdated = true;
1993
+ console.log(` updated ${configName} with the docs/index.md pointer`);
1994
+ } catch (e) {
1995
+ console.error(` WARN could not update ${configName} with the docs/index.md pointer: ${e.message}`);
1996
+ }
1997
+ logOp('apply-reorg', `moved ${results.moved}, skipped ${results.skipped}, `
1998
+ + `${splitCandidates.length} oversized split candidate(s), `
1999
+ + `${results.linksRewritten} link(s) rewritten, ${results.syncFailed} sync failure(s), `
2000
+ + `${results.dirsRemoved} empty dir(s) removed, ${configName} updated: ${results.claudeMdUpdated}`);
2001
+ // Accumulated here; the dispatcher prints the run's ONE recipe last, after every step.
2002
+ noteMoved(...movedDestPaths); noteLinks(linkFilesTouched);
2003
+ }
2004
+
2005
+ // ---------------------------------------------------------------- reorg (single front door)
2006
+
2007
+ // v3: `reorg` folds the old `reconcile` and `due` into one front door. "First run" (nothing
2008
+ // under product/archive yet) and "since last time" (a ledger stamp already exists) are the
2009
+ // same job with different starting state — two commands only made users guess which to run.
2010
+ // `due` stays individually runnable, unchanged: `/remember` step 7 shells out to it directly
2011
+ // and its output/exit code/threshold must not move. `reorg` calls that SAME due() in-process,
2012
+ // only when a ledger stamp exists, so its drift summary is additive, never a rewrite of it.
2013
+ //
2014
+ // What reconcile did that has no home here, and why that's not a loss: reconcile's
2015
+ // validate+index steps needed labels.json (a model-produced theme assignment), which only the
2016
+ // split pipeline (`cleanup`) ever creates. `reorg` never splits (rule 1) and never calls a
2017
+ // model by default, so it never has labels.json to validate against — that capability didn't
2018
+ // move, it stayed exactly where it already lived: the standalone `validate`/`index` commands,
2019
+ // unchanged, still runnable by hand once labels.json exists.
2020
+ function reorg() {
2021
+ // discover/apply-reorg/lint/due each write a DIFFERENT artifact, and every one of them
2022
+ // honours the same `OUT` override — same trap reconcile's own OUT guard existed to catch.
2023
+ if (process.env.OUT) {
2024
+ console.error(`WARN: ignoring OUT=${process.env.OUT} — reorg writes several artifacts `
2025
+ + '(reorg-plan.json, outline.json, index.md, lint.json) and each goes to its own default path.');
2026
+ delete process.env.OUT;
2027
+ }
2028
+ // due() reads git HEAD and the CURRENT working tree — `apply-reorg`'s `git mv` runs
2029
+ // uncommitted, so calling due() AFTER apply-reorg would see this run's own in-flight moves
2030
+ // and misreport them as deletions (the moved file no longer exists at its pre-move path,
2031
+ // and due() has no way to tell "moved by this very run" from "actually gone"). due() runs
2032
+ // FIRST, against whatever the tree looked like coming in, so its drift summary reflects
2033
+ // real external changes since the stamp, not reorg's own not-yet-committed side effects.
2034
+ const ledgerF = path.join(REPO, process.env.OUT || LEDGER);
2035
+ const hadLedger = fs.existsSync(ledgerF);
2036
+ if (hadLedger) {
2037
+ console.log('== due (drift since the last ledger stamp) ==');
2038
+ due();
2039
+ console.log('');
2040
+ }
2041
+ console.log('== discover ==');
2042
+ discover();
2043
+ // v3: classification is the model's job, behind an approval gate (docs-builder-v3-spec.md
2044
+ // §4). `reorg` must not silently proceed past a plan the interview hasn't touched yet —
2045
+ // that would be the exact failure the gate exists to prevent, just moved one layer up.
2046
+ // applyReorg() would refuse anyway, but refusing HERE means `reorg` stops with instructions
2047
+ // instead of a die()'d stack-shaped error from a step the user didn't know was next.
2048
+ const planFile = path.join(ARTIFACTS, 'reorg-plan.json');
2049
+ const plan = parseJSONFile(planFile);
2050
+ const unclassified = plan.rows.filter(r => !VALID_BUCKETS.has(r.bucket));
2051
+ if (unclassified.length) {
2052
+ console.log(`\n${unclassified.length} of ${plan.rows.length} row(s) still need `
2053
+ + `classification — the interview hasn't happened yet. Run it (docs-builder.md): feed `
2054
+ + `the model ${planFile}, get bucket+reason `
2055
+ + 'per row, get the user\'s approval, write the approved buckets back into the plan, '
2056
+ + 'then re-run `reorg` (or `apply-reorg` directly).');
2057
+ logOp('reorg', `discover only — ${unclassified.length} of ${plan.rows.length} row(s) `
2058
+ + 'await the classification interview');
2059
+ return;
2060
+ }
2061
+ console.log('\n== apply-reorg ==');
2062
+ applyReorg();
2063
+ const corpus = wholeCorpusFiles();
2064
+ console.log('\n== lint ==');
2065
+ if (corpus.length) lint(corpus);
2066
+ else console.log('LOUD-SKIP: lint did not run — the corpus is empty');
2067
+ logOp('reorg', `discover+apply-reorg+lint over ${corpus.length} doc(s)`
2068
+ + (hadLedger ? ', due reported' : ', no ledger stamp yet'));
2069
+ }
2070
+
2071
+ // ---------------------------------------------------------------- cleanup (Mode 1 entry point)
2072
+
2073
+ // Mechanical heading-shape grouper for the interview's proposal. NO semantics — the script
2074
+ // must never guess what a document "is about"; it only measures. Rule: walk a heading's
2075
+ // tokens left to right; the group KEY is every token up to and including the first one that
2076
+ // contains a digit, with digit runs replaced by "#" (so "Addendum v1.01" and "Addendum
2077
+ // v1.42" fall in the same group, and "§1 Scope"/"§2 Goals" both key to "§#"). A heading with
2078
+ // no digit at all keys on its own full (unchanged) text — verbatim duplicates still group,
2079
+ // anything else stays a singleton until the group-size cutoff below folds it into "other".
2080
+ // Verified against bareloop's real docs/01-product/PRD.md: 75 `Addendum v1.NN — <date>`
2081
+ // headings and 11 `§N ...` headings resolve to exactly two groups — see the bareloop run
2082
+ // pasted in the PR description.
2083
+ function shapeKey(text) {
2084
+ const tokens = clean(text).split(' ').filter(Boolean);
2085
+ const key = [];
2086
+ for (const tok of tokens) {
2087
+ key.push(tok.replace(/\d+/g, '#'));
2088
+ if (/\d/.test(tok)) break;
2089
+ }
2090
+ return key.join(' ') || '(untitled)';
2091
+ }
2092
+
2093
+ // A "group" of 1 shares no pattern with anything else — it is folded into one "other" bucket
2094
+ // instead of printed as its own row, so the report stays a SHAPE summary, not a heading dump.
2095
+ const SHAPE_MIN_GROUP = 2;
2096
+
2097
+ function buildShape(file, records) {
2098
+ const totalLines = records.reduce((a, r) => a + r.lines, 0);
2099
+ const byKey = new Map();
2100
+ for (const r of records) {
2101
+ const k = shapeKey(r.h2);
2102
+ if (!byKey.has(k)) byKey.set(k, []);
2103
+ byKey.get(k).push(r);
2104
+ }
2105
+ const groups = [], other = [];
2106
+ for (const [k, recs] of byKey) {
2107
+ if (recs.length >= SHAPE_MIN_GROUP)
2108
+ groups.push({ key: k, sections: recs.length, lines: recs.reduce((a, r) => a + r.lines, 0) });
2109
+ else other.push(...recs);
2110
+ }
2111
+ if (other.length)
2112
+ groups.push({ key: 'other', sections: other.length, lines: other.reduce((a, r) => a + r.lines, 0) });
2113
+ groups.sort((a, b) => b.lines - a.lines);
2114
+ for (const g of groups) g.pct = totalLines ? Math.round(g.lines / totalLines * 1000) / 10 : 0;
2115
+ return { file, totalLines, totalSections: records.length, groups };
2116
+ }
2117
+
2118
+ function printShape(shape) {
2119
+ console.log(`\n${shape.totalLines} lines. ${shape.totalSections} sections.`);
2120
+ for (const g of shape.groups) {
2121
+ const label = g.key === 'other' ? 'other' : g.key.replace(/#/g, 'N');
2122
+ console.log(` ${label}`.padEnd(32) + `${g.sections} sections, ${g.lines} lines (${g.pct}%)`);
2123
+ }
2124
+ }
2125
+
2126
+ // `docs/.docs-builder/` is shared, not per-file: `cleanup`'s own `scan` OVERWRITES
2127
+ // outline.json with only ITS file's records (scan() has always worked this way — see its own
2128
+ // comment on batch-size-independent keys). MEASURED, real: running `cleanup` on a SECOND file
2129
+ // while a FIRST split still sat between `plan` and `archive` clobbered outline.json out from
2130
+ // under the first split (its labels.json now referenced keys the outline no longer had),
2131
+ // leaving `docs/wiki-index.md` at 0 rows with no error at all — the real defect was that
2132
+ // nothing failed; the artifacts were just silently wrong. Two ways to close this were
2133
+ // weighed: (a) make `scan` MERGE instead of overwrite, or (b) refuse to start a second split
2134
+ // while an earlier one is in flight. (a) was rejected: `validate` compares outline.json's
2135
+ // FULL record set against labels.json 1:1 (`doValidate`'s `missing` check) — a merged,
2136
+ // corpus-wide outline would make every OTHER file's sections show up as "missing" from a
2137
+ // single-file split's labels.json, trading this bug for a new false-positive gate failure in
2138
+ // a command this pipeline still ships standalone. (b) is chosen: safer, and the workflow it
2139
+ // blocks (two splits genuinely in flight at once) was never the documented one anyway —
2140
+ // cleanup-apply's own resumability already means "finish this split, then start the next".
2141
+ function inFlightSplit(file) {
2142
+ const labelsF = path.join(ARTIFACTS, 'labels.json');
2143
+ const outlineF = path.join(ARTIFACTS, 'outline.json');
2144
+ if (!fs.existsSync(labelsF) || !fs.existsSync(outlineF)) return null;
2145
+ let l, o;
2146
+ try { l = parseJSONFileOrThrow(labelsF); o = parseJSONFileOrThrow(outlineF); }
2147
+ catch { return null; } // a malformed artifact is not this check's job to diagnose
2148
+ if (!coreThemeName(l.themes)) return null; // no core theme -> not a cleanup split at all
2149
+ const other = [...new Set((o.records || []).map(r => r.file))].find(f => f !== file);
2150
+ if (!other) return null;
2151
+ // Archived -> that split is DONE: `archive()` rewrites outline.json's records[].file to
2152
+ // the docs/archive/ path the instant the move lands (rewriteArchivedPath), so a finished
2153
+ // split's record permanently exists on disk at its archive path — checking existence alone
2154
+ // would misread a COMPLETED split as still in flight forever. Still at its pre-archive
2155
+ // location -> genuinely in flight (plan ran, archive hasn't). Gone entirely -> archived
2156
+ // (same as above) or a stale artifact; a leftover labels.json/outline.json is not a live
2157
+ // conflict — a second, unrelated cleanup is exactly the normal, supported next step.
2158
+ if (other.startsWith(REORG_DEST.archive + '/')) return null;
2159
+ return fs.existsSync(repoPath(other)) ? other : null;
2160
+ }
2161
+
2162
+ // v3 rule 1: `reorg` never splits. `cleanup` is the ONLY door into the split pipeline, and it
2163
+ // is now a MEASURE step only: cost estimate, scan, a mechanical heading-shape report — then
2164
+ // it STOPS. Settled 2026-08-23 (docs-builder-v3-spec.md, "cleanup"): the proposal (what this
2165
+ // document is mainly about, what other themes it holds) comes from a model's cheap-tier read,
2166
+ // driven by docs-builder.md, never guessed here; the verdict comes from the user via
2167
+ // AskUserQuestion. Nothing past this function runs — no page, no archive move, no further
2168
+ // model call — until that interview is answered. `cleanup-apply` (below) is the door back in.
2169
+ function cleanup(files) {
2170
+ if (!files.length) die('usage: docs-builder.cjs cleanup <file.md>');
2171
+ if (files.length > 1)
2172
+ die(`cleanup takes exactly ONE file, not ${files.length} (${files.join(', ')}) — `
2173
+ + 'splitting spends real model budget, so it only ever runs on a single file you '
2174
+ + 'named. Run it once per file.');
2175
+ const [file] = files;
2176
+ if (path.extname(file) !== '.md') die(`cleanup: ${file} is not a .md file`);
2177
+ if (PROTECTED_NAMES.has(path.basename(file)))
2178
+ die(`cleanup: ${file} is a protected entry-point doc (README/CLAUDE.md/etc.) and is `
2179
+ + 'never split');
2180
+ if (!fs.existsSync(repoPath(file))) die(`cleanup: no such file: ${file}`);
2181
+ const inFlight = inFlightSplit(file);
2182
+ if (inFlight)
2183
+ die(`cleanup: refusing to start — ${inFlight} looks like an in-progress split `
2184
+ + '(docs/.docs-builder/labels.json has a core:true theme and its source file still '
2185
+ + `exists, meaning \`plan\` has run but \`archive\` has not). A second cleanup here `
2186
+ + `would overwrite that split's still-in-flight outline.json/labels.json. Finish it `
2187
+ + `first: write its remaining pages, then re-run \`cleanup-apply ${inFlight} ...\` until `
2188
+ + `it archives — THEN run \`cleanup ${file}\`.`);
2189
+ const lines = read(file).split('\n').length;
2190
+ const est = writeCostEstimate(1, lines);
2191
+ console.log(`${file}: ${lines} lines`);
2192
+ console.log(`est. write cost: $${est.toFixed(2)} (mid tier, floor assuming 1 page — the `
2193
+ + "real page count depends on the model's grouping step; `plan` reports the precise "
2194
+ + 'figure once labels.json exists)');
2195
+ console.log('\n== scan ==');
2196
+ scan([file]);
2197
+
2198
+ // Same dest scan() itself just wrote to (write()'s own OUT-or-default), so this always
2199
+ // reads back exactly the outline scan produced, whether OUT was overridden or not.
2200
+ const outlineDest = process.env.OUT || path.join(ARTIFACTS, 'outline.json');
2201
+ const o = readArtifactJSON(outlineDest);
2202
+ const shape = buildShape(file, o.records);
2203
+ const shapeDest = path.join(ARTIFACTS, 'cleanup-shape.json');
2204
+ fs.mkdirSync(path.dirname(shapeDest), { recursive: true });
2205
+ fs.writeFileSync(shapeDest, JSON.stringify(shape, null, 1));
2206
+ console.log('\n== shape ==');
2207
+ printShape(shape);
2208
+ console.log(`\nwrote ${shapeDest}`);
2209
+ console.log('\nawaiting the interview — cleanup stops here. Not the archive move, not a '
2210
+ + 'page, not a model call beyond the scan above. Read the shape, propose what this '
2211
+ + 'document is mainly about and what other themes it holds, and ask the user via '
2212
+ + 'AskUserQuestion before anything else runs (docs-builder.md, Mode 1, step 1b). Once the '
2213
+ + 'themes are confirmed, propose+assign (step 2a/2b) writes labels.json with exactly one '
2214
+ + 'theme marked core:true, then run:\n'
2215
+ + ` docs-builder.cjs cleanup-apply ${file} ${outlineDest} ${path.join(ARTIFACTS, 'labels.json')}`);
2216
+ }
2217
+
2218
+ // ---------------------------------------------------------------- cleanup-apply (post-approval)
2219
+
2220
+ // The interview settles the themes; this is the first script step allowed to run after it.
2221
+ // A new subcommand rather than a `cleanup --apply` flag: this file has no flag parser
2222
+ // anywhere (every subcommand is positional, on purpose — see the dispatch table below), and
2223
+ // a one-off flag here would be a new parsing convention for one caller. It is also a
2224
+ // SEPARATE command from `plan`/`archive`/`index-flat` rather than a wrapper that always
2225
+ // chains all three: a human/model page-writing step sits between `plan` and `archive` that
2226
+ // this script cannot run, so `cleanup-apply` is deliberately re-runnable — call it once and
2227
+ // it reports pages still to write (same resumability `plan` already has); call it again once
2228
+ // every page exists and THAT run archives the original and rebuilds the index. It refuses
2229
+ // outright, before doing anything, if the interview clearly has not happened: no labels.json,
2230
+ // or a labels.json with no theme marked core:true.
2231
+ //
2232
+ // The core page's REAL destination (its own document's original directory, not PAGES —
2233
+ // settled 2026-08-23, docs-builder-v3-spec.md "cleanup") must be read from outline.json
2234
+ // BEFORE archiving: `archive(file)` runs moveDoc -> rewriteArchivedPath, which rewrites
2235
+ // outline.json's records[].file to the ARCHIVE path the instant the move lands, so the
2236
+ // original directory can only be recovered here, one line before that happens.
2237
+ function coreFileInfo(o, l) {
2238
+ const coreName = coreThemeName(l.themes);
2239
+ if (!coreName) return null;
2240
+ const files = [...new Set((o.records || []).map(r => r.file))];
2241
+ if (files.length !== 1) return null; // buildThemeSlugs (via plan(), already run) validates this
2242
+ return { coreName, dir: path.posix.dirname(files[0]), base: path.basename(files[0]) };
2243
+ }
2244
+
2245
+ function cleanupApply(file, outlineF, labelsF) {
2246
+ if (!file || !outlineF || !labelsF)
2247
+ die('usage: docs-builder.cjs cleanup-apply <file.md> <outline.json> <labels.json>');
2248
+ if (!fs.existsSync(labelsF))
2249
+ die(`cleanup-apply: no labels.json at ${labelsF} — the interview has not happened yet. `
2250
+ + 'Run `cleanup <file>`, answer the interview it prints, then have the model '
2251
+ + 'propose+assign themes (labels.json, with exactly one theme marked core:true) before '
2252
+ + 'calling cleanup-apply.');
2253
+ const l = parseJSONFile(labelsF);
2254
+ if (!coreThemeName(l.themes))
2255
+ die('cleanup-apply: labels.json has no theme marked core:true — the interview has not '
2256
+ + "happened yet. Mark exactly one theme core:true (the document's main subject, from "
2257
+ + 'the interview\'s answer), then re-run cleanup-apply.');
2258
+ const { rows, todo, pages } = plan(outlineF, labelsF);
2259
+ if (todo.length) {
2260
+ console.log(`\n${todo.length} page(s) still to write — write them (mid tier, one agent `
2261
+ + 'per page, docs-builder.md step 5), then re-run `cleanup-apply` to archive the '
2262
+ + 'original and rebuild the index.');
2263
+ return;
2264
+ }
2265
+ console.log('\nall pages written — archiving the original and rebuilding the index.');
2266
+ // Every page this split produced is a brand-new, untracked file. Nothing MOVED them, so
2267
+ // only this knows they exist — and a commit recipe that omits them stages an archived
2268
+ // original whose replacement content is nowhere in the commit.
2269
+ noteGenerated(...rows.map(r => path.posix.join(pages, `${r.theme}.md`)));
2270
+ const core = coreFileInfo(readArtifactJSON(outlineF), l);
2271
+ try { archiveOrThrow(file); }
2272
+ catch (e) {
2273
+ if (!e.followUpFailed) die(e.message); // nothing moved — the original is untouched
2274
+ // The original DID move, so this run cannot be resumed by re-running `cleanup-apply`:
2275
+ // that would call archiveOrThrow on a file that is no longer there. Name the two steps
2276
+ // this run is dropping and how to finish them by hand — exiting silently left an
2277
+ // operator with a half-applied split and no idea the index was still stale.
2278
+ console.error(e.message);
2279
+ console.error(`\ncleanup-apply STOPPED here. Two steps did NOT run: the core page was not `
2280
+ + `relocated out of ${pages}/ into the original document's directory, and docs/index.md `
2281
+ + `was not rebuilt (it still describes the pre-split shape). Do NOT re-run `
2282
+ + `\`cleanup-apply\` — ${file} has already moved. Fix the failure above, move the core `
2283
+ + `page yourself, then run \`docs-builder.cjs index-flat\` to rebuild the index.`);
2284
+ flushCommitAdvisory();
2285
+ process.exit(2);
2286
+ }
2287
+ // The model writes every page — core included — under PAGES (docs-builder.md's documented
2288
+ // convention; unchanged, since the model can't write there directly: until the line above
2289
+ // runs, the ORIGINAL still occupies that exact path, and overwriting a live, not-yet-
2290
+ // archived source would violate "the original is never rewritten"). Once archiving frees
2291
+ // that path, the core page is relocated from its interim PAGES location into the original
2292
+ // document's own directory — only NON-core theme pages stay under PAGES for good.
2293
+ if (core) {
2294
+ const from = path.join(pages, core.base);
2295
+ const to = path.join(core.dir, core.base);
2296
+ if (from === to) {
2297
+ console.log(` core page ${from} already lives in the original document's own directory.`);
2298
+ } else if (!fs.existsSync(repoPath(from))) {
2299
+ console.error(` WARN: expected the core page at ${from} (the model's page-writing step `
2300
+ + `should have written it there) but it is missing — cannot relocate it into `
2301
+ + `${core.dir}/. The index below will not show a core page for this split.`);
2302
+ } else {
2303
+ try {
2304
+ const r = moveDoc(from, to);
2305
+ console.log(` relocated core page: ${from} -> ${to} (its own document's original `
2306
+ + `directory — only non-core theme pages stay under ${pages}/)`);
2307
+ for (const m of r.artifactNotes) console.log(` ${m}`);
2308
+ if (r.failures.length) console.error(` WARN core page relocated, but ${r.failures.join('; ')}`);
2309
+ noteMoved(r.rel); noteLinks(r.linkFiles.map(x => x.file));
2310
+ } catch (e) {
2311
+ console.error(` WARN could not relocate the core page from ${from} to ${to}: `
2312
+ + `${e.message} — it remains at ${from}.`);
2313
+ }
2314
+ }
2315
+ }
2316
+ // v3 scope change (2026-08-24): the themed per-split index (docs/wiki-index.md) is gone —
2317
+ // it was the direct cause of three separate defects (a corpus-map clobber, the multi-split
2318
+ // outline.json clobber inFlightSplit() above now refuses, and a slugOf() lowercasing bug
2319
+ // that rendered a real page as "pending"). ONE index now: index-flat's whole-corpus
2320
+ // docs/index.md, rebuilt here so it captures the split's new shape (the archived original,
2321
+ // the relocated core page at its real path, and the remaining PAGES/ pages) same as it
2322
+ // already is after every reorg.
2323
+ indexFlat();
2324
+ }
2325
+
2326
+ // ---------------------------------------------------------------- dispatch
2327
+
2328
+ // Machine state has one home. Callers can override with OUT, but the default must never
2329
+ // scatter JSON into whatever directory the user happened to be standing in.
2330
+ const ARTIFACTS = 'docs/.docs-builder';
2331
+ function write(obj, fallback) {
2332
+ const dest = process.env.OUT || path.join(ARTIFACTS, fallback);
2333
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
2334
+ fs.writeFileSync(dest, JSON.stringify(obj, null, 1));
2335
+ }
2336
+
2337
+ const [cmd, ...rest] = process.argv.slice(2);
2338
+ switch (cmd) {
2339
+ case 'scan': scan(rest); break;
2340
+ case 'validate': validate(rest[0], rest[1]); break;
2341
+ case 'plan': plan(rest[0], rest[1]); break;
2342
+ case 'index-flat': indexFlat(); break;
2343
+ case 'search': search(rest[0], rest.slice(1)); break;
2344
+ case 'archive': archive(rest[0], rest[1]); break;
2345
+ case 'ledger': ledger(); break;
2346
+ case 'due': due(); break;
2347
+ case 'lint': lint(rest); break;
2348
+ case 'discover': discover(rest[0]); break;
2349
+ case 'apply-reorg': applyReorg(rest[0]); break;
2350
+ case 'reorg': reorg(); break;
2351
+ case 'cleanup': cleanup(rest); break;
2352
+ case 'cleanup-apply': cleanupApply(rest[0], rest[1], rest[2]); break;
2353
+ default:
2354
+ die('usage: docs-builder.cjs <scan|validate|plan|index-flat|search|archive|ledger|due|lint|'
2355
+ + 'discover|apply-reorg|reorg|cleanup> [args]\n'
2356
+ + ' scan <file.md...> -> outline.json\n'
2357
+ + ' validate <outline.json> <labels.json> -> PASS/FAIL (exit 1 on FAIL)\n'
2358
+ + ' plan <outline.json> <labels.json> -> task-<theme>.json per page\n'
2359
+ + ' index-flat -> index.md, the ONE index (whole corpus, no labels needed)\n'
2360
+ + ' search <outline.json> <query...> -> ranked sections (BM25, no deps)\n'
2361
+ + ' archive <src.md> [dest.md] -> verified MOVE into docs/archive/\n'
2362
+ + ' ledger -> record current state of docs/\n'
2363
+ + ' due -> what changed since the ledger\n'
2364
+ + ' lint <file.md...> -> lint.json\n'
2365
+ + ' discover [root=docs] -> reorg-plan.json (proposes `suggested`, never moves, never CLASSIFIES — carries forward already-approved buckets)\n'
2366
+ + ' apply-reorg [plan.json] -> executes the plan; refuses if any row\'s `bucket` is empty\n'
2367
+ + ' reorg -> discover+apply-reorg+lint, plus `due`\'s '
2368
+ + 'drift summary if a ledger stamp exists (the single front door)\n'
2369
+ + ' cleanup <file.md> -> ONE named file: cost estimate, then scan\n'
2370
+ + ' (the ONLY entry point to the split pipeline)\n'
2371
+ + 'env: REPO (default cwd), OUT (output path), INDEX (default docs/index.md), '
2372
+ + 'PAGES (default docs/wiki), TASKS (default docs/.docs-builder/tasks), '
2373
+ + 'N (search result count, default 10), OVERSIZED_LINES (default 500)');
2374
+ }
2375
+
2376
+ // The run's ONE commit recipe, printed last, after every step of whichever subcommand ran.
2377
+ // Here rather than inside each subcommand precisely because a subcommand can call another
2378
+ // (cleanup-apply calls archive) — a per-function print is what produced several partial
2379
+ // recipes for one run. `die()`/`process.exit()` paths never reach this, which is correct:
2380
+ // a run that failed has nothing to stage. archive's exit-2 branch flushes explicitly, since
2381
+ // there the move DID land.
2382
+ flushCommitAdvisory();