liteagents 3.6.0 → 3.7.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.
@@ -774,10 +774,26 @@ function indexRow(rel, dest, includeH2) {
774
774
  return row;
775
775
  }
776
776
 
777
- function renderSection(title, rows) {
777
+ // `subdirOf`: optional, `row => subdir name or '' (loose)`. Gap 7 (logs may nest one level —
778
+ // same rule as apply-reorg's destDir computation): grouping is purely a rendering concern, so
779
+ // it lives here rather than as a second traversal of the corpus. `''` (loose) always sorts
780
+ // first, then subdirs alphabetically, so a reader hits the ungrouped files before the buckets.
781
+ function renderSection(title, rows, subdirOf = null) {
778
782
  let s = `## ${title}\n\n`;
779
- s += rows.length ? rows.map(r => r.row).join('') : '_(none)_\n';
780
- return s + '\n';
783
+ if (!rows.length) return s + '_(none)_\n\n';
784
+ if (!subdirOf) return s + rows.map(r => r.row).join('') + '\n';
785
+ const groups = new Map();
786
+ for (const r of rows) {
787
+ const key = subdirOf(r);
788
+ if (!groups.has(key)) groups.set(key, []);
789
+ groups.get(key).push(r);
790
+ }
791
+ const keys = [...groups.keys()].sort((a, b) => a === '' ? -1 : b === '' ? 1 : a.localeCompare(b));
792
+ for (const k of keys) {
793
+ if (k) s += `### ${k}/\n\n`;
794
+ s += groups.get(k).map(r => r.row).join('') + '\n';
795
+ }
796
+ return s;
781
797
  }
782
798
 
783
799
  function indexFlat() {
@@ -819,7 +835,14 @@ function indexFlat() {
819
835
  + 'after every split (`cleanup-apply`). No theme grouping, no model call. Never '
820
836
  + 'hand-edit._\n\n';
821
837
  s += renderSection('Product', productRows);
822
- s += renderSection('Logs', logsRows);
838
+ // Gap 7: group logs rows by their (one-level) subdir name, same nesting rule apply-reorg
839
+ // uses to compute the destination in the first place — a loose docs/logs/x.md row has no
840
+ // '/' after the 'docs/logs/' prefix, so it keys to '' (loose, rendered ungrouped, first).
841
+ s += renderSection('Logs', logsRows, r => {
842
+ const rest = r.file.slice(logsRel.length);
843
+ const slash = rest.indexOf('/');
844
+ return slash === -1 ? '' : rest.slice(0, slash);
845
+ });
823
846
  s += renderSection('Archive', archiveRows);
824
847
  const total = productRows.length + logsRows.length + archiveRows.length;
825
848
  s += `---\n\nTotal: ${total} row(s) — ${productRows.length} product, `
@@ -982,7 +1005,7 @@ function confined(p, what, { deref = false } = {}) {
982
1005
  function doArchive(src, dest) {
983
1006
  const s = confined(src, 'move a doc from', { deref: true });
984
1007
  if (!fs.existsSync(s)) throw new Error(`no such file: ${src}`);
985
- if (PROTECTED_NAMES.has(path.basename(src)))
1008
+ if (isProtectedName(path.basename(src)))
986
1009
  throw new Error(`refusing to move ${src}: ${path.basename(src)} is an entry-point/contract `
987
1010
  + 'doc (README, CLAUDE.md, CHANGELOG, the index, the log, ...) and is never moved, at '
988
1011
  + 'any depth — every human and agent reads it first.');
@@ -1059,7 +1082,10 @@ function rewriteArchivedPath(oldPath, newPath) {
1059
1082
  // log.md (append-only history — a record of where a file WAS is not a broken link); never the
1060
1083
  // pipeline's own JSON (rewriteArchivedPath owns those); never a file RESIDENT under
1061
1084
  // docs/archive/ (same rationale, one directory further — see isRewriteExempt below).
1062
- const LINK_EXTS = new Set(['.md', '.js', '.cjs', '.mjs', '.json', '.yml', '.yaml']);
1085
+ //
1086
+ // docs-builder is a DOCS tool: it only ever reads or edits `.md` files. FIELD BUG (bareloop,
1087
+ // 2026-09-10, real): also scanning .json/.mjs/.js rewrote 6 signed job specs (breaking their
1088
+ // hashes), a byte-signed close script, and a code comment that tripped a commit gate.
1063
1089
  const LINK_SKIP = /(^|\/)(CHANGELOG\.md|log\.md)$/;
1064
1090
 
1065
1091
  // docs/archive/ exists to hold frozen originals — its whole purpose is a record of where a
@@ -1186,7 +1212,7 @@ function rewriteLinks(oldPath, newPath) {
1186
1212
  return result;
1187
1213
  }
1188
1214
  for (const f of candidates) {
1189
- if (!f || !LINK_EXTS.has(path.extname(f))) continue;
1215
+ if (!f || !f.endsWith('.md')) continue;
1190
1216
  if (isRewriteExempt(f) || f.startsWith('docs/.docs-builder/')) continue;
1191
1217
  let text;
1192
1218
  try { text = fs.readFileSync(repoPath(f), 'utf8'); } catch { continue; }
@@ -1280,14 +1306,55 @@ function moveDoc(src, dest) {
1280
1306
  // longer exists. `git add` is atomic: one stale pathspec makes the whole command exit 128 and
1281
1307
  // stage NOTHING, so following the printed recipe committed nothing at all. flush() drops
1282
1308
  // anything not on disk (it moved; its destination is already in `moved`).
1283
- const RUN = { moved: [], links: [], generated: [] };
1309
+ // `movedFrom`: {src, dest} for every successful move this run, so the commit recipe below can
1310
+ // record the RENAME against its old name — `git add` cannot see a path that no longer exists,
1311
+ // but `git commit --pathspec-from-file` naming both old and new records an R100.
1312
+ const RUN = { moved: [], links: [], generated: [], movedFrom: [], dirty: [] };
1284
1313
  const noteMoved = (...paths) => RUN.moved.push(...paths);
1285
1314
  const noteLinks = files => RUN.links.push(...files);
1315
+ const noteMovedFrom = (src, dest) => RUN.movedFrom.push({ src, dest });
1316
+
1317
+ // Snapshot of paths that were ALREADY dirty (staged, unstaged, or untracked) before this run
1318
+ // touched anything. Taken once, from the dispatcher, before a move-capable command runs.
1319
+ // Needed because `git add --pathspec-from-file` can't split hunks: if CLAUDE.md (say) already
1320
+ // had the operator's own uncommitted edit before the run, the recipe's `git add` on CLAUDE.md
1321
+ // stages that edit too, silently, since it's on the list for an unrelated reason (it's always
1322
+ // on the list). This can't be prevented — only made visible, in flushCommitAdvisory below.
1323
+ // Crash-isolated and never throws: no git repo (or a repo with no commits yet) means "nothing
1324
+ // was dirty", not a failure worth interrupting the run over.
1325
+ function snapshotDirty() {
1326
+ try {
1327
+ const out = execFileSync('git', ['-C', REPO, 'status', '--porcelain', '-z'],
1328
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
1329
+ const records = out.split('\0').filter(Boolean);
1330
+ const paths = [];
1331
+ for (let i = 0; i < records.length; i++) {
1332
+ const rec = records[i], code = rec.slice(0, 2), p = rec.slice(3);
1333
+ paths.push(p);
1334
+ // A rename/copy record's OLD path follows as its own NUL-terminated record right after.
1335
+ if (code[0] === 'R' || code[0] === 'C') paths.push(records[++i]);
1336
+ }
1337
+ return paths;
1338
+ } catch { return []; }
1339
+ }
1340
+ // Tool-owned outputs, exempt from the dirty-on-list WARN below: an earlier step of the same
1341
+ // flow (e.g. `reorg` stopping for the interview) already wrote them, so "dirty" there is the
1342
+ // tool's own work, not the user's. docs/log.md is append-only via logOp(); docs/index.md is
1343
+ // fully regenerated by index-flat().
1344
+ const TOOL_OWNED = new Set(['docs/log.md', 'docs/index.md']);
1286
1345
  // Files this run CREATED or REWROTE that no move produced — the rebuilt index, the config
1287
1346
  // file's pointer block, the pages a split wrote. Omitting them meant following the recipe
1288
1347
  // committed a reorg with no index and no pointer.
1289
1348
  const noteGenerated = (...paths) => RUN.generated.push(...paths);
1290
1349
 
1350
+ // Proven by POC (git 2.55): `git add` rejects a moved file's OLD path (not in the index or
1351
+ // worktree any more); `git commit -- <path>` rejects a brand-new UNTRACKED file. Neither
1352
+ // command alone can stage-and-commit both a rename's old name (for `git commit` to record an
1353
+ // R100 against) and this run's newly generated files (index.md, log.md, split pages) in one
1354
+ // shot — so two pathspec-from-file lists, one per command, replace the old single `git add`
1355
+ // recipe. `commit-add.txt` holds only on-disk paths (what `git add` stages); `commit-files.txt`
1356
+ // adds every moved file's OLD path on top (what `git commit` needs to see, so the rename is
1357
+ // recorded against its previous name, even though that path no longer exists on disk).
1291
1358
  function flushCommitAdvisory() {
1292
1359
  if (!RUN.moved.length) return;
1293
1360
  try {
@@ -1295,17 +1362,26 @@ function flushCommitAdvisory() {
1295
1362
  const moveSet = Array.from(new Set(RUN.moved)).filter(onDisk);
1296
1363
  const linkSet = Array.from(new Set(RUN.links)).filter(onDisk);
1297
1364
  const genSet = Array.from(new Set(RUN.generated)).filter(onDisk);
1298
- const allFiles = Array.from(new Set([...moveSet, ...linkSet, ...genSet]));
1299
- // The failure mode this must break: an operator reads `git status`, sees only the
1300
- // STAGED block (the smaller, docs-shaped half), and scopes their commit to `docs/`
1301
- // silently dropping every link repair outside it. A bare count doesn't fight that
1302
- // ("35 link rewrites" still reads as "docs stuff"); naming the actual non-docs
1303
- // locations does. Derived from moveDoc's own linkFiles paths — no hardcoded dir names.
1304
- // Count FILES outside docs/, but list the distinct top-level LOCATIONS. Reporting the
1305
- // location count instead understates the trap: 19 files across 6 dirs printed as "6"
1306
- // reads as a rounding error rather than most of the change set.
1307
- const outsideFiles = linkSet.filter(f => f.split('/')[0] !== 'docs');
1308
- const outsideDocs = Array.from(new Set(outsideFiles.map(f => f.split('/')[0]))).sort();
1365
+ const addFiles = Array.from(new Set([...moveSet, ...linkSet, ...genSet]));
1366
+ // Only old paths git knows from HEAD: a page this run wrote and then moved (cleanup-apply's
1367
+ // core-page relocation) was never tracked, and `git commit` rejects a pathspec it can't match.
1368
+ const moveSrcs = Array.from(new Set(RUN.movedFrom.map(m => m.src)));
1369
+ let oldPaths = [];
1370
+ if (moveSrcs.length) {
1371
+ try {
1372
+ // -z / NUL-separated: `--name-only` alone C-quotes any path with a non-ASCII or quote
1373
+ // character (core.quotepath) e.g. `docs/x/café.md` comes back as `"docs/x/caf\\303\\251.md"`,
1374
+ // which never matches the raw JS path, so that path silently fell out of `oldPaths` and the
1375
+ // recipe committed it as a fresh ADD, leaving its true `D` staged. gitOrThrow() trims() the
1376
+ // whole output, but NUL (U+0000) isn't in trim()'s whitespace set, so a trailing record
1377
+ // survives intact.
1378
+ const inHead = new Set(gitOrThrow(['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', ...moveSrcs],
1379
+ 'listing moved files known to HEAD').split('\0').filter(Boolean));
1380
+ oldPaths = moveSrcs.filter(f => inHead.has(f));
1381
+ } catch { /* no HEAD yet (fresh repo): nothing moved was ever committed */ }
1382
+ }
1383
+ const commitFiles = Array.from(new Set([...addFiles, ...oldPaths]));
1384
+
1309
1385
  console.log(`\n${moveSet.length} rename(s) this run (git mv stages these automatically; `
1310
1386
  + 'the copy+unlink fallback used outside a git repo does not)');
1311
1387
  // Unit, stated: linkSet holds FILE paths, so this is a file count. cleanup-apply's
@@ -1313,28 +1389,50 @@ function flushCommitAdvisory() {
1313
1389
  // "restored 38" — same-sounding nouns, two different units, and the reader has no way to
1314
1390
  // tell they are not a discrepancy. Name the unit in both places.
1315
1391
  console.log(linkSet.length
1316
- ? `${linkSet.length} file(s) with link rewrites UNSTAGED` + (outsideFiles.length
1317
- ? `, ${outsideFiles.length} outside docs/: ${outsideDocs.join(', ')}`
1318
- : ' (all inside docs/)')
1392
+ ? `${linkSet.length} .md file(s) with link rewrites UNSTAGED`
1319
1393
  : 'no inbound-link rewrites this run.');
1320
- console.log('A blanket `git add -A` / `git add -u` / `git commit -a` would ALSO absorb any');
1321
- console.log('unrelated in-flight work in the tree this tool never suggests one (it does');
1322
- console.log('NOT auto-commit either: you may want these moves bundled with other work).');
1323
- console.log('Stage exactly what this run touched (renames + link rewrites), then commit:');
1324
- const quote = f => `'${f.replace(/'/g, `'\\''`)}'`;
1325
- const SHOWN = 20;
1326
- if (allFiles.length <= SHOWN) {
1327
- console.log(` git add -- ${allFiles.map(quote).join(' ')}`);
1328
- } else {
1329
- const listRel = path.join(ARTIFACTS, 'commit-files.txt');
1330
- fs.mkdirSync(path.dirname(repoPath(listRel)), { recursive: true });
1331
- fs.writeFileSync(repoPath(listRel), allFiles.join('\n') + '\n');
1332
- console.log(` ${allFiles.length} files touched — full list written to ${listRel}`);
1333
- console.log(` git add -- ${allFiles.slice(0, SHOWN).map(quote).join(' ')} # + `
1334
- + `${allFiles.length - SHOWN} more, see ${listRel}`);
1335
- console.log(` cat ${listRel} | xargs git add --`);
1394
+
1395
+ // Paths on the recipe's own list that were ALREADY dirty before this run (see
1396
+ // snapshotDirty() above). These are only untouched-by-THIS-RUN's move logic the recipe's
1397
+ // `git add` still stages whatever the operator's own edit left in them, because pathspec
1398
+ // can't split hunks. Always written, even when empty, so a stale file from an earlier run
1399
+ // that DID find dirty paths can never be mistaken for this run's (clean) answer.
1400
+ const dirtySet = new Set(RUN.dirty);
1401
+ const dirtyOnList = commitFiles.filter(f => dirtySet.has(f) && !TOOL_OWNED.has(f));
1402
+ fs.mkdirSync(ARTIFACTS, { recursive: true });
1403
+ fs.writeFileSync(path.join(ARTIFACTS, 'commit-dirty.txt'),
1404
+ dirtyOnList.length ? dirtyOnList.join('\n') + '\n' : '');
1405
+
1406
+ if (addFiles.length) {
1407
+ console.log('\nA blanket `git add -A` / `git add -u` / `git commit -a` would ALSO absorb any');
1408
+ console.log('unrelated in-flight work in the tree — this tool never suggests one (it does');
1409
+ console.log('NOT auto-commit either: you may want these moves bundled with other work).');
1410
+ console.log('This recipe stages each listed file WHOLE — pathspec cannot split hunks, so');
1411
+ console.log('any of the operator\'s OWN edits already sitting in a listed file (not made by');
1412
+ console.log('this run) are committed right along with it.');
1413
+ if (dirtyOnList.length) {
1414
+ console.log(`\nWARN: ${dirtyOnList.length} file(s) on this list already had uncommitted `
1415
+ + 'changes before this run — the recipe commits those changes too:');
1416
+ for (const f of dirtyOnList) console.log(` ${f}`);
1417
+ }
1418
+ // Written directly under ARTIFACTS (already REPO-anchored) — NOT re-joined with
1419
+ // repoPath(), which would double-prefix REPO when REPO is a subdirectory of cwd (e.g.
1420
+ // REPO=sub writing to sub/sub/docs/.docs-builder/... while the recipe below still prints
1421
+ // the correct sub/docs/... path, so following it hit `fatal: pathspec ... did not match
1422
+ // any files`, exit 128, nothing staged).
1423
+ fs.writeFileSync(path.join(ARTIFACTS, 'commit-add.txt'), addFiles.join('\n') + '\n');
1424
+ fs.writeFileSync(path.join(ARTIFACTS, 'commit-files.txt'), commitFiles.join('\n') + '\n');
1425
+ console.log(`Commit exactly this run's ${commitFiles.length} file(s):`);
1426
+ // The pathspec-from-file ARGUMENT and its CONTENTS must both be REPO-relative: `git -C`
1427
+ // applies REPO as a prefix to both. Proven by POC (git 2.55): an absolute REPO-relative
1428
+ // argument path combined with `-C`-prefixed contents double-prefixes; a REPO-relative
1429
+ // argument path with REPO-relative contents is the one combination that works whether or
1430
+ // not `-C` is present, so the printed recipe always uses it — no fork for the common case.
1431
+ const repoAbs = path.resolve(REPO);
1432
+ const gitCmd = repoAbs === process.cwd() ? 'git' : `git -C '${repoAbs}'`;
1433
+ console.log(` ${gitCmd} add --pathspec-from-file=${ARTIFACTS_REL}/commit-add.txt && `
1434
+ + `${gitCmd} commit -m "docs: reorg" --pathspec-from-file=${ARTIFACTS_REL}/commit-files.txt`);
1336
1435
  }
1337
- console.log(' git commit -m "docs: reorg"');
1338
1436
  } catch (e) {
1339
1437
  console.error(` WARN could not print the commit advisory: ${e.message}`);
1340
1438
  }
@@ -1364,6 +1462,7 @@ function archiveOrThrow(src, dest) {
1364
1462
  + (r.links ? `, ${r.links} link(s) rewritten` : '')
1365
1463
  + (r.failures.length ? `, FOLLOW-UP FAILED: ${r.failures.join('; ')}` : ''));
1366
1464
  noteMoved(r.rel); noteLinks(r.linkFiles.map(x => x.file));
1465
+ noteMovedFrom(src, r.rel);
1367
1466
  if (r.failures.length) {
1368
1467
  const e = new Error(`the move above SUCCEEDED — ${src} is now at ${r.rel}. But ${r.failures.join('; ')}\n`
1369
1468
  + `Fix that, then re-run \`scan\` (and redo labels) — do NOT re-run \`archive\` for `
@@ -1658,6 +1757,22 @@ const ARCHIVAL_STATUS_RE = /\b(CLOSED|ARCHIVAL|ARCHIVED|SUPERSEDED|WITHDRAWN|RET
1658
1757
  // (`REUSE-PREPROBE-PREREG.md`), not always a prefix.
1659
1758
  const LOGS_FILENAME_RE = /\b(PREREG|LEARNINGS|REPORT|RESULTS|POSTMORTEM|RETRO)\b/;
1660
1759
 
1760
+ // Gap 5 (docs-builder-v3-spec follow-up): a SECOND, weaker prior read from the doc's own
1761
+ // heading text (H1 + its first few H2s), for the case where the filename carries no signal at
1762
+ // all. Case-INsensitive on purpose here, unlike ARCHIVAL_STATUS_RE/LOGS_FILENAME_RE above —
1763
+ // those are anchored to a SHOUTED self-declaration or a filename convention, both of which are
1764
+ // real author signals only in their exact (caps/prefix) form; a heading is ordinary prose
1765
+ // ("Postmortem: the outage", "How to configure X"), so demanding shouted case here would just
1766
+ // never fire. Checked LAST, after every stronger path/status/filename/residency signal above,
1767
+ // and it is still only a PRIOR — the classification interview decides, same as every row.
1768
+ const HEADING_ARCHIVE_RE = /\b(deprecated|obsolete|retired|superseded|no longer (?:used|maintained|relevant))\b/i;
1769
+ const HEADING_LOGS_RE = /\b(postmortem|retrospective|retro|investigation|incident report|experiment|proof of concept|\bpoc\b|session log|findings|results)\b/i;
1770
+ // Bare "guide"/"reference" were tried first and DROPPED: a real fixture titled plainly "Guide"
1771
+ // (an ordinary product doc, nothing generic about it) false-positived immediately — the same
1772
+ // failure species as FROZEN in ARCHIVAL_STATUS_RE's own comment above. Only compound phrases
1773
+ // that are near-exclusively about repo-wide, not-product-specific knowledge stay.
1774
+ const HEADING_WIKI_RE = /\b(conventions?|how[- ]to|style guide|reference guide|glossary)\b/i;
1775
+
1661
1776
  // Never reorged, at ANY depth: the repo's entry-point/contract docs. Moving a README or a
1662
1777
  // CLAUDE.md into archive/ breaks the thing every human and agent reads first. Bare LICENSE /
1663
1778
  // NOTICE have no .md extension and are already excluded by walkMd's extension filter.
@@ -1666,6 +1781,13 @@ const PROTECTED_NAMES = new Set([
1666
1781
  'CHANGELOG.md', 'LICENSE.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'SECURITY.md',
1667
1782
  'CLAUDE.md', 'AGENTS.md', 'AGENT.md',
1668
1783
  ]);
1784
+ // Case-INSENSITIVE on purpose (regression): a filesystem that happily has both `README.md`
1785
+ // and `readme.md` protects both — this is a real-world filename, not a hypothetical, and the
1786
+ // three call sites below (walkMd, doArchive, cleanup) must never disagree on the answer, which
1787
+ // is why this is the one chokepoint they all go through instead of three separate `.has()`
1788
+ // checks drifting apart.
1789
+ const PROTECTED_NAMES_LC = new Set([...PROTECTED_NAMES].map(n => n.toLowerCase()));
1790
+ const isProtectedName = name => PROTECTED_NAMES_LC.has(name.toLowerCase());
1669
1791
 
1670
1792
  const DEFAULT_OVERSIZED_LINES = 500; // a starting default, UNMEASURED — see docs-builder.md
1671
1793
 
@@ -1695,7 +1817,7 @@ function isIncludeStub(lines) {
1695
1817
  function classifyDoc(rel, text) {
1696
1818
  const lines = splitLines(text);
1697
1819
  const mask = fenceMask(lines);
1698
- const { h1 } = headings(lines, mask);
1820
+ const { h1, heads } = headings(lines, mask);
1699
1821
  const snip = snippet(lines, mask, 0, lines.length, 200);
1700
1822
  const opening = lines.slice(0, 20).join(' ').slice(0, 2000);
1701
1823
  const ceiling = +process.env.OVERSIZED_LINES || DEFAULT_OVERSIZED_LINES;
@@ -1711,6 +1833,29 @@ function classifyDoc(rel, text) {
1711
1833
  return row('archive', 'filename matches an archive-shaped pattern (weak signal, no content confirmation)');
1712
1834
  if (LOGS_FILENAME_RE.test(path.basename(rel)))
1713
1835
  return row('logs', 'filename matches an experiment-record pattern (PREREG/LEARNINGS/REPORT/RESULTS/POSTMORTEM/RETRO) — weak signal, no content confirmation');
1836
+
1837
+ // Gap 4: a file already resident under docs/product, docs/wiki or docs/logs carries ITS
1838
+ // OWN current bucket forward as its prior — it is re-checked every run (only docs/archive
1839
+ // stays frozen, so archive-resident files never reach classifyDoc at all: walkMd's default
1840
+ // skip keeps that directory out of the walk entirely). Checked AFTER the stronger
1841
+ // path/status/filename signals above, on purpose: those can still override mere residency
1842
+ // (e.g. a product-resident doc whose content now shouts DEPRECATED still suggests archive).
1843
+ const posixRel = rel.split(path.sep).join('/');
1844
+ for (const bucket of ['product', 'wiki', 'logs'])
1845
+ if (posixRel.startsWith(REORG_DEST[bucket] + '/'))
1846
+ return row(bucket, `already resident in ${REORG_DEST[bucket]}/ — re-checked every run`);
1847
+
1848
+ // Gap 5: a weaker secondary prior read from the doc's own heading text (H1 + its first 3
1849
+ // H2s), for a file whose filename carries no signal at all. Still only a prior — see the
1850
+ // regexes' own comment for why this is case-insensitive unlike the two above it.
1851
+ const headingText = [h1, ...heads.filter(h => h.lvl === 2).slice(0, 3).map(h => h.text)].join(' ');
1852
+ if (HEADING_ARCHIVE_RE.test(headingText))
1853
+ return row('archive', 'heading text (H1 + early H2s) suggests retired/superseded content');
1854
+ if (HEADING_LOGS_RE.test(headingText))
1855
+ return row('logs', 'heading text (H1 + early H2s) suggests a one-time investigation/experiment/report');
1856
+ if (HEADING_WIKI_RE.test(headingText))
1857
+ return row('wiki', 'heading text (H1 + early H2s) suggests generic reference/how-to content');
1858
+
1714
1859
  if (!h1) {
1715
1860
  if (isIncludeStub(lines))
1716
1861
  return row('product', 'include stub');
@@ -1720,10 +1865,17 @@ function classifyDoc(rel, text) {
1720
1865
  // that default is gone with it — nothing moves until the interview says so.
1721
1866
  return row('product', 'no H1 — no strong signal, model decides');
1722
1867
  }
1723
- return row('product', 'structured (has an H1), no archive/logs signal');
1868
+ return row('product', 'structured (has an H1), no archive/logs/wiki signal');
1724
1869
  }
1725
1870
 
1726
- function walkMd(dir, base, out) {
1871
+ // `enter`: bucket dir names this walk should descend into DESPITE the reserved-name skip
1872
+ // below — used by discover()'s own default docs/ walk (gap 4: product/wiki/logs are
1873
+ // re-checked every run; archive alone stays frozen, so it is never passed here). Every OTHER
1874
+ // caller (wholeCorpusFiles' root walk, and any explicit `discover <dir>`/`reorg <dir>` whose
1875
+ // named dir IS itself one of these — the skip only ever applies to a CHILD name, never the
1876
+ // walk's own starting dir) keeps the old default: skip all four, so wholeCorpusFiles' own
1877
+ // explicit product/archive/logs calls stay the only source of those rows and nothing doubles.
1878
+ function walkMd(dir, base, out, enter = new Set()) {
1727
1879
  for (const name of fs.readdirSync(dir, { withFileTypes: true })) {
1728
1880
  const abs = path.join(dir, name.name), rel = path.join(base, name.name);
1729
1881
  if (name.isDirectory()) {
@@ -1731,11 +1883,11 @@ function walkMd(dir, base, out) {
1731
1883
  // machine/tool state (.git, .github, .claude, .factory, .opencode, .amp, .docs-builder)
1732
1884
  // and node_modules is vendored — moving a .md out of those is never wanted.
1733
1885
  if (name.name.startsWith('.') || name.name === 'node_modules') continue;
1734
- if (['wiki', 'archive', 'product', 'logs'].includes(name.name)) continue;
1735
- walkMd(abs, rel, out);
1886
+ if (['wiki', 'archive', 'product', 'logs'].includes(name.name) && !enter.has(name.name)) continue;
1887
+ walkMd(abs, rel, out, enter);
1736
1888
  } else if (name.isFile() && name.name.endsWith('.md')) {
1737
1889
  // Entry-point/contract docs are never subject to reorg, wherever they sit.
1738
- if (PROTECTED_NAMES.has(name.name)) continue;
1890
+ if (isProtectedName(name.name)) continue;
1739
1891
  out.push(rel);
1740
1892
  }
1741
1893
  }
@@ -1760,11 +1912,37 @@ function walkMd(dir, base, out) {
1760
1912
  // only accepts a currently-VALID bucket — a legacy pre-v3 value (e.g. 'oversized', 'review')
1761
1913
  // is dropped, not carried, so it starts unclassified instead of failing apply-reorg's schema check.
1762
1914
  function discover(root) {
1763
- const rootRel = root || 'docs';
1764
- const rootAbs = path.join(REPO, rootRel);
1765
- if (!fs.existsSync(rootAbs)) die(`no such directory: ${rootRel}`);
1766
- const files = [];
1767
- walkMd(rootAbs, rootRel, files);
1915
+ // ROOT is not a recognised env var anywhere in this script (REPO is the repo-root override;
1916
+ // easy to confuse the two). A caller that sets ROOT expecting it to scope the scan gets
1917
+ // silently ignored otherwise loud, not silent, same law as every other guard in this file.
1918
+ if (process.env.ROOT)
1919
+ console.error(`WARN: ROOT=${process.env.ROOT} is ignored — pass the folder as an argument `
1920
+ + '(`discover <dir>` / `reorg <dir>`), not an env var.');
1921
+ // Scan scope, no dir argument (gap 1): ONLY (a) .md files sitting directly at the repo root
1922
+ // (top level, not recursive) and (b) everything under docs/ (recursive, gap 4: product/wiki/
1923
+ // logs are re-checked every run there — only docs/archive stays skipped/frozen, via walkMd's
1924
+ // default `enter` set). Every other .md file in the repo is out of scope — never listed,
1925
+ // never moved. `discover <dir>` / `reorg <dir>` keep scoping to exactly that one directory,
1926
+ // unchanged (walkMd's own default skip-all-four behaviour, same as always).
1927
+ let rootRel, files = [];
1928
+ if (root) {
1929
+ rootRel = root;
1930
+ const rootAbs = path.join(REPO, rootRel);
1931
+ if (!fs.existsSync(rootAbs)) die(`no such directory: ${rootRel}`);
1932
+ walkMd(rootAbs, rootRel, files);
1933
+ } else {
1934
+ rootRel = '.';
1935
+ for (const name of fs.readdirSync(REPO, { withFileTypes: true }))
1936
+ if (name.isFile() && name.name.endsWith('.md') && !isProtectedName(name.name))
1937
+ files.push(name.name);
1938
+ const docsAbs = path.join(REPO, 'docs');
1939
+ if (fs.existsSync(docsAbs)) walkMd(docsAbs, 'docs', files, new Set(['product', 'wiki', 'logs']));
1940
+ if (!files.length)
1941
+ console.log('no docs/ directory and no loose .md files at the repo root — nothing to scan '
1942
+ + '(entry-point files like README.md, CLAUDE.md and CHANGELOG.md are never moved, and a '
1943
+ + '.md file elsewhere in the repo is out of scope by design — pass `discover <dir>` to '
1944
+ + 'scan it explicitly).');
1945
+ }
1768
1946
  const planFile = path.join(ARTIFACTS, 'reorg-plan.json');
1769
1947
  const prevBuckets = new Map();
1770
1948
  if (fs.existsSync(planFile)) {
@@ -1778,7 +1956,7 @@ function discover(root) {
1778
1956
  }
1779
1957
  const rows = files.map(rel =>
1780
1958
  ({ ...classifyDoc(rel, read(rel)), bucket: prevBuckets.get(rel) || '' }));
1781
- const bySuggested = { product: 0, logs: 0, archive: 0 };
1959
+ const bySuggested = { product: 0, logs: 0, archive: 0, wiki: 0 };
1782
1960
  for (const r of rows) bySuggested[r.suggested]++;
1783
1961
  const oversizedCount = rows.filter(r => r.oversized).length;
1784
1962
  write({ generated: new Date().toISOString(), root: rootRel, rows }, 'reorg-plan.json');
@@ -1797,9 +1975,12 @@ function discover(root) {
1797
1975
  // buckets already set, so this has to report what is actually in the plan.
1798
1976
  const filled = rows.filter(r => r.bucket).length;
1799
1977
  if (!rows.length) {
1800
- console.log(`plan written to docs/.docs-builder/reorg-plan.json — 0 rows. Nothing outside `
1801
- + 'product/, logs/ and archive/ is left to classify — the corpus is already sorted. '
1802
- + '`apply-reorg` will only rescan and rebuild the index.');
1978
+ console.log(`plan written to docs/.docs-builder/reorg-plan.json — 0 rows. docs/archive/ is `
1979
+ + 'frozen and never re-checked; docs/product/, docs/wiki/ and docs/logs/ ARE re-checked '
1980
+ + 'automatically on every bare `discover`/`reorg` (gap 4) 0 rows here means there is '
1981
+ + 'nothing in scope at all yet. `apply-reorg` will only rescan and rebuild the index. To '
1982
+ + 'scope a check to one directory outside the default root+docs/ coverage, run '
1983
+ + '`node $DB discover <dir>` (or `/docs-builder reorg <dir>`).');
1803
1984
  } else if (!filled) {
1804
1985
  console.log(`plan written to docs/.docs-builder/reorg-plan.json — every row's \`suggested\` `
1805
1986
  + 'is a PRIOR, not a verdict, and `bucket` is empty. Run the classification interview '
@@ -1880,7 +2061,7 @@ function scanWholeCorpus() {
1880
2061
  return corpus.length;
1881
2062
  }
1882
2063
 
1883
- const REORG_DEST = { product: 'docs/product', logs: 'docs/logs', archive: 'docs/archive' };
2064
+ const REORG_DEST = { product: 'docs/product', logs: 'docs/logs', archive: 'docs/archive', wiki: 'docs/wiki' };
1884
2065
  const VALID_BUCKETS = new Set(Object.keys(REORG_DEST));
1885
2066
  // bucket values a PRE-v3 reorg-plan.json could hold — neither exists any more ('oversized'
1886
2067
  // was a bucket, now a boolean; 'review' is gone outright, see classifyDoc). Distinguishing
@@ -1949,7 +2130,7 @@ function injectClaudeMdPointer() {
1949
2130
 
1950
2131
  // v3 reorg (docs-builder-v3-spec.md, "four buckets"): the interview, not this function, does
1951
2132
  // the classifying — this only executes an ALREADY-approved plan. It refuses outright if any
1952
- // row's `bucket` isn't one of the three real buckets: an empty bucket means the interview
2133
+ // row's `bucket` isn't one of the four real buckets: an empty bucket means the interview
1953
2134
  // hasn't happened, and a stale 'oversized'/'review' bucket means the plan predates this
1954
2135
  // version's schema. Oversized rows are no longer skipped — they move like everything else
1955
2136
  // (size decides splittable, not sorted) and come back as split candidates at their NEW path.
@@ -1966,9 +2147,9 @@ function applyReorg(planFile) {
1966
2147
  ? ` This plan predates v3's four-bucket schema ('oversized'/'review' no longer `
1967
2148
  + 'exist as buckets) — re-run `discover` to regenerate it, then classify.'
1968
2149
  : ' Run the classification interview (docs-builder.md): fill every row\'s `bucket` '
1969
- + '(product/logs/archive), get the user\'s approval, then re-run.'));
2150
+ + '(product/wiki/logs/archive), get the user\'s approval, then re-run.'));
1970
2151
  }
1971
- const results = { moved: 0, skipped: 0, artifactsSynced: 0, linksRewritten: 0,
2152
+ const results = { moved: 0, skipped: 0, unchanged: 0, artifactsSynced: 0, linksRewritten: 0,
1972
2153
  syncFailed: 0, dirsRemoved: 0, claudeMdUpdated: false };
1973
2154
  // Set once, up front, from the SAME plan the loop below reads row.file from — every row
1974
2155
  // this run already commits to bucket:'archive' is exempt from every rewrite the run makes,
@@ -1980,18 +2161,83 @@ function applyReorg(planFile) {
1980
2161
  const sourceDirs = [];
1981
2162
  const linkFilesTouched = []; // dedup'd by flushCommitAdvisory() at the end of the run
1982
2163
  const movedDestPaths = []; // every successful move's NEW path, same accumulator
2164
+
2165
+ // Gap 6: `logs` is the ONE bucket that may nest, ONE level. product/wiki/archive stay flat
2166
+ // (REORG_DEST[row.bucket] alone). The group is the FIRST path segment under docs/ — a
2167
+ // special subfolder is one self-explanatory group, e.g. every one of a repo's POCs under
2168
+ // docs/fwd/ stays together as `fwd`, however deep a given file actually sits inside it —
2169
+ // UNLESS that first segment is itself a bucket name: `product`/`wiki`/`archive` mean flat,
2170
+ // never a group, and `logs` means the group is the SECOND segment instead (an existing
2171
+ // docs/logs/<group>/... keeps its own group on a re-check, rather than a ratchet). A file
2172
+ // with no first segment at all — loose at the repo root, or sitting directly in docs/ —
2173
+ // is flat. REJECTED first attempt (orchestrator review, real bugs): "the file's own
2174
+ // immediate parent directory name" — that put docs/product/x.md (bucket-name parent) under
2175
+ // docs/logs/product/ instead of flat, and put docs/fwd/poc/deep/y.md under docs/logs/deep/
2176
+ // (its own parent) instead of docs/logs/fwd/ (the group it actually belongs with). One
2177
+ // function, used by both the pre-reservation pass and the move pass right below it, so they
2178
+ // can never compute two different answers for the same row.
2179
+ const destDirFor = row => {
2180
+ if (row.bucket !== 'logs') return REORG_DEST[row.bucket];
2181
+ const segs = row.file.split(path.sep).join('/').split('/');
2182
+ const dirSegs = segs.slice(0, -1); // drop the filename itself
2183
+ const under = dirSegs[0] === 'docs' ? dirSegs.slice(1) : dirSegs; // strip a leading docs/
2184
+ const seg1 = under[0];
2185
+ if (seg1 === undefined || seg1 === 'product' || seg1 === 'wiki' || seg1 === 'archive')
2186
+ return REORG_DEST.logs; // loose (root, or directly in docs/), or a bucket name: flat
2187
+ if (seg1 === 'logs') {
2188
+ const seg2 = under[1]; // e.g. resident docs/logs/<seg2>/x.md: keep its own group
2189
+ return seg2 ? path.posix.join(REORG_DEST.logs, seg2) : REORG_DEST.logs;
2190
+ }
2191
+ // A real, non-bucket subdir name (under docs/, or outside docs/ via an explicit
2192
+ // `discover <dir>` scan) IS the group — one level, no matter how deep the file actually
2193
+ // sits inside it (docs/fwd/poc/deep/y.md flattens to docs/logs/fwd/y.md, not .../deep/).
2194
+ return path.posix.join(REORG_DEST.logs, seg1);
2195
+ };
2196
+ // Gap 4 fallout: a resident row (already correctly bucketed, discovered in PLACE — new
2197
+ // since product/wiki/logs are now re-checked every run) must keep its OWN name even when a
2198
+ // DIFFERENT row elsewhere shares the same basename and is scheduled to move into the same
2199
+ // destDir. REPRODUCED pre-fix: docs/product/TAKEN.md (resident) got bumped to
2200
+ // docs/product/TAKEN-2.md because a loose docs/TAKEN.md, sharing the basename, happened to
2201
+ // be visited first in the single collision-counting pass and claimed the name first. Fix:
2202
+ // pre-reserve every resident row's own slot before any row's name gets disambiguated, so a
2203
+ // moving row is the one that yields, never the file that was already correctly in place.
2204
+ for (const row of plan.rows) {
2205
+ const destDir0 = destDirFor(row);
2206
+ const key0 = destDir0 + '/' + path.basename(row.file);
2207
+ if (path.join(destDir0, path.basename(row.file)) === row.file) usedNames.set(key0, 1);
2208
+ }
1983
2209
  for (const row of plan.rows) {
1984
- const destDir = REORG_DEST[row.bucket];
2210
+ const destDir = destDirFor(row);
1985
2211
  let base = path.basename(row.file);
1986
- const n = (usedNames.get(destDir + '/' + base) || 0) + 1;
1987
- usedNames.set(destDir + '/' + base, n);
1988
- if (n > 1) { const ext = path.extname(base); base = base.slice(0, -ext.length) + `-${n}` + ext; }
2212
+ const naiveDest = path.join(destDir, base);
2213
+ let dest;
2214
+ if (naiveDest === row.file) {
2215
+ // Already reserved for itself above — never disambiguated away from its own path.
2216
+ dest = row.file;
2217
+ } else {
2218
+ // Reserve/disambiguate the name FIRST, same order as before a `reorg <dir>` re-check
2219
+ // could land here — a row scheduled to move here still claims its own name so a LATER
2220
+ // row cannot collide onto it either.
2221
+ const n = (usedNames.get(destDir + '/' + base) || 0) + 1;
2222
+ usedNames.set(destDir + '/' + base, n);
2223
+ if (n > 1) { const ext = path.extname(base); base = base.slice(0, -ext.length) + `-${n}` + ext; }
2224
+ dest = path.join(destDir, base);
2225
+ }
2226
+ // `reorg <dir>` re-checks files ALREADY inside their bucket (Change 3) — a row whose
2227
+ // destination equals its current path used to reach moveDoc anyway and fail doArchive's
2228
+ // "refusing to overwrite" guard (the destination is itself), counted as a false SKIP. It's
2229
+ // not a skip: nothing is wrong, the file already lives where it should.
2230
+ if (dest === row.file) {
2231
+ console.log(` ${row.file} stays in ${destDir}`);
2232
+ results.unchanged++;
2233
+ continue;
2234
+ }
1989
2235
  // Only a failed MOVE skips the file. A failed follow-up is a warning on a file that has
1990
2236
  // already moved — counting it as skipped would be a lie, and stopping the loop would
1991
2237
  // strand the rest of the plan half-applied.
1992
2238
  let r;
1993
2239
  try {
1994
- r = moveDoc(row.file, path.join(destDir, base));
2240
+ r = moveDoc(row.file, dest);
1995
2241
  } catch (e) {
1996
2242
  console.error(`SKIP ${row.file}: ${e.message}`);
1997
2243
  results.skipped++;
@@ -2000,9 +2246,10 @@ function applyReorg(planFile) {
2000
2246
  console.log(` ${row.file} -> ${r.rel}`);
2001
2247
  results.moved++;
2002
2248
  movedDestPaths.push(r.rel);
2249
+ noteMovedFrom(row.file, r.rel);
2003
2250
  results.artifactsSynced += r.artifacts;
2004
2251
  results.linksRewritten += r.links;
2005
- sourceDirs.push(path.dirname(path.join(REPO, row.file)));
2252
+ sourceDirs.push(path.dirname(path.resolve(REPO, row.file)));
2006
2253
  if (row.oversized) splitCandidates.push({ file: r.rel, bucket: row.bucket, lines: row.lines });
2007
2254
  for (const { file, n } of r.linkFiles) { console.log(` ${file}: ${n} link(s) -> ${r.rel}`); linkFilesTouched.push(file); }
2008
2255
  for (const f of r.failures) {
@@ -2012,7 +2259,9 @@ function applyReorg(planFile) {
2012
2259
  }
2013
2260
  // Only directories the moves THIS RUN emptied are candidates — never a dir this run never
2014
2261
  // touched, even if it happens to be empty already (that's not ours to remove).
2015
- const rootAbs = path.join(REPO, plan.root || 'docs');
2262
+ // resolve, not join: with a relative REPO and root '.', join yields 'src' — never prefixed by
2263
+ // rootAbs + sep — so collectEmptyDirs would silently skip every dir this run emptied.
2264
+ const rootAbs = path.resolve(REPO, plan.root || 'docs');
2016
2265
  const removedDirs = sourceDirs.length ? collectEmptyDirs(rootAbs, sourceDirs) : [];
2017
2266
  results.dirsRemoved = removedDirs.length;
2018
2267
  for (const dir of removedDirs)
@@ -2073,7 +2322,7 @@ function applyReorg(planFile) {
2073
2322
  // model by default, so it never has labels.json to validate against — that capability didn't
2074
2323
  // move, it stayed exactly where it already lived: the standalone `validate`/`index` commands,
2075
2324
  // unchanged, still runnable by hand once labels.json exists.
2076
- function reorg() {
2325
+ function reorg(dir) {
2077
2326
  // discover/apply-reorg/lint/due each write a DIFFERENT artifact, and every one of them
2078
2327
  // honours the same `OUT` override — same trap reconcile's own OUT guard existed to catch.
2079
2328
  if (process.env.OUT) {
@@ -2095,7 +2344,7 @@ function reorg() {
2095
2344
  console.log('');
2096
2345
  }
2097
2346
  console.log('== discover ==');
2098
- discover();
2347
+ discover(dir);
2099
2348
  // v3: classification is the model's job, behind an approval gate (docs-builder-v3-spec.md
2100
2349
  // §4). `reorg` must not silently proceed past a plan the interview hasn't touched yet —
2101
2350
  // that would be the exact failure the gate exists to prevent, just moved one layer up.
@@ -2230,7 +2479,7 @@ function cleanup(files) {
2230
2479
  + 'named. Run it once per file.');
2231
2480
  const [file] = files;
2232
2481
  if (path.extname(file) !== '.md') die(`cleanup: ${file} is not a .md file`);
2233
- if (PROTECTED_NAMES.has(path.basename(file)))
2482
+ if (isProtectedName(path.basename(file)))
2234
2483
  die(`cleanup: ${file} is a protected entry-point doc (README/CLAUDE.md/etc.) and is `
2235
2484
  + 'never split');
2236
2485
  if (!fs.existsSync(repoPath(file))) die(`cleanup: no such file: ${file}`);
@@ -2363,6 +2612,7 @@ function cleanupApply(file, outlineF, labelsF) {
2363
2612
  for (const m of r.artifactNotes) console.log(` ${m}`);
2364
2613
  if (r.failures.length) console.error(` WARN core page relocated, but ${r.failures.join('; ')}`);
2365
2614
  noteMoved(r.rel); noteLinks(r.linkFiles.map(x => x.file));
2615
+ noteMovedFrom(from, r.rel);
2366
2616
 
2367
2617
  // FIELD BUG (real, reproduced): archiveOrThrow above rewrote EVERY inbound reference
2368
2618
  // to point at docs/archive/, because at that moment the archive genuinely was the
@@ -2417,7 +2667,8 @@ function cleanupApply(file, outlineF, labelsF) {
2417
2667
 
2418
2668
  // Machine state has one home. Callers can override with OUT, but the default must never
2419
2669
  // scatter JSON into whatever directory the user happened to be standing in.
2420
- const ARTIFACTS = path.join(REPO, 'docs/.docs-builder');
2670
+ const ARTIFACTS_REL = 'docs/.docs-builder';
2671
+ const ARTIFACTS = path.join(REPO, ARTIFACTS_REL);
2421
2672
  function write(obj, fallback) {
2422
2673
  const dest = process.env.OUT || path.join(ARTIFACTS, fallback);
2423
2674
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -2425,6 +2676,10 @@ function write(obj, fallback) {
2425
2676
  }
2426
2677
 
2427
2678
  const [cmd, ...rest] = process.argv.slice(2);
2679
+ // Taken BEFORE the command runs, only for commands that can move a file — the point is to
2680
+ // capture what was dirty BEFORE this run touched anything, for flushCommitAdvisory's WARN.
2681
+ const MOVE_COMMANDS = new Set(['archive', 'apply-reorg', 'reorg', 'cleanup-apply']);
2682
+ if (MOVE_COMMANDS.has(cmd)) RUN.dirty = snapshotDirty();
2428
2683
  switch (cmd) {
2429
2684
  case 'scan': scan(rest); break;
2430
2685
  case 'validate': validate(rest[0], rest[1]); break;
@@ -2437,7 +2692,7 @@ switch (cmd) {
2437
2692
  case 'lint': lint(rest); break;
2438
2693
  case 'discover': discover(rest[0]); break;
2439
2694
  case 'apply-reorg': applyReorg(rest[0]); break;
2440
- case 'reorg': reorg(); break;
2695
+ case 'reorg': reorg(rest[0]); break;
2441
2696
  case 'cleanup': cleanup(rest); break;
2442
2697
  case 'cleanup-apply': cleanupApply(rest[0], rest[1], rest[2]); break;
2443
2698
  default: