@sabaiway/agent-workflow-memory 3.2.0 → 4.1.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 +114 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/archive-changelog.mjs +300 -192
- package/references/scripts/archive-changelog.test.mjs +341 -0
- package/references/scripts/archive-conservation.test.mjs +466 -0
- package/references/scripts/archive-decisions.mjs +374 -32
- package/references/scripts/archive-decisions.test.mjs +615 -2
- package/references/scripts/archive-issues.mjs +344 -108
- package/references/scripts/archive-issues.test.mjs +762 -32
- package/references/scripts/archiver-structure.test.mjs +39 -0
- package/references/scripts/markdown-blocks.mjs +143 -0
- package/references/scripts/markdown-blocks.test.mjs +310 -0
- package/references/templates/changelog.md +3 -1
- package/references/templates/known_issues.md +13 -5
|
@@ -17,25 +17,45 @@
|
|
|
17
17
|
// plateaus at O(governing), never O(cumulative). Not a ledger.
|
|
18
18
|
//
|
|
19
19
|
// Modes:
|
|
20
|
-
// (default) rotate: explode the oldest HOT entries beyond the cap into adr/ records,
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
20
|
+
// (default) rotate: explode the oldest HOT entries beyond the cap into adr/ records,
|
|
21
|
+
// REWRITE the inbound `decisions.md#ad-NNN…` links across docs/ai/** to the
|
|
22
|
+
// record files (fragment preserved), then regenerate the navigator +
|
|
23
|
+
// docs/ai/index.md (item (h)). Monoliths present → a LOUD legacy-guard refusal
|
|
24
|
+
// ("run --migrate first"); it never half-explodes.
|
|
25
|
+
// --check verify HOT cap + adr/ store integrity + the legacy guard + navigator freshness
|
|
26
|
+
// + reference integrity (every inbound `decisions.md#ad-NNN…` anchor must
|
|
27
|
+
// resolve to the CURRENT HOT window — an archived id is stale, not resolving —
|
|
28
|
+
// and every `adr/AD-NNN-slug.md` link must name an existing record FILE); exit 1
|
|
29
|
+
// listing every breach with file:line. A STATED skip (exit 0) only when NO ADR
|
|
30
|
+
// substrate exists (neither decisions.md NOR docs/ai/adr/) AND the docs tree
|
|
31
|
+
// carries no matching ADR reference.
|
|
26
32
|
// --migrate one-time retirement of the 3-tier monoliths → per-file adr/ records. Dry-run by
|
|
27
|
-
// default (prints the file set + id diff + conservation proof
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
33
|
+
// default (prints the file set + id diff + conservation proof + the planned
|
|
34
|
+
// inbound-rewrite set, writes nothing).
|
|
35
|
+
// --migrate --apply writes a durable pre-delete snapshot, writes the records, rewrites the inbound
|
|
36
|
+
// links (monolith-form anchors too, targets computed relative to the linking
|
|
37
|
+
// file) and the retained HOT preamble, and only THEN removes the monoliths —
|
|
38
|
+
// gated on conservation AND the snapshot. Re-run skips byte-identical records
|
|
39
|
+
// (crash-resumable). Combining with --dry-run is a loud pre-spend refusal.
|
|
31
40
|
// --write-navigator regenerate docs/ai/adr/log.md AND re-trigger the index regen (the authoring /
|
|
32
41
|
// supersession write-side; the --write-index analog). With --dry-run it runs
|
|
33
42
|
// EXACTLY the same validation (parse, half-migrated guard, store integrity) and
|
|
34
43
|
// stops before every write — the read-only preflight a guarded caller needs to
|
|
35
44
|
// earn a go-ahead without risking a partial write.
|
|
36
|
-
// --dry-run print the planned rotation move-set,
|
|
45
|
+
// --dry-run print the planned rotation move-set + inbound-rewrite set (file:line, old
|
|
46
|
+
// target → new target), change nothing.
|
|
37
47
|
// --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs).
|
|
38
48
|
//
|
|
49
|
+
// Reference-scan boundary + stated limitations: the inbound-link scan covers docs/ai/** ONLY (links
|
|
50
|
+
// in README / agent entry points are out of scope); the ADR corpus surfaces themselves are NEVER
|
|
51
|
+
// rewritten — a rewrite-form link to a moved id inside decisions.md, an adr/ record or a monolith
|
|
52
|
+
// tier is a loud pre-write refusal (convert it, e.g. to the [[AD-NNN]] form, then re-run). Fenced
|
|
53
|
+
// regions never count; inline code is NOT tracked (a backtick-wrapped link is treated as live);
|
|
54
|
+
// matching is line-scoped — a link hand-wrapped across a line break is not matched (the same
|
|
55
|
+
// accepted residual as the hand-wrapped preamble continuation line below); and frontmatter is
|
|
56
|
+
// opaque metadata — an ADR link inside YAML frontmatter is neither rewritten nor checked (it is
|
|
57
|
+
// preserved byte-exactly on every write).
|
|
58
|
+
//
|
|
39
59
|
// FAIL-LOUD invariants (the Issue-009 lesson — never silently glue an entry to the previous body):
|
|
40
60
|
// • every `## ` heading MUST parse canonically as `## AD-NNN — <title>` (AD-\d{3,}) — a malformed
|
|
41
61
|
// heading is exit 1 naming file:line, never a silent merge;
|
|
@@ -43,6 +63,10 @@
|
|
|
43
63
|
// • migration is CONSERVATION-checked before any destructive write: the full multiset
|
|
44
64
|
// {id → sha256(verbatim block)} across the OLD monoliths equals {retained-HOT ∪ written records};
|
|
45
65
|
// a drop / renumber / edited-block / stray adr record fails exit 1 before any remove or overwrite;
|
|
66
|
+
// • inbound-link rewrites are CONSERVATION-checked before the run's first write: every moved-id
|
|
67
|
+
// link rewritten, every other byte of every scanned file identical — any mismatch is exit 1 with
|
|
68
|
+
// nothing written; the cross-file write order is pinned (records → inbound rewrites → HOT rewrite
|
|
69
|
+
// / monolith removal) so every interrupted state re-runs to completion;
|
|
46
70
|
// • a legacy monolith still on disk fails LOUD on default/--check (it is a half-migrated tree).
|
|
47
71
|
//
|
|
48
72
|
// docs/ai here is git-ignored, so the monoliths were NEVER committed (no VCS recovery) — every
|
|
@@ -51,12 +75,13 @@
|
|
|
51
75
|
//
|
|
52
76
|
// Dependency-free, Node >= 22. Deployed into a consumer's scripts/ like its siblings.
|
|
53
77
|
|
|
54
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
55
|
-
import { dirname, resolve, join } from 'node:path';
|
|
78
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
79
|
+
import { dirname, resolve, join, posix } from 'node:path';
|
|
56
80
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
57
81
|
import { spawnSync } from 'node:child_process';
|
|
58
82
|
import { createHash } from 'node:crypto';
|
|
59
83
|
import { tmpdir } from 'node:os';
|
|
84
|
+
import { tokenizeMarkdown } from './markdown-blocks.mjs';
|
|
60
85
|
|
|
61
86
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
62
87
|
const DEFAULT_ROOT = resolve(__dirname, '..');
|
|
@@ -78,8 +103,10 @@ const NAV_RECENT_WINDOW = 15;
|
|
|
78
103
|
|
|
79
104
|
// AD-\d{3,}: 3-digit ids stay valid, AD-1000+ parse; ordering is always NUMERIC (never lexical).
|
|
80
105
|
export const HEADING_RE = /^## AD-(\d{3,}) — (.+)$/;
|
|
81
|
-
|
|
82
|
-
|
|
106
|
+
// AD-SHAPE, deliberately wider than the grammar: the AD- prefix at ANY level and indent (space or
|
|
107
|
+
// tab separated), so `### AD-051 — …`, ` ## AD-051 — …` and a tab-separated form refuse loudly
|
|
108
|
+
// instead of being absorbed as body text.
|
|
109
|
+
const AD_SHAPED_HEADING_RE = /^\s*#{1,6}[ \t]+AD-\d/;
|
|
83
110
|
const RECORD_FILE_RE = /^AD-(\d{3,})-.*\.md$/;
|
|
84
111
|
|
|
85
112
|
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
@@ -126,26 +153,38 @@ const extractLifecycle = (block) => {
|
|
|
126
153
|
return { status, date, supersedes, supersededBy };
|
|
127
154
|
};
|
|
128
155
|
|
|
129
|
-
// Parse one tier's text → { frontmatter, cap, preamble, entries }.
|
|
130
|
-
//
|
|
156
|
+
// Parse one tier's text → { frontmatter, cap, preamble, entries }. The text is read through the
|
|
157
|
+
// shared block tokenizer, so a `## ` line inside a fenced sample is BODY (an ADR documenting a
|
|
158
|
+
// heading format is no longer falsely refused), an unclosed fence is a loud error, and CRLF never
|
|
159
|
+
// changes a parse. Every column-0 H2 heading TOKEN must be a canonical AD heading, and an
|
|
160
|
+
// AD-shaped heading at any other level or indent refuses too — anything else is exit 1 naming
|
|
161
|
+
// file:line (Issue-009).
|
|
131
162
|
export const parseDecisionsText = (text, label) => {
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const fmLines = frontmatter === '' ? 0 : frontmatter.split('\n').length - 1;
|
|
135
|
-
const rest = text.slice(frontmatter.length);
|
|
136
|
-
const lines = rest.split('\n');
|
|
163
|
+
const { frontmatter, frontLines, lines, headings } = tokenizeMarkdown(text, label);
|
|
164
|
+
const fileLine = (index) => frontLines + index + 1;
|
|
137
165
|
|
|
138
166
|
const startIdxs = [];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
167
|
+
const matchByIdx = new Map();
|
|
168
|
+
for (const heading of headings) {
|
|
169
|
+
if (heading.level === 2 && heading.text.startsWith('## ')) {
|
|
170
|
+
const m = HEADING_RE.exec(heading.text);
|
|
171
|
+
if (!m) {
|
|
172
|
+
throw fail(
|
|
173
|
+
1,
|
|
174
|
+
`${label}:${fileLine(heading.index)}: non-canonical H2 heading "${heading.text}" — every "## " heading must be \`## AD-NNN — <title>\` (AD-\\d{3,}; never silently glued to the previous entry; fix the heading, then re-run)`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
startIdxs.push(heading.index);
|
|
178
|
+
matchByIdx.set(heading.index, m);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (AD_SHAPED_HEADING_RE.test(heading.text)) {
|
|
142
182
|
throw fail(
|
|
143
183
|
1,
|
|
144
|
-
`${label}:${
|
|
184
|
+
`${label}:${fileLine(heading.index)}: "${heading.text}" is AD-shaped but not a canonical ADR heading — expected \`## AD-NNN — <title>\` (level 2, column 0). It would previously have been silently treated as body text; fix the heading, then re-run`,
|
|
145
185
|
);
|
|
146
186
|
}
|
|
147
|
-
|
|
148
|
-
});
|
|
187
|
+
}
|
|
149
188
|
|
|
150
189
|
const preambleEnd = startIdxs.length > 0 ? startIdxs[0] : lines.length;
|
|
151
190
|
const preamble = lines.slice(0, preambleEnd).join('\n').trim();
|
|
@@ -153,7 +192,7 @@ export const parseDecisionsText = (text, label) => {
|
|
|
153
192
|
const entries = startIdxs.map((idx, i) => {
|
|
154
193
|
const end = i + 1 < startIdxs.length ? startIdxs[i + 1] : lines.length;
|
|
155
194
|
const blockLines = stripTrailingSeparators(lines.slice(idx, end));
|
|
156
|
-
const m =
|
|
195
|
+
const m = matchByIdx.get(idx);
|
|
157
196
|
const block = blockLines.join('\n');
|
|
158
197
|
return {
|
|
159
198
|
id: m[1],
|
|
@@ -260,6 +299,265 @@ export const verifyConservation = (oldItems, newItems) => {
|
|
|
260
299
|
}
|
|
261
300
|
};
|
|
262
301
|
|
|
302
|
+
// ── inbound reference integrity (the anchor-orphan fix) ─────────────────────────────────
|
|
303
|
+
//
|
|
304
|
+
// Match contract (line-scoped; fenced lines never count): a textual occurrence of the
|
|
305
|
+
// source-file-plus-fragment form. The lookbehind rejects a longer filename
|
|
306
|
+
// (`other-decisions.md#…` is a different file, never a match); a bare `#ad-NNN` has no file part.
|
|
307
|
+
const DOCS_AI_REL = 'docs/ai';
|
|
308
|
+
const HOT_LINK_RE = /(?<![\w.-])decisions\.md#(ad-(\d{3,})[\w-]*)/g;
|
|
309
|
+
const MONOLITH_LINK_RE = /(?<![\w.-])(?:\.\.?\/)*(?:history\/)?decisions-archive(?:-early)?\.md#(ad-(\d{3,})[\w-]*)/g;
|
|
310
|
+
// The right boundary mirrors the lookbehind: `…md.bak` / `…md/child` are DIFFERENT targets, never
|
|
311
|
+
// a match for the base record (the exact-filename contract cuts both ways).
|
|
312
|
+
const RECORD_LINK_RE = /(?<![\w.-])adr\/(AD-(\d{3,})-[A-Za-z0-9-]+\.md)(?![\w./-])/g;
|
|
313
|
+
|
|
314
|
+
const walkDocsMarkdown = (root) => {
|
|
315
|
+
const base = resolve(root, DOCS_AI_REL);
|
|
316
|
+
if (!existsSync(base)) return [];
|
|
317
|
+
const out = [];
|
|
318
|
+
const walk = (dir, relDir) => {
|
|
319
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
320
|
+
const rel = `${relDir}/${entry.name}`;
|
|
321
|
+
if (entry.isDirectory()) {
|
|
322
|
+
walk(join(dir, entry.name), rel);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// read/write both FOLLOW a symlink (out of the tree), and silently skipping one hides a
|
|
326
|
+
// scannable doc or a whole subtree from the reference scan — refuse loudly either way; only
|
|
327
|
+
// a symlink resolving to a plain non-markdown file stays an ignored stray.
|
|
328
|
+
if (entry.isSymbolicLink() && !entry.name.endsWith('.md')) {
|
|
329
|
+
let targetIsDirectory = false;
|
|
330
|
+
try {
|
|
331
|
+
targetIsDirectory = statSync(join(dir, entry.name)).isDirectory();
|
|
332
|
+
} catch {
|
|
333
|
+
throw fail(1, `${rel}: a dangling symlink in the scan tree — the reference scan cannot classify it; remove or materialize it, then re-run`);
|
|
334
|
+
}
|
|
335
|
+
if (targetIsDirectory) {
|
|
336
|
+
throw fail(1, `${rel}: a symlinked directory in the scan tree would hide its subtree from the reference scan — materialize or remove it, then re-run`);
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (entry.name.endsWith('.md')) {
|
|
341
|
+
if (!entry.isFile()) {
|
|
342
|
+
throw fail(1, `${rel}: a markdown name in the scan tree is not a regular file (a symlink or special file) — the reference scan never reads or writes THROUGH it; materialize or remove it, then re-run`);
|
|
343
|
+
}
|
|
344
|
+
out.push(rel);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
walk(base, DOCS_AI_REL);
|
|
349
|
+
return out;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// REWRITE scope: docs/ai/** minus the ADR corpus surfaces (HOT, adr/ records + navigator) and the
|
|
353
|
+
// retired monoliths — those are parsed, conservation-hashed and deleted in the same run; a same-run
|
|
354
|
+
// disk rewrite would poison the crash-resume corpus-union guard and write into removed files.
|
|
355
|
+
const isRewriteScope = (rel) =>
|
|
356
|
+
rel !== HOT_REL && rel !== WARM_REL && rel !== COLD_REL && rel !== NAV_REL && !rel.startsWith(`${ADR_DIR_REL}/`);
|
|
357
|
+
|
|
358
|
+
// The corpus surfaces are never rewritten: a rewrite-form link to a MOVED id anywhere in them is a
|
|
359
|
+
// loud pre-write refusal — the operator converts the link and re-runs; verbatim blocks stay absolute.
|
|
360
|
+
const assertAdrCorpusFreeOfMovedLinks = (root, movedById, corpusRels, linkRes) => {
|
|
361
|
+
const violations = [];
|
|
362
|
+
for (const rel of corpusRels) {
|
|
363
|
+
const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
|
|
364
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
365
|
+
if (fencedLines.has(index)) continue;
|
|
366
|
+
for (const re of linkRes) {
|
|
367
|
+
for (const m of lines[index].matchAll(re)) {
|
|
368
|
+
if (movedById.has(m[2])) violations.push(`${rel}:${frontLines + index + 1}: "${m[0]}"`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (violations.length > 0) {
|
|
374
|
+
throw fail(1, `refusing pre-write: the ADR corpus itself carries rewrite-form links to a moved id — a corpus surface is never rewritten; convert each link (e.g. to the [[AD-NNN]] form), then re-run:\n ${violations.join('\n ')}`);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// A block leaving decisions.md cannot keep a RELATIVE ADR link meaningful: the block moves
|
|
379
|
+
// verbatim (the conservation invariant forbids editing it) while its base directory changes —
|
|
380
|
+
// refuse pre-write when a TO-EXPLODE block links a RETAINED id (the moved-id complement is
|
|
381
|
+
// refused corpus-wide by assertAdrCorpusFreeOfMovedLinks) or carries ANY record-form link
|
|
382
|
+
// (valid from decisions.md, broken from inside adr/).
|
|
383
|
+
const assertMovingBlocksFreeOfRelativeAdrLinks = (root, movedIds, retainedIds) => {
|
|
384
|
+
const { frontLines, lines, fencedLines, headings } = tokenizeMarkdown(readFileSync(resolve(root, HOT_REL), 'utf8'), HOT_REL);
|
|
385
|
+
const violations = [];
|
|
386
|
+
const h2 = headings.filter((h) => h.level === 2 && HEADING_RE.test(h.text));
|
|
387
|
+
for (let i = 0; i < h2.length; i += 1) {
|
|
388
|
+
if (!movedIds.has(HEADING_RE.exec(h2[i].text)[1])) continue;
|
|
389
|
+
const end = i + 1 < h2.length ? h2[i + 1].index : lines.length;
|
|
390
|
+
for (let index = h2[i].index; index < end; index += 1) {
|
|
391
|
+
if (fencedLines.has(index)) continue;
|
|
392
|
+
for (const m of lines[index].matchAll(HOT_LINK_RE)) {
|
|
393
|
+
if (retainedIds.has(m[2])) violations.push(`${HOT_REL}:${frontLines + index + 1}: "${m[0]}"`);
|
|
394
|
+
}
|
|
395
|
+
for (const m of lines[index].matchAll(RECORD_LINK_RE)) {
|
|
396
|
+
violations.push(`${HOT_REL}:${frontLines + index + 1}: "${m[0]}"`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (violations.length > 0) {
|
|
401
|
+
throw fail(1, `refusing pre-write: a block leaving ${HOT_REL} carries a relative ADR link (a RETAINED decisions.md#… anchor or an adr/… record link) — the block moves verbatim, so the link would change meaning inside the record; convert each link (e.g. to the [[AD-NNN]] form), then re-run:\n ${violations.join('\n ')}`);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Migrate deletes the monolith files: a monolith-form link whose id is NOT in the moved set could
|
|
406
|
+
// never point at a record after --apply (D5's "all point at records" outcome) — refuse pre-write
|
|
407
|
+
// wherever it sits (rewrite scope, HOT, records, the monolith blocks about to become records).
|
|
408
|
+
const assertNoOrphanedMonolithLinks = (root, movedById, rels) => {
|
|
409
|
+
const violations = [];
|
|
410
|
+
for (const rel of rels) {
|
|
411
|
+
const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
|
|
412
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
413
|
+
if (fencedLines.has(index)) continue;
|
|
414
|
+
for (const m of lines[index].matchAll(MONOLITH_LINK_RE)) {
|
|
415
|
+
if (!movedById.has(m[2])) violations.push(`${rel}:${frontLines + index + 1}: "${m[0]}"`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (violations.length > 0) {
|
|
420
|
+
throw fail(1, `refusing pre-write: monolith-form links target an id OUTSIDE the moved set — after --apply removes the monoliths these links could never resolve to a record; fix each link, then re-run:\n ${violations.join('\n ')}`);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// The inbound-rewrite plan across the REWRITE scope. HOT-form links keep their leading relative
|
|
425
|
+
// prefix (adr/ is a SIBLING of decisions.md, so the same prefix reaches the record and the fragment
|
|
426
|
+
// resolves verbatim there); monolith-form targets are computed RELATIVE TO THE LINKING FILE with
|
|
427
|
+
// URL-style forward-slash separators on every platform.
|
|
428
|
+
const planInboundRewrites = (root, movedById, withMonolithForms) => {
|
|
429
|
+
const plans = [];
|
|
430
|
+
for (const rel of walkDocsMarkdown(root).filter(isRewriteScope)) {
|
|
431
|
+
const { frontmatter, frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
|
|
432
|
+
const rewrites = [];
|
|
433
|
+
const afterLines = lines.map((line, index) => {
|
|
434
|
+
if (fencedLines.has(index)) return line;
|
|
435
|
+
// Every match of every form is collected POSITIONALLY on the ORIGINAL line (the forms are
|
|
436
|
+
// textually disjoint), then the line is rebuilt by range-splicing — a negative sharing
|
|
437
|
+
// bytes with a valid link elsewhere on the line can never contaminate the rewrite or its
|
|
438
|
+
// re-derivation.
|
|
439
|
+
const matches = [];
|
|
440
|
+
for (const m of line.matchAll(HOT_LINK_RE)) {
|
|
441
|
+
const moved = movedById.get(m[2]);
|
|
442
|
+
if (moved) matches.push({ start: m.index, old: m[0], new: `adr/${moved.fileName}#${m[1]}` });
|
|
443
|
+
}
|
|
444
|
+
if (withMonolithForms) {
|
|
445
|
+
for (const m of line.matchAll(MONOLITH_LINK_RE)) {
|
|
446
|
+
const moved = movedById.get(m[2]);
|
|
447
|
+
if (moved) matches.push({ start: m.index, old: m[0], new: `${posix.relative(posix.dirname(rel), `${ADR_DIR_REL}/${moved.fileName}`)}#${m[1]}` });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (matches.length === 0) return line;
|
|
451
|
+
matches.sort((a, b) => a.start - b.start);
|
|
452
|
+
let cursor = 0;
|
|
453
|
+
let next = '';
|
|
454
|
+
for (const match of matches) {
|
|
455
|
+
if (match.start < cursor) throw fail(1, `${rel}:${frontLines + index + 1}: overlapping link matches — refusing to plan a rewrite`);
|
|
456
|
+
next += line.slice(cursor, match.start) + match.new;
|
|
457
|
+
cursor = match.start + match.old.length;
|
|
458
|
+
rewrites.push({ index, line: frontLines + index + 1, start: match.start, old: match.old, new: match.new });
|
|
459
|
+
}
|
|
460
|
+
next += line.slice(cursor);
|
|
461
|
+
return next;
|
|
462
|
+
});
|
|
463
|
+
if (rewrites.length > 0) plans.push({ rel, frontmatter, frontLines, beforeLines: lines, afterLines, rewrites });
|
|
464
|
+
}
|
|
465
|
+
return plans;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Conservation (fail-loud): re-derive the rewritten file from { before + planned rewrites } and
|
|
469
|
+
// require byte-equality — a dropped/altered link or any changed byte outside the plan is exit 1
|
|
470
|
+
// with nothing written. Exported so its failure paths are unit-testable directly.
|
|
471
|
+
export const verifyRewriteConservation = (plan) => {
|
|
472
|
+
const { rel, frontLines, beforeLines, afterLines, rewrites } = plan;
|
|
473
|
+
const refuse = (line, detail) => fail(1, `${rel}:${line}: inbound-rewrite conservation violation — ${detail}; refusing with nothing written`);
|
|
474
|
+
if (beforeLines.length !== afterLines.length) throw refuse(frontLines + 1, `the rewrite changed the line count (${beforeLines.length} → ${afterLines.length})`);
|
|
475
|
+
const byIndex = new Map();
|
|
476
|
+
for (const rw of rewrites) {
|
|
477
|
+
const list = byIndex.get(rw.index);
|
|
478
|
+
if (list) list.push(rw);
|
|
479
|
+
else byIndex.set(rw.index, [rw]);
|
|
480
|
+
}
|
|
481
|
+
for (let index = 0; index < beforeLines.length; index += 1) {
|
|
482
|
+
const fileLine = frontLines + index + 1;
|
|
483
|
+
const planned = byIndex.get(index);
|
|
484
|
+
if (!planned) {
|
|
485
|
+
if (beforeLines[index] !== afterLines[index]) throw refuse(fileLine, 'a line outside the planned rewrite set changed');
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
let expected;
|
|
489
|
+
if (planned.every((rw) => typeof rw.start === 'number')) {
|
|
490
|
+
// Range-splicing re-derivation (offsets recorded on the ORIGINAL line) — a same-bytes
|
|
491
|
+
// negative elsewhere on the line can never contaminate it.
|
|
492
|
+
const ordered = [...planned].sort((a, b) => a.start - b.start);
|
|
493
|
+
let cursor = 0;
|
|
494
|
+
expected = '';
|
|
495
|
+
for (const rw of ordered) {
|
|
496
|
+
if (rw.start < cursor) throw refuse(fileLine, 'overlapping planned rewrites');
|
|
497
|
+
if (beforeLines[index].slice(rw.start, rw.start + rw.old.length) !== rw.old) throw refuse(fileLine, `the planned link "${rw.old}" is not at its recorded offset in the source line`);
|
|
498
|
+
expected += beforeLines[index].slice(cursor, rw.start) + rw.new;
|
|
499
|
+
cursor = rw.start + rw.old.length;
|
|
500
|
+
}
|
|
501
|
+
expected += beforeLines[index].slice(cursor);
|
|
502
|
+
} else {
|
|
503
|
+
// Occurrence-based fallback for plans without offsets (the exported contract's original
|
|
504
|
+
// shape); longest-first so a link that is a textual prefix of another cannot corrupt it.
|
|
505
|
+
const unique = [...new Map(planned.map((rw) => [`${rw.old} ${rw.new}`, rw])).values()].sort((a, b) => b.old.length - a.old.length);
|
|
506
|
+
expected = beforeLines[index];
|
|
507
|
+
for (const rw of unique) {
|
|
508
|
+
if (!expected.includes(rw.old)) throw refuse(fileLine, `the planned link "${rw.old}" is absent from the source line`);
|
|
509
|
+
expected = expected.split(rw.old).join(rw.new);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (afterLines[index] !== expected) throw refuse(fileLine, 'the rewritten line diverges from the planned substitution (a link would be dropped or altered)');
|
|
513
|
+
for (const rw of planned) {
|
|
514
|
+
if (!afterLines[index].includes(rw.new)) throw refuse(fileLine, `the rewritten link "${rw.new}" is missing from the output line (a link would be dropped)`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const summarizeRewrites = (plans) =>
|
|
520
|
+
plans.flatMap((plan) => plan.rewrites.map((rw) => `${plan.rel}:${rw.line} ${rw.old} → ${rw.new}`));
|
|
521
|
+
|
|
522
|
+
export const writeInboundRewrites = (root, plans) => {
|
|
523
|
+
// Two passes (the plan is computed pre-write, D4): verify the LIVE bytes of EVERY planned file
|
|
524
|
+
// first, then write — a drift anywhere refuses with NOTHING written in the rewrite phase.
|
|
525
|
+
for (const plan of plans) {
|
|
526
|
+
if (readFileSync(resolve(root, plan.rel), 'utf8') !== `${plan.frontmatter}${plan.beforeLines.join('\n')}`) {
|
|
527
|
+
throw fail(1, `${plan.rel}: changed between the rewrite plan and the write — refusing to overwrite from a stale snapshot; re-run`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const plan of plans) {
|
|
531
|
+
writeFileSync(resolve(root, plan.rel), `${plan.frontmatter}${plan.afterLines.join('\n')}`, 'utf8');
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// CHECK scope (read-only, wider): docs/ai/** including decisions.md (whole file) and the adr/
|
|
536
|
+
// records, excluding only the generated navigator. (a) a `decisions.md#ad-NNN…` id outside the
|
|
537
|
+
// CURRENT HOT window is dead (an archived id is stale, not resolving); (b) an `adr/AD-NNN-slug.md`
|
|
538
|
+
// link must name an existing record FILE (exact filename, not id-presence).
|
|
539
|
+
const collectReferenceViolations = (root, hotIds, archivedIds) => {
|
|
540
|
+
const violations = [];
|
|
541
|
+
for (const rel of walkDocsMarkdown(root).filter((r) => r !== NAV_REL)) {
|
|
542
|
+
const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
|
|
543
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
544
|
+
if (fencedLines.has(index)) continue;
|
|
545
|
+
const fileLine = frontLines + index + 1;
|
|
546
|
+
for (const m of lines[index].matchAll(HOT_LINK_RE)) {
|
|
547
|
+
if (hotIds.has(m[2])) continue;
|
|
548
|
+
const why = archivedIds.has(m[2]) ? `AD-${m[2]} is archived — repoint the link at ${ADR_DIR_REL}/` : `AD-${m[2]} is not in the current HOT window`;
|
|
549
|
+
violations.push(`${rel}:${fileLine}: dead ADR anchor "${m[0]}" — ${why}`);
|
|
550
|
+
}
|
|
551
|
+
for (const m of lines[index].matchAll(RECORD_LINK_RE)) {
|
|
552
|
+
if (!existsSync(resolve(root, ADR_DIR_REL, m[1]))) {
|
|
553
|
+
violations.push(`${rel}:${fileLine}: dead ADR record link "${m[0]}" — no record file ${ADR_DIR_REL}/${m[1]}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return violations;
|
|
559
|
+
};
|
|
560
|
+
|
|
263
561
|
// ── tier / store IO ─────────────────────────────────────────────────────────────────────
|
|
264
562
|
|
|
265
563
|
export const lineCountOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
@@ -566,6 +864,10 @@ const parseArgs = (argv) => {
|
|
|
566
864
|
else if (arg.startsWith('--today=')) today = arg.slice('--today='.length);
|
|
567
865
|
else throw fail(2, `Unknown argument: ${arg}\n${USAGE}`);
|
|
568
866
|
}
|
|
867
|
+
// Pre-fix, --apply silently won over --dry-run and wrote — a fail-closed contract cannot keep that.
|
|
868
|
+
if (flags.migrate && flags.apply && flags.dryRun) {
|
|
869
|
+
throw fail(2, `--migrate --apply --dry-run is contradictory — plain --migrate IS the dry run; drop --apply to preview or --dry-run to apply\n${USAGE}`);
|
|
870
|
+
}
|
|
569
871
|
return { flags, today };
|
|
570
872
|
};
|
|
571
873
|
|
|
@@ -657,10 +959,23 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
|
|
|
657
959
|
for (const r of records) finalStoreById.set(r.id, { id: r.id, idNum: r.idNum, fileName: r.fileName });
|
|
658
960
|
assertStoreIntegrity(retained, [...finalStoreById.values()]);
|
|
659
961
|
|
|
962
|
+
const movedById = new Map(records.map((r) => [r.id, r]));
|
|
963
|
+
assertAdrCorpusFreeOfMovedLinks(root, movedById, [HOT_REL, ...existingStore.map((e) => e.rel), ...present], [HOT_LINK_RE, MONOLITH_LINK_RE]);
|
|
964
|
+
assertMovingBlocksFreeOfRelativeAdrLinks(root, new Set(movedById.keys()), new Set(retained.map((e) => e.id)));
|
|
965
|
+
assertNoOrphanedMonolithLinks(root, movedById, [
|
|
966
|
+
...walkDocsMarkdown(root).filter(isRewriteScope),
|
|
967
|
+
HOT_REL,
|
|
968
|
+
...existingStore.map((e) => e.rel),
|
|
969
|
+
...present,
|
|
970
|
+
]);
|
|
971
|
+
const rewritePlans = planInboundRewrites(root, movedById, true);
|
|
972
|
+
for (const plan of rewritePlans) verifyRewriteConservation(plan);
|
|
973
|
+
|
|
660
974
|
const summary = {
|
|
661
975
|
records: records.map((r) => r.fileName),
|
|
662
976
|
retainedHot: retained.map((e) => `AD-${e.id}`),
|
|
663
977
|
monolithsRetired: present,
|
|
978
|
+
inboundRewrites: summarizeRewrites(rewritePlans),
|
|
664
979
|
conservation: `${oldItems.length} corpus blocks → ${retained.length} retained-HOT + ${records.length} records (conserved)`,
|
|
665
980
|
};
|
|
666
981
|
|
|
@@ -677,7 +992,9 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
|
|
|
677
992
|
];
|
|
678
993
|
const snapshot = writeSnapshot(root, snapshotFiles, deps);
|
|
679
994
|
|
|
995
|
+
// Pinned write order: records → inbound rewrites → HOT rewrite / monolith removal.
|
|
680
996
|
writeRecords(root, records);
|
|
997
|
+
writeInboundRewrites(root, rewritePlans);
|
|
681
998
|
const corpus = [...retained, ...loadAdrStore(root)];
|
|
682
999
|
writeNavigatorFile(root, corpus, today);
|
|
683
1000
|
writeHot(root, hot, retained, today);
|
|
@@ -688,6 +1005,7 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
|
|
|
688
1005
|
log('[archive-decisions] migrated the 3-tier cascade → one-file-per-ADR store:');
|
|
689
1006
|
log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'})`);
|
|
690
1007
|
log(` records written: ${records.length} under ${ADR_DIR_REL}/`);
|
|
1008
|
+
log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${rewritePlans.length} file(s)`);
|
|
691
1009
|
log(` retained HOT: ${summary.retainedHot.join(', ') || '(none)'}`);
|
|
692
1010
|
log(` retired monoliths: ${present.join(', ')}`);
|
|
693
1011
|
log(` navigator: ${NAV_REL}`);
|
|
@@ -741,6 +1059,13 @@ const runCheck = (root, today, log, logError) => {
|
|
|
741
1059
|
return 1;
|
|
742
1060
|
}
|
|
743
1061
|
if (!hasHot && !hasStore) {
|
|
1062
|
+
// The reference scan still runs: a matching reference over NO substrate is a dead link, never a
|
|
1063
|
+
// clean skip — the SKIP remains only for a tree with zero matches.
|
|
1064
|
+
const orphaned = collectReferenceViolations(root, new Set(), new Set());
|
|
1065
|
+
if (orphaned.length > 0) {
|
|
1066
|
+
for (const v of orphaned) logError(`[archive-decisions] FAIL: ${v}.`);
|
|
1067
|
+
return 1;
|
|
1068
|
+
}
|
|
744
1069
|
log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to check.`);
|
|
745
1070
|
return 0;
|
|
746
1071
|
}
|
|
@@ -751,7 +1076,9 @@ const runCheck = (root, today, log, logError) => {
|
|
|
751
1076
|
|
|
752
1077
|
const problems = [];
|
|
753
1078
|
if (hot) {
|
|
754
|
-
|
|
1079
|
+
// Arm B: the verdict names the parsed unit count, not just raw lines — with the loud parse
|
|
1080
|
+
// path, zero can only mean genuinely empty, never a populated file read as nothing.
|
|
1081
|
+
log(`[archive-decisions] ${HOT_REL}: ${hot.rawLines}/${hot.cap} lines, ${hot.entries.length} ADR(s) in the HOT window`);
|
|
755
1082
|
if (hot.cap !== null && hot.rawLines > hot.cap) problems.push(`${HOT_REL} is over its cap (${hot.rawLines}/${hot.cap}) — run \`node scripts/archive-decisions.mjs\` to explode the oldest entries`);
|
|
756
1083
|
}
|
|
757
1084
|
log(`[archive-decisions] ${ADR_DIR_REL}: ${adrEntries.length} record(s)`);
|
|
@@ -766,11 +1093,13 @@ const runCheck = (root, today, log, logError) => {
|
|
|
766
1093
|
problems.push(`${NAV_REL} is stale (out of sync with the ADR corpus) — run \`node scripts/archive-decisions.mjs --write-navigator\` and commit it`);
|
|
767
1094
|
}
|
|
768
1095
|
|
|
1096
|
+
problems.push(...collectReferenceViolations(root, new Set(hotEntries.map((e) => e.id)), new Set(adrEntries.map((e) => e.id))));
|
|
1097
|
+
|
|
769
1098
|
if (problems.length > 0) {
|
|
770
1099
|
for (const p of problems) logError(`[archive-decisions] FAIL: ${p}.`);
|
|
771
1100
|
return 1;
|
|
772
1101
|
}
|
|
773
|
-
log('[archive-decisions] OK — HOT within cap, store integrity intact, navigator fresh.');
|
|
1102
|
+
log('[archive-decisions] OK — HOT within cap, store integrity intact, navigator fresh, inbound ADR references resolve.');
|
|
774
1103
|
return 0;
|
|
775
1104
|
};
|
|
776
1105
|
|
|
@@ -808,20 +1137,33 @@ const runRotate = (root, flags, today, deps, log, logError) => {
|
|
|
808
1137
|
for (const rec of records) finalStoreById.set(rec.id, { id: rec.id, idNum: rec.idNum, fileName: rec.fileName });
|
|
809
1138
|
assertStoreIntegrity(retained, [...finalStoreById.values()]);
|
|
810
1139
|
|
|
811
|
-
const
|
|
1140
|
+
const movedById = new Map(records.map((r) => [r.id, r]));
|
|
1141
|
+
assertAdrCorpusFreeOfMovedLinks(root, movedById, [HOT_REL, ...existingStore.map((e) => e.rel)], [HOT_LINK_RE]);
|
|
1142
|
+
assertMovingBlocksFreeOfRelativeAdrLinks(root, new Set(movedById.keys()), new Set(retained.map((e) => e.id)));
|
|
1143
|
+
const rewritePlans = planInboundRewrites(root, movedById, false);
|
|
1144
|
+
for (const plan of rewritePlans) verifyRewriteConservation(plan);
|
|
1145
|
+
|
|
1146
|
+
const summary = {
|
|
1147
|
+
explode: records.map((r) => r.fileName),
|
|
1148
|
+
retainedHot: retained.map((e) => `AD-${e.id}`),
|
|
1149
|
+
inboundRewrites: summarizeRewrites(rewritePlans),
|
|
1150
|
+
};
|
|
812
1151
|
if (flags.dryRun) {
|
|
813
1152
|
log('[archive-decisions] DRY-RUN — no files will be changed.');
|
|
814
1153
|
log(JSON.stringify(summary, null, 2));
|
|
815
1154
|
return 0;
|
|
816
1155
|
}
|
|
817
1156
|
|
|
1157
|
+
// Pinned write order: records → inbound rewrites → HOT rewrite (crash-resume idempotency).
|
|
818
1158
|
writeRecords(root, records);
|
|
1159
|
+
writeInboundRewrites(root, rewritePlans);
|
|
819
1160
|
const corpus = [...retained, ...loadAdrStore(root)];
|
|
820
1161
|
writeNavigatorFile(root, corpus, today);
|
|
821
1162
|
writeHot(root, hot, retained, today);
|
|
822
1163
|
const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
|
|
823
1164
|
log('[archive-decisions] rotated:');
|
|
824
1165
|
log(` exploded to adr/: ${summary.explode.join(', ') || '(none)'}`);
|
|
1166
|
+
log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${rewritePlans.length} file(s)`);
|
|
825
1167
|
log(` retained HOT: ${summary.retainedHot.join(', ')}`);
|
|
826
1168
|
if (regen.ok) log(' regenerated docs/ai/index.md');
|
|
827
1169
|
else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
|