liteagents 3.5.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.
- package/CHANGELOG.md +104 -0
- package/installer/package-manager.js +3 -1
- package/package.json +1 -1
- package/packages/ampcode/skills/branch-review/SKILL.md +34 -3
- package/packages/ampcode/skills/docs-builder/SKILL.md +168 -70
- package/packages/ampcode/skills/docs-builder/docs-builder.cjs +325 -70
- package/packages/ampcode/skills/release/SKILL.md +38 -8
- package/packages/ampcode/skills/security/SKILL.md +6 -3
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package-lock.json +3 -3
- package/packages/claude/skills/branch-review/SKILL.md +34 -3
- package/packages/claude/skills/docs-builder/SKILL.md +168 -69
- package/packages/claude/skills/docs-builder/docs-builder.cjs +325 -70
- package/packages/claude/skills/release/SKILL.md +38 -8
- package/packages/claude/skills/security/SKILL.md +6 -3
- package/packages/droid/commands/branch-review.md +34 -3
- package/packages/droid/commands/docs-builder/docs-builder.cjs +325 -70
- package/packages/droid/commands/docs-builder.md +168 -70
- package/packages/droid/commands/release.md +38 -8
- package/packages/droid/commands/security.md +6 -3
- package/packages/opencode/command/branch-review.md +34 -3
- package/packages/opencode/command/docs-builder/docs-builder.cjs +325 -70
- package/packages/opencode/command/docs-builder.md +168 -70
- package/packages/opencode/command/release.md +38 -8
- package/packages/opencode/command/security.md +6 -3
- package/tools/ampcode/manifest-template.json +0 -1
- package/tools/claude/manifest-template.json +0 -1
- package/tools/droid/manifest-template.json +0 -1
- package/tools/opencode/manifest-template.json +0 -1
|
@@ -774,10 +774,26 @@ function indexRow(rel, dest, includeH2) {
|
|
|
774
774
|
return row;
|
|
775
775
|
}
|
|
776
776
|
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 || !
|
|
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
|
-
|
|
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
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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`
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
console.log(`
|
|
1334
|
-
|
|
1335
|
-
console.log(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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.
|
|
1801
|
-
+ 'product/,
|
|
1802
|
-
+ '`
|
|
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
|
|
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 =
|
|
2210
|
+
const destDir = destDirFor(row);
|
|
1985
2211
|
let base = path.basename(row.file);
|
|
1986
|
-
const
|
|
1987
|
-
|
|
1988
|
-
if (
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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:
|