@sabaiway/agent-workflow-memory 1.11.1 → 2.0.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.
@@ -1,64 +1,87 @@
1
1
  #!/usr/bin/env node
2
- // Three-tier cascade archive for docs/ai/decisions.md (ADRs) — the archive-changelog.mjs sibling.
2
+ // One-file-per-ADR store for docs/ai/decisions.md (ADRs) — the durable replacement for the retired
3
+ // 3-tier cascade (HOT → WARM archive → a single COLD monolith whose cap was raised release after
4
+ // release). Every ADR beyond the HOT window becomes its OWN immutable record so no artifact is ever
5
+ // O(n) and no cap is ever raised again.
3
6
  //
4
- // HOT (docs/ai/decisions.md) — the active ADR set (newest at the bottom)
5
- // WARM (docs/ai/history/decisions-archive.md) — stable/superseded ADRs rotated out of HOT
6
- // COLD (docs/ai/history/decisions-archive-early.md) the earliest ADRs, rolled out of WARM
7
- //
8
- // Caps are read from each file's OWN frontmatter `maxLines`. The cascade is CHAINED: rolling
9
- // HOTWARM when WARM is near its cap first rolls WARMCOLD to make headroom. Whole entries move,
10
- // oldest (lowest AD id, top of file) first; an entry's lines move verbatim.
7
+ // HOT (docs/ai/decisions.md) — the active ADR window (newest at the bottom), self-bounding
8
+ // under its own frontmatter maxLines.
9
+ // STORE (docs/ai/adr/AD-NNN-slug.md) one immutable MADR record per archived ADR (frontmatter +
10
+ // the verbatim `## AD-NNN — title` block). Retrieval is O(1):
11
+ // by id the deterministic filename glob `AD-NNN-*.md`; by
12
+ // topicgrep the flat tree; by lifecyclethe two-way
13
+ // supersedes/supersededBy frontmatter + the [[AD-NNN]] chain.
14
+ // NAV (docs/ai/adr/log.md) — the ONE generated navigator: the currently-governing heads
15
+ // (accepted ∧ not-superseded, computed by supersession
16
+ // inference across the whole corpus) + a recent window. It
17
+ // plateaus at O(governing), never O(cumulative). Not a ledger.
11
18
  //
12
19
  // Modes:
13
- // (default) rotate, mutate files in place (only when something is over cap)
14
- // --dry-run print the planned move-set, change nothing
15
- // --check report per-tier lines/cap; exit 1 if any tier is over its cap
16
- // --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs)
20
+ // (default) rotate: explode the oldest HOT entries beyond the cap into adr/ records, then
21
+ // regenerate the navigator + docs/ai/index.md (item (h)). Monoliths present → a
22
+ // LOUD legacy-guard refusal ("run --migrate first"); it never half-explodes.
23
+ // --check verify HOT cap + adr/ store integrity + the legacy guard + navigator freshness;
24
+ // exit 1 on any breach. A STATED skip (exit 0) only when NO ADR substrate exists
25
+ // (neither decisions.md NOR docs/ai/adr/).
26
+ // --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, writes nothing).
28
+ // --migrate --apply writes a durable pre-delete snapshot, writes the records, rewrites the retained
29
+ // HOT preamble, and only THEN removes the monoliths — gated on conservation AND
30
+ // the snapshot. Re-run skips byte-identical records (crash-resumable).
31
+ // --write-navigator regenerate docs/ai/adr/log.md AND re-trigger the index regen (the authoring /
32
+ // supersession write-side; the --write-index analog).
33
+ // --dry-run print the planned rotation move-set, change nothing.
34
+ // --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs).
17
35
  //
18
36
  // FAIL-LOUD invariants (the Issue-009 lesson — never silently glue an entry to the previous body):
19
- // • every `## ` heading in every tier MUST parse canonically as `## AD-0NN — <title>` — a
20
- // malformed heading is exit 1 naming the file + line, never a silent merge;
21
- // • ADR ids must be strictly ascending within a tier and unique across tiers;
22
- // • a COLD tier at its cap or a roll that would not fit COLD's remaining headroom — fails
23
- // LOUD **before any write** (a cap raise / a new COLD file is a maintainer/agent decision;
24
- // this script only moves entries);
25
- // • conservation is self-verified before writing: the multiset of AD ids across all three
26
- // tiers and every entry's line count are unchanged by the plan.
27
- //
28
- // DELIBERATE divergence from the siblings: on a project WITHOUT docs/ai/decisions.md, `--check`
29
- // reports the absence and exits 0 — the deployed pre-commit hook must never block a commit over
30
- // an absent ADR substrate. (archive-changelog.mjs reads its source unconditionally and crashes
31
- // ENOENT on an absent file; this script states the skip instead.)
37
+ // • every `## ` heading MUST parse canonically as `## AD-NNN — <title>` (AD-\d{3,}) — a malformed
38
+ // heading is exit 1 naming file:line, never a silent merge;
39
+ // • ADR ids are strictly ascending (NUMERIC, never lexical) within a tier and unique across HOT ∪ adr/;
40
+ // • migration is CONSERVATION-checked before any destructive write: the full multiset
41
+ // {id sha256(verbatim block)} across the OLD monoliths equals {retained-HOT written records};
42
+ // a drop / renumber / edited-block / stray adr record fails exit 1 before any remove or overwrite;
43
+ // • a legacy monolith still on disk fails LOUD on default/--check (it is a half-migrated tree).
32
44
  //
33
- // Cap accounting is on the REAL on-disk line count (what the docs cap-validator counts), never
34
- // on a normalized render a template-shaped file with `---` separators between entries must not
35
- // false-green near its cap. A write NORMALIZES formatting (entries joined by one blank line;
36
- // separators dropped), so when a tier is over cap on raw lines but already fits after
37
- // normalization, the rotation performs a NORMALIZE-ONLY rewrite (zero entry moves, stated).
45
+ // docs/ai here is git-ignored, so the monoliths were NEVER committed (no VCS recovery) every
46
+ // destructive --migrate --apply writes a durable snapshot to the git dir (uncommittable, the
47
+ // review-receipts precedent) BEFORE any delete, with a stated out-of-tree fallback off git.
38
48
  //
39
49
  // Dependency-free, Node >= 18. Deployed into a consumer's scripts/ like its siblings.
40
50
 
41
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
42
- import { dirname, resolve } from 'node:path';
51
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
52
+ import { dirname, resolve, join } from 'node:path';
43
53
  import { fileURLToPath, pathToFileURL } from 'node:url';
54
+ import { spawnSync } from 'node:child_process';
55
+ import { createHash } from 'node:crypto';
56
+ import { tmpdir } from 'node:os';
44
57
 
45
58
  const __dirname = dirname(fileURLToPath(import.meta.url));
46
59
  const DEFAULT_ROOT = resolve(__dirname, '..');
47
60
 
48
61
  export const HOT_REL = 'docs/ai/decisions.md';
62
+ // The retired monolith tiers — still READ during migration (their entries explode into records) and
63
+ // still DETECTED by the legacy guard; never written by the new scheme.
49
64
  export const WARM_REL = 'docs/ai/history/decisions-archive.md';
50
65
  export const COLD_REL = 'docs/ai/history/decisions-archive-early.md';
51
-
52
- const DEFAULT_WARM_CAP = 500;
53
- const DEFAULT_COLD_CAP = 400;
54
-
55
- export const HEADING_RE = /^## AD-(\d{3})(.+)$/;
66
+ export const ADR_DIR_REL = 'docs/ai/adr';
67
+ export const NAV_REL = 'docs/ai/adr/log.md';
68
+
69
+ // A per-record cap (generous vs the largest real ADR ~74 lines; a body over it is a genuine smell) and
70
+ // the navigator cap (boundedexceeding it is the future plateau-shard trigger, Decision 8).
71
+ export const RECORD_CAP = 400;
72
+ export const NAV_MAXLINES = 200;
73
+ // How many most-recent ids the navigator's recent window carries (status-annotated, incl. supersessions).
74
+ const NAV_RECENT_WINDOW = 15;
75
+
76
+ // AD-\d{3,}: 3-digit ids stay valid, AD-1000+ parse; ordering is always NUMERIC (never lexical).
77
+ export const HEADING_RE = /^## AD-(\d{3,}) — (.+)$/;
56
78
  const ANY_H2_RE = /^## /;
57
79
  const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/;
80
+ const RECORD_FILE_RE = /^AD-(\d{3,})-.*\.md$/;
58
81
 
59
82
  export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
60
83
 
61
- // ── parsing (strict; malformed headings are LOUD) ─────────────────────────────────────
84
+ // ── parsing (strict; malformed headings are LOUD) + lifecycle extraction ───────────────
62
85
 
63
86
  const stripTrailingSeparators = (blockLines) => {
64
87
  const lines = [...blockLines];
@@ -68,8 +91,40 @@ const stripTrailingSeparators = (blockLines) => {
68
91
  return lines;
69
92
  };
70
93
 
94
+ // Lifecycle from the real corpus body forms (Decision 9), the block preserved VERBATIM:
95
+ // • status = the leading word of `**Status:**` (lowercased); the marker may be on its own line OR
96
+ // same-line after `**Date:** … · **Status:** …`, and its prose may wrap. A MISSING status line →
97
+ // `accepted` (the in-force default; 6 of 9 active HOT ADRs carry no status line). An unknown
98
+ // leading word is kept verbatim (never coerced) — governance keys on the exact 'accepted' token.
99
+ // • date = the `**Date:**` value up to a ` · ` separator or end of line (null when absent).
100
+ // • supersedes ← every `Supersedes [[AD-NNN]]`.
101
+ // • supersededBy ← every `Superseded by [[AD-NNN]]` / `Amended by [[AD-NNN]]`.
102
+ const extractLifecycle = (block) => {
103
+ const statusMatch = block.match(/\*\*Status:\*\*\s*([^\n]*)/);
104
+ let status = 'accepted';
105
+ if (statusMatch) {
106
+ const word = (statusMatch[1].trim().match(/^[A-Za-z]+/) || [''])[0].toLowerCase();
107
+ if (word) status = word;
108
+ }
109
+ const dateMatch = block.match(/\*\*Date:\*\*\s*([^\n·]*)/);
110
+ const date = dateMatch ? dateMatch[1].trim() || null : null;
111
+ const idsFrom = (re) => {
112
+ const out = [];
113
+ let m;
114
+ const g = new RegExp(re.source, 'g');
115
+ while ((m = g.exec(block)) !== null) out.push(m[1]);
116
+ return out;
117
+ };
118
+ const supersedes = idsFrom(/Supersedes \[\[AD-(\d{3,})\]\]/);
119
+ const supersededBy = [
120
+ ...idsFrom(/Superseded by \[\[AD-(\d{3,})\]\]/),
121
+ ...idsFrom(/Amended by \[\[AD-(\d{3,})\]\]/),
122
+ ];
123
+ return { status, date, supersedes, supersededBy };
124
+ };
125
+
71
126
  // Parse one tier's text → { frontmatter, cap, preamble, entries }. Every `## ` line must be a
72
- // canonical AD heading — anything else in any tier is exit 1 naming file:line (Issue-009).
127
+ // canonical AD heading — anything else is exit 1 naming file:line (Issue-009).
73
128
  export const parseDecisionsText = (text, label) => {
74
129
  const fmMatch = text.match(FRONTMATTER_RE);
75
130
  const frontmatter = fmMatch ? fmMatch[1] : '';
@@ -83,7 +138,7 @@ export const parseDecisionsText = (text, label) => {
83
138
  if (!HEADING_RE.test(line)) {
84
139
  throw fail(
85
140
  1,
86
- `${label}:${fmLines + i + 1}: non-canonical H2 heading "${line}" — every "## " heading must be \`## AD-0NN — <title>\` (never silently glued to the previous entry; fix the heading, then re-run)`,
141
+ `${label}:${fmLines + i + 1}: non-canonical H2 heading "${line}" — every "## " heading must be \`## AD-NNN — <title>\` (AD-\\d{3,}; never silently glued to the previous entry; fix the heading, then re-run)`,
87
142
  );
88
143
  }
89
144
  startIdxs.push(i);
@@ -96,12 +151,14 @@ export const parseDecisionsText = (text, label) => {
96
151
  const end = i + 1 < startIdxs.length ? startIdxs[i + 1] : lines.length;
97
152
  const blockLines = stripTrailingSeparators(lines.slice(idx, end));
98
153
  const m = HEADING_RE.exec(lines[idx]);
154
+ const block = blockLines.join('\n');
99
155
  return {
100
156
  id: m[1],
101
157
  idNum: Number(m[1]),
102
158
  title: m[2],
103
- block: blockLines.join('\n'),
159
+ block,
104
160
  lineCount: blockLines.length,
161
+ ...extractLifecycle(block),
105
162
  };
106
163
  });
107
164
 
@@ -109,7 +166,7 @@ export const parseDecisionsText = (text, label) => {
109
166
  if (entries[i].idNum <= entries[i - 1].idNum) {
110
167
  throw fail(
111
168
  1,
112
- `${label}: AD-${entries[i].id} appears after AD-${entries[i - 1].id} — ids must be strictly ascending within a tier (oldest at the top); refusing to rotate a disordered file`,
169
+ `${label}: AD-${entries[i].id} appears after AD-${entries[i - 1].id} — ids must be strictly ascending (numeric) within a tier (oldest at the top); refusing to rotate a disordered file`,
113
170
  );
114
171
  }
115
172
  }
@@ -118,175 +175,390 @@ export const parseDecisionsText = (text, label) => {
118
175
  return { frontmatter, cap: capMatch ? Number(capMatch[1]) : null, preamble, entries };
119
176
  };
120
177
 
121
- // ── tier IO ───────────────────────────────────────────────────────────────────────────
122
-
123
- const CREATED_FRONTMATTER = (cap, today) =>
124
- `---\ntype: history\nlastUpdated: ${today}\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${cap}\n---\n`;
125
-
126
- const CREATED_WARM_PREAMBLE = [
127
- '# Architecture Decision Records — Archive (AD-000 … AD-000)',
128
- '',
129
- '> Stable ADRs rotated out of the active [`decisions.md`](../decisions.md) per the 3-tier archive',
130
- '> discipline. The earliest entries roll further to the COLD',
131
- '> [`decisions-archive-early.md`](./decisions-archive-early.md). Cross-links (`[[AD-XXX]]`) resolve',
132
- '> by id across all three decision files.',
133
- ].join('\n');
134
-
135
- const CREATED_COLD_PREAMBLE = [
136
- '# Architecture Decision Records Early Archive (AD-000 … AD-000)',
137
- '',
138
- '> The earliest foundational ADRs, rolled out of [`decisions-archive.md`](./decisions-archive.md) (WARM)',
139
- '> into this COLD tier when the WARM archive neared its cap. Cross-links (`[[AD-XXX]]`) still resolve',
140
- '> by id across all three decision files.',
141
- ].join('\n');
142
-
143
- export const loadTiers = (root, today) => {
144
- const load = (rel, { createdCap, createdPreamble }) => {
145
- const path = resolve(root, rel);
146
- if (existsSync(path)) {
147
- const raw = readFileSync(path, 'utf8');
148
- const parsed = parseDecisionsText(raw, rel);
149
- if (parsed.cap === null) throw fail(1, `${rel}: frontmatter carries no maxLines cap — refusing to rotate against an unknown budget`);
150
- return { rel, path, exists: true, rawLines: lineCountOf(raw), ...parsed };
151
- }
178
+ // ── slug + record rendering (frontmatter built INLINE, Decision 1 — no runtime template read) ──
179
+
180
+ const SLUG_MAX = 60;
181
+
182
+ export const slugify = (title) => {
183
+ const base = title
184
+ .toLowerCase()
185
+ .replace(/[^a-z0-9]+/g, '-')
186
+ .replace(/^-+|-+$/g, '');
187
+ if (base.length <= SLUG_MAX) return base || 'record';
188
+ const cut = base.slice(0, SLUG_MAX);
189
+ const lastDash = cut.lastIndexOf('-');
190
+ return (lastDash > 0 ? cut.slice(0, lastDash) : cut) || 'record';
191
+ };
192
+
193
+ export const recordFileName = (id, slug) => `AD-${id}-${slug}.md`;
194
+
195
+ const yamlIdList = (ids) => (ids.length > 0 ? `[${ids.map((id) => `AD-${id}`).join(', ')}]` : '[]');
196
+
197
+ // The record's 6-field frontmatter + the 4 lifecycle keys (Decision 1). Ends with `---\n` like the
198
+ // sibling CREATED_FRONTMATTER pattern; the block follows after one blank line.
199
+ export const buildRecordFrontmatter = (entry, today) =>
200
+ [
201
+ '---',
202
+ 'type: adr',
203
+ `lastUpdated: ${today}`,
204
+ 'scope: permanent',
205
+ 'staleAfter: never',
206
+ 'owner: none',
207
+ `maxLines: ${RECORD_CAP}`,
208
+ `status: ${entry.status}`,
209
+ `date: ${entry.date ?? 'unknown'}`,
210
+ `supersedes: ${yamlIdList(entry.supersedes)}`,
211
+ `supersededBy: ${yamlIdList(entry.supersededBy)}`,
212
+ '---',
213
+ '',
214
+ ].join('\n');
215
+
216
+ export const renderRecord = (record) => `${record.frontmatter}\n${record.block}\n`;
217
+
218
+ // explode entries → immutable records (id, slug, filename, frontmatter, verbatim block).
219
+ export const explode = (entries, today) =>
220
+ entries.map((entry) => {
221
+ const slug = slugify(entry.title);
152
222
  return {
153
- rel,
154
- path,
155
- exists: false,
156
- rawLines: 0,
157
- frontmatter: CREATED_FRONTMATTER(createdCap, today),
158
- cap: createdCap,
159
- preamble: createdPreamble,
160
- entries: [],
223
+ id: entry.id,
224
+ idNum: entry.idNum,
225
+ slug,
226
+ fileName: recordFileName(entry.id, slug),
227
+ frontmatter: buildRecordFrontmatter(entry, today),
228
+ block: entry.block,
161
229
  };
162
- };
163
- const hot = load(HOT_REL, { createdCap: 0, createdPreamble: '' }); // HOT is never created here
164
- const warm = load(WARM_REL, { createdCap: DEFAULT_WARM_CAP, createdPreamble: CREATED_WARM_PREAMBLE });
165
- const cold = load(COLD_REL, { createdCap: DEFAULT_COLD_CAP, createdPreamble: CREATED_COLD_PREAMBLE });
230
+ });
166
231
 
167
- const seen = new Map();
168
- for (const tier of [hot, warm, cold]) {
169
- for (const entry of tier.entries) {
170
- if (seen.has(entry.id)) throw fail(1, `AD-${entry.id} appears in both ${seen.get(entry.id)} and ${tier.rel} — duplicate id across tiers; refusing to rotate`);
171
- seen.set(entry.id, tier.rel);
172
- }
232
+ // ── conservation (fail-loud, partition-preserving, extra-aware — Decision 4) ────────────
233
+
234
+ export const blockHash = (block) => createHash('sha256').update(block, 'utf8').digest('hex');
235
+
236
+ const multiset = (items) => {
237
+ const map = new Map();
238
+ for (const { id, block } of items) {
239
+ if (map.has(id)) throw fail(1, `conservation: AD-${id} appears twice on the same side — a duplicate id (no double-count allowed)`);
240
+ map.set(id, blockHash(block));
173
241
  }
174
- return { hot, warm, cold };
242
+ return map;
175
243
  };
176
244
 
177
- // ── rendering ─────────────────────────────────────────────────────────────────────────
178
-
179
- export const renderTier = (tier, entries) => {
180
- const blocks = entries.map((entry) => entry.block).join('\n\n');
181
- const body = [tier.preamble, blocks].filter((part) => part !== '').join('\n\n');
182
- return `${tier.frontmatter}\n${body}\n`;
245
+ // OLD = every block across the migrated tiers; NEW = retained-HOT ∪ written record bodies. Every OLD
246
+ // id must appear exactly once on the NEW side with a byte-identical block; a NEW id absent from OLD is
247
+ // a stray (crashed-run remnant / invented history) refuse. No loss, no double-count, no edit.
248
+ export const verifyConservation = (oldItems, newItems) => {
249
+ const before = multiset(oldItems);
250
+ const after = multiset(newItems);
251
+ for (const [id, hash] of before) {
252
+ if (!after.has(id)) throw fail(1, `conservation violation: AD-${id} is present in the OLD tiers but absent from the migrated store — refusing to write (an ADR would be lost)`);
253
+ if (after.get(id) !== hash) throw fail(1, `conservation violation: AD-${id}'s block changed during migration (hash mismatch) — the block must move verbatim; refusing to write`);
254
+ }
255
+ for (const id of after.keys()) {
256
+ if (!before.has(id)) throw fail(1, `conservation violation: AD-${id} is present in the migrated store but NOT in the OLD tiers — a stray/invented record; refuse or quarantine it, then re-run`);
257
+ }
183
258
  };
184
259
 
185
- export const lineCountOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
260
+ // ── tier / store IO ─────────────────────────────────────────────────────────────────────
186
261
 
187
- // ── the deterministic move plan (same input same move-set; nothing written here) ────
262
+ export const lineCountOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
188
263
 
189
- export const planRotation = ({ hot, warm, cold }) => {
190
- const hotEntries = [...hot.entries];
191
- const warmEntries = [...warm.entries];
192
- const coldEntries = [...cold.entries];
193
- const moves = { hotToWarm: [], warmToCold: [] };
264
+ export const loadHot = (root) => {
265
+ const path = resolve(root, HOT_REL);
266
+ const raw = readFileSync(path, 'utf8');
267
+ const parsed = parseDecisionsText(raw, HOT_REL);
268
+ if (parsed.cap === null) {
269
+ throw fail(1, `${HOT_REL}: frontmatter carries no maxLines cap — refusing to operate against an unknown budget (add a maxLines field to the frontmatter)`);
270
+ }
271
+ return { rel: HOT_REL, path, raw, rawLines: lineCountOf(raw), ...parsed };
272
+ };
194
273
 
195
- const linesOf = (tier, entries) => lineCountOf(renderTier(tier, entries));
274
+ const loadMonolith = (root, rel) => {
275
+ const path = resolve(root, rel);
276
+ if (!existsSync(path)) return { rel, path, exists: false, entries: [] };
277
+ const raw = readFileSync(path, 'utf8');
278
+ const parsed = parseDecisionsText(raw, rel);
279
+ return { rel, path, exists: true, raw, ...parsed };
280
+ };
196
281
 
197
- const rollWarmToCold = () => {
198
- if (warmEntries.length === 0) {
199
- throw fail(1, `${WARM_REL} exceeds its cap with no entries left to roll — its preamble alone is over budget; fix the file by hand`);
282
+ export const monolithsPresent = (root) =>
283
+ [WARM_REL, COLD_REL].filter((rel) => existsSync(resolve(root, rel)));
284
+
285
+ export const NAV_BASENAME = 'log.md';
286
+
287
+ // Read the immutable adr/ store → one entry per AD-NNN-slug.md record (the navigator log.md
288
+ // excluded). Any UNEXPECTED markdown file in the tree is a LOUD integrity failure — never silently
289
+ // dropped from the store (the collapse would otherwise hide it from both navigator and index). The
290
+ // heading id must match the filename id.
291
+ export const loadAdrStore = (root) => {
292
+ const dir = resolve(root, ADR_DIR_REL);
293
+ if (!existsSync(dir)) return [];
294
+ const records = [];
295
+ const seenIds = new Map(); // the ROOT store invariant: exactly one file per id (never a silent dup)
296
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
297
+ const name = entry.name;
298
+ if (entry.isDirectory()) {
299
+ throw fail(1, `${ADR_DIR_REL}/${name}/: the adr/ store is a FLAT directory — a nested subdirectory would hide records from the navigator + integrity checks; remove it, then re-run`);
200
300
  }
201
- const moved = warmEntries[0];
202
- const next = [...coldEntries, moved];
203
- if (linesOf(cold, next) > cold.cap) {
204
- throw fail(
205
- 1,
206
- `refusing BEFORE any write: rolling AD-${moved.id} (${moved.lineCount} lines) into ${COLD_REL} would exceed its cap ` +
207
- `(${linesOf(cold, coldEntries)}/${cold.cap} now, ${linesOf(cold, next)} after) — the COLD tier is exhausted; ` +
208
- 'a cap raise or a new COLD file is a maintainer/agent decision, this script only moves entries',
209
- );
301
+ if (name === NAV_BASENAME) continue; // the navigator, not a record
302
+ if (!RECORD_FILE_RE.test(name)) {
303
+ if (name.endsWith('.md')) {
304
+ throw fail(1, `${ADR_DIR_REL}/${name}: unexpected markdown file in the adr/ store — only the navigator ${NAV_BASENAME} and AD-NNN-slug.md records belong here; rename/remove it, then re-run (never silently hidden)`);
305
+ }
306
+ continue; // a non-markdown stray is ignored by the ADR store (and by the cap-validator)
210
307
  }
211
- warmEntries.shift();
212
- coldEntries.push(moved);
213
- moves.warmToCold.push(moved.id);
214
- };
308
+ const rel = `${ADR_DIR_REL}/${name}`;
309
+ const parsed = parseDecisionsText(readFileSync(resolve(dir, name), 'utf8'), rel);
310
+ if (parsed.entries.length !== 1) {
311
+ throw fail(1, `${rel}: an adr/ record must hold exactly one ADR block (found ${parsed.entries.length}); refusing to treat it as a record`);
312
+ }
313
+ const record = parsed.entries[0];
314
+ const fnId = name.match(RECORD_FILE_RE)[1];
315
+ if (fnId !== record.id) {
316
+ throw fail(1, `${rel}: filename id AD-${fnId} does not match the heading id AD-${record.id} — a corrupt/renamed record; refusing to trust it`);
317
+ }
318
+ // Fail LOUD at the SOURCE on a duplicate id (two files for one AD-NNN) so no caller ever operates
319
+ // on — or silently dedups away — a corrupt store (rotate/migrate/navigator/check all read here).
320
+ if (seenIds.has(record.id)) {
321
+ throw fail(1, `${ADR_DIR_REL}/: two records for AD-${record.id} (${seenIds.get(record.id)} and ${name}) — the store must hold exactly one file per id; remove the stale one, then re-run`);
322
+ }
323
+ seenIds.set(record.id, name);
324
+ records.push({ ...record, fileName: name, rel });
325
+ }
326
+ return records;
327
+ };
215
328
 
216
- const ensureWarmFits = () => {
217
- while (linesOf(warm, warmEntries) > warm.cap) rollWarmToCold();
218
- };
329
+ // ── HOT rendering + preamble rewrite (Decision 15) ──────────────────────────────────────
330
+
331
+ export const renderTier = (frontmatter, preamble, entries) => {
332
+ const blocks = entries.map((entry) => entry.block).join('\n\n');
333
+ const body = [preamble, blocks].filter((part) => part !== '').join('\n\n');
334
+ return `${frontmatter}\n${body}\n`;
335
+ };
219
336
 
220
- ensureWarmFits(); // a pre-existing WARM overflow chains down first
221
- while (linesOf(hot, hotEntries) > hot.cap) {
222
- if (hotEntries.length <= 1) {
223
- throw fail(1, `${HOT_REL} exceeds its cap but only one entry remains — the newest entry alone is over budget; trim it or raise the cap (maintainer decision)`);
337
+ const NEW_ARCHIVE_LINE = (oldestId) =>
338
+ `> **Archive:** older ADRs are stored one file per record under [\`adr/\`](./adr/) — see the active-set ` +
339
+ `navigator [\`adr/log.md\`](./adr/log.md). This file carries the active window (AD-${oldestId} onward).`;
340
+
341
+ // Repoint the retained HOT preamble at the navigator and DROP the dead monolith links/"rolled to
342
+ // COLD" prose (Decision 15 — not just a range re-stamp). Any line that is the `**Archive:**` marker
343
+ // OR names a retired monolith is dropped; the FIRST such line is replaced by the navigator pointer.
344
+ // A preamble that never mentioned the archive is left untouched (a consumer's own wording preserved).
345
+ // Assumes the archive prose is one logical blockquote line (the shipped shape); a hand-wrapped
346
+ // continuation line carrying NEITHER marker is left in place (a stated, benign residual).
347
+ export const rewriteHotPreamble = (preamble, oldestId) => {
348
+ const isArchiveLine = (line) => /decisions-archive/.test(line) || /\*\*Archive:\*\*/.test(line);
349
+ const out = [];
350
+ let replaced = false;
351
+ for (const line of preamble.split('\n')) {
352
+ if (isArchiveLine(line)) {
353
+ if (!replaced) {
354
+ out.push(NEW_ARCHIVE_LINE(oldestId));
355
+ replaced = true;
356
+ }
357
+ continue;
224
358
  }
225
- const moved = hotEntries.shift();
226
- warmEntries.push(moved);
227
- moves.hotToWarm.push(moved.id);
228
- ensureWarmFits();
359
+ out.push(line);
229
360
  }
361
+ return out.join('\n');
362
+ };
363
+
364
+ const stampLastUpdated = (frontmatter, today) => frontmatter.replace(/^lastUpdated: .*$/m, `lastUpdated: ${today}`);
230
365
 
231
- return { moves, hotEntries, warmEntries, coldEntries };
366
+ // ── the deterministic HOT-bounding plan (oldest-out until the render fits its cap) ──────
367
+
368
+ export const boundHot = (candidates, frontmatter, preamble, cap) => {
369
+ const retained = [...candidates].sort((a, b) => a.idNum - b.idNum);
370
+ const toExplode = [];
371
+ while (retained.length > 1 && lineCountOf(renderTier(frontmatter, preamble, retained)) > cap) {
372
+ toExplode.unshift(retained.shift());
373
+ }
374
+ return { retained, toExplode };
232
375
  };
233
376
 
234
- // Conservation self-verify: the multiset of (id lineCount) across all tiers is unchanged.
235
- export const verifyConservation = (before, after) => {
236
- const snapshot = (tiers) =>
237
- tiers
238
- .flat()
239
- .map((entry) => `${entry.id}:${entry.lineCount}`)
240
- .sort()
241
- .join('|');
242
- const beforeKey = snapshot(before);
243
- const afterKey = snapshot(after);
244
- if (beforeKey !== afterKey) {
245
- throw fail(1, `internal conservation violation — the planned move-set would change the ADR set (before ${beforeKey.slice(0, 120)}… vs after ${afterKey.slice(0, 120)}…); refusing to write`);
377
+ // ── navigator generation (governance COMPUTED by supersession inference Decision 8) ───
378
+
379
+ // The set of ids superseded BY ANOTHER ADR: an entry's own `Superseded by / Amended by [[X]]`
380
+ // declaration retires that entry, and an IN-FORCE (accepted) entry's `Supersedes [[Y]]` retires Y
381
+ // — so a new superseding ADR needs NO predecessor-file mutation. A Supersedes edge from a NON-accepted
382
+ // entry (proposed/rejected/superseded) is NOT effective and never retires its target.
383
+ export const IN_FORCE_STATUS = 'accepted';
384
+
385
+ export const computeSupersededSet = (entries) => {
386
+ const superseded = new Set();
387
+ for (const entry of entries) {
388
+ if (entry.supersededBy.length > 0) superseded.add(entry.id);
389
+ if (entry.status === IN_FORCE_STATUS) {
390
+ for (const id of entry.supersedes) superseded.add(id);
391
+ }
246
392
  }
393
+ return superseded;
247
394
  };
248
395
 
249
- // ── preamble range-token maintenance ──────────────────────────────────────────────────
250
- // The hand-authored preambles carry range tokens ("AD-014 … AD-023", "(AD-024 onward)",
251
- // "from **AD-024**"). After a rotation those would silently lie, so the recognizable tokens are
252
- // updated in place; a preamble without them (a consumer's own wording) is left untouched.
253
-
254
- const RANGE_TOKEN_RE = /AD-\d{3} … AD-\d{3}/g;
255
-
256
- const formatRange = (entries) => `AD-${entries[0].id} … AD-${entries[entries.length - 1].id}`;
257
-
258
- export const updateRangeTokens = (preamble, kind, { hotEntries, warmEntries, coldEntries }) => {
259
- // Token order per tier file: HOT + WARM preambles mention the WARM range then the COLD range;
260
- // COLD mentions only its own. This ASSUMES the hand-authored order (WARM before COLD) — the
261
- // shape all three files here carry. A maintainer who rewrites a preamble with the ranges
262
- // swapped would get the bounds injected into the wrong slots; if you reorder the ranges,
263
- // update this sequence too (a preamble WITHOUT the tokens is simply left untouched).
264
- const sequence = kind === 'cold' ? [coldEntries] : [warmEntries, coldEntries];
265
- let occurrence = 0;
266
- let out = preamble.replace(RANGE_TOKEN_RE, (token) => {
267
- const tierEntries = sequence[Math.min(occurrence, sequence.length - 1)];
268
- occurrence += 1;
269
- return tierEntries.length > 0 ? formatRange(tierEntries) : token;
396
+ // Governing heads = in-force (accepted) AND not superseded by another. A non-accepted status
397
+ // (proposed/rejected/superseded/amended/deprecated) is never a governing head.
398
+ export const computeGoverningIds = (entries) => {
399
+ const superseded = computeSupersededSet(entries);
400
+ return new Set(entries.filter((e) => e.status === IN_FORCE_STATUS && !superseded.has(e.id)).map((e) => e.id));
401
+ };
402
+
403
+ const NAV_FRONTMATTER = (today) =>
404
+ `---\ntype: reference\nlastUpdated: ${today}\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${NAV_MAXLINES}\n---\n`;
405
+
406
+ // Build docs/ai/adr/log.md from the WHOLE corpus (HOT entries adr/ records). Governing heads
407
+ // (accepted not-superseded) sorted newest-first; superseded ADRs drop OUT (still reachable by
408
+ // filename/grep/chain) so the navigator plateaus at O(governing). A recent window follows, status-
409
+ // annotated, so recent supersessions stay visible. On-demand (type: reference), never a full ledger.
410
+ export const buildNavigator = (corpus, today) => {
411
+ const entries = [...corpus].sort((a, b) => a.idNum - b.idNum);
412
+ const superseded = computeSupersededSet(entries);
413
+ const governingIds = computeGoverningIds(entries);
414
+ const governing = entries.filter((entry) => governingIds.has(entry.id));
415
+ const govLines = [...governing]
416
+ .reverse()
417
+ .map((entry) => {
418
+ const where = entry.fileName ? `[\`${entry.fileName}\`](./${entry.fileName})` : `[\`../decisions.md\`](../decisions.md)`;
419
+ return `| AD-${entry.id} | ${entry.title.replace(/\|/g, '\\|')} | ${where} |`;
420
+ });
421
+ const recent = [...entries].slice(-NAV_RECENT_WINDOW).reverse().map((entry) => {
422
+ const state = governingIds.has(entry.id) ? 'governing' : superseded.has(entry.id) ? 'superseded' : entry.status;
423
+ const where = entry.fileName ? `adr/${entry.fileName}` : 'decisions.md (HOT)';
424
+ return `- **AD-${entry.id}** — ${state} — \`${where}\``;
270
425
  });
271
- if (hotEntries.length > 0) {
272
- out = out.replace(/\(AD-\d{3} onward\)/, `(AD-${hotEntries[0].id} onward)`);
273
- out = out.replace(/from \*\*AD-\d{3}\*\*/, `from **AD-${hotEntries[0].id}**`);
426
+ const oldestId = entries.length > 0 ? entries[0].id : '000';
427
+ const newestId = entries.length > 0 ? entries[entries.length - 1].id : '000';
428
+ const body = [
429
+ `# ADR Navigator — active set (AD-${oldestId} … AD-${newestId})`,
430
+ '',
431
+ '> **Auto-generated** (`archive-decisions.mjs --write-navigator`). The governing heads only —',
432
+ '> an ADR superseded/amended by another drops OUT (still reachable by filename, grep, or the',
433
+ `> \`[[AD-NNN]]\` chain). The HOT window lives in [\`../decisions.md\`](../decisions.md); every`,
434
+ '> archived record is one file in this directory. This is a navigator, never a full ledger.',
435
+ '',
436
+ `## Governing (${governing.length}) — accepted & not superseded`,
437
+ '',
438
+ '| ADR | Title | Record |',
439
+ '|-----|-------|--------|',
440
+ ...govLines,
441
+ '',
442
+ `## Recent (${Math.min(NAV_RECENT_WINDOW, entries.length)})`,
443
+ '',
444
+ ...recent,
445
+ ].join('\n');
446
+ return `${NAV_FRONTMATTER(today)}\n${body}\n`;
447
+ };
448
+
449
+ // ── snapshot (durable, pre-delete — Decision 5) ─────────────────────────────────────────
450
+
451
+ const SNAPSHOT_PREFIX = 'agent-workflow-adr-migration-snapshot';
452
+
453
+ const resolveGitDir = (root, spawn) => {
454
+ const r = spawn('git', ['rev-parse', '--absolute-git-dir'], { cwd: root, encoding: 'utf8' });
455
+ return r && r.status === 0 && r.stdout ? r.stdout.trim() : null;
456
+ };
457
+
458
+ // Snapshot the docs the migration is about to destroy (decisions.md + both monoliths) into the git
459
+ // dir (uncommittable — never in the work tree, never stageable). Off git → a stated tmpdir fallback;
460
+ // fails LOUD if neither base is writable. Returns { dir, base } for the caller to report.
461
+ export const writeSnapshot = (root, files, deps = {}) => {
462
+ const spawn = deps.spawnSync ?? spawnSync;
463
+ const stamp = deps.stamp ?? `${new Date().toISOString().replace(/[:.]/g, '-')}`;
464
+ const fallbackBase = deps.snapshotFallbackBase ?? tmpdir();
465
+ const gitDir = resolveGitDir(root, spawn);
466
+ const bases = gitDir ? [gitDir, fallbackBase] : [fallbackBase];
467
+ let lastErr = null;
468
+ for (const base of bases) {
469
+ const dir = resolve(base, `${SNAPSHOT_PREFIX}-${stamp}`);
470
+ try {
471
+ mkdirSync(dir, { recursive: true });
472
+ for (const { rel, content } of files) {
473
+ writeFileSync(resolve(dir, rel.replace(/\//g, '__')), content, 'utf8');
474
+ }
475
+ return { dir, base, viaGitDir: base === gitDir };
476
+ } catch (err) {
477
+ lastErr = err;
478
+ }
274
479
  }
275
- return out;
480
+ throw fail(1, `refusing to migrate: no writable snapshot location (git dir + fallback both failed: ${lastErr && lastErr.message}) — a durable pre-delete snapshot is mandatory`);
276
481
  };
277
482
 
278
- const stampLastUpdated = (frontmatter, today) => frontmatter.replace(/^lastUpdated: .*$/m, `lastUpdated: ${today}`);
483
+ // ── index regeneration (item (h) reuse the ONE generator in check-docs-size.mjs) ──────
484
+
485
+ const CHECK_DOCS_SIBLING = resolve(__dirname, 'check-docs-size.mjs');
486
+ const INDEX_INSTRUCT = 'run `node scripts/check-docs-size.mjs --write-index` to refresh docs/ai/index.md';
487
+
488
+ export const defaultRegenerateIndex = (root, today, deps = {}) => {
489
+ const exists = deps.existsSync ?? existsSync;
490
+ const spawn = deps.spawnSync ?? spawnSync;
491
+ const sibling = deps.sibling ?? CHECK_DOCS_SIBLING;
492
+ if (!exists(sibling)) {
493
+ return { ok: false, detail: `the index generator is not beside this script — ${INDEX_INSTRUCT}` };
494
+ }
495
+ const r = spawn(process.execPath, [sibling, '--write-index', '--report', `--root=${root}`, `--today=${today}`], { encoding: 'utf8' });
496
+ if (r.error || r.status !== 0) {
497
+ return { ok: false, detail: `index regeneration failed (${(r.error && r.error.message) || `exit ${r.status}`}) — ${INDEX_INSTRUCT}` };
498
+ }
499
+ return { ok: true, detail: (r.stdout || '').trim() };
500
+ };
279
501
 
280
- // ── CLI ───────────────────────────────────────────────────────────────────────────────
502
+ // ── shared store-write helpers ──────────────────────────────────────────────────────────
503
+
504
+ const writeRecords = (root, records) => {
505
+ const dir = resolve(root, ADR_DIR_REL);
506
+ mkdirSync(dir, { recursive: true });
507
+ // Index the existing filenames by id ONCE (O(store), not O(records × store)) so a stale same-id
508
+ // file under a DIVERGENT slug (a retitled or prior-run remnant) can be pruned — exactly one file
509
+ // per id survives, the canonical slug wins.
510
+ const existingById = new Map();
511
+ for (const name of readdirSync(dir)) {
512
+ const m = name.match(RECORD_FILE_RE);
513
+ if (!m) continue;
514
+ const arr = existingById.get(m[1]);
515
+ if (arr) arr.push(name);
516
+ else existingById.set(m[1], [name]);
517
+ }
518
+ let written = 0;
519
+ let skipped = 0;
520
+ for (const record of records) {
521
+ for (const name of existingById.get(record.id) ?? []) {
522
+ if (name !== record.fileName) rmSync(resolve(dir, name));
523
+ }
524
+ const path = resolve(dir, record.fileName);
525
+ const body = renderRecord(record);
526
+ if (existsSync(path) && readFileSync(path, 'utf8') === body) {
527
+ skipped += 1; // crash-resumable: a byte-identical record is left as is
528
+ continue;
529
+ }
530
+ writeFileSync(path, body, 'utf8');
531
+ written += 1;
532
+ }
533
+ return { written, skipped };
534
+ };
281
535
 
282
- const USAGE = 'Usage: archive-decisions.mjs [--dry-run|--check] [--today=YYYY-MM-DD]';
536
+ const writeNavigatorFile = (root, corpus, today) => {
537
+ const dir = resolve(root, ADR_DIR_REL);
538
+ mkdirSync(dir, { recursive: true });
539
+ writeFileSync(resolve(root, NAV_REL), buildNavigator(corpus, today), 'utf8');
540
+ };
541
+
542
+ const writeHot = (root, hot, retained, today) => {
543
+ const oldestId = retained.length > 0 ? retained[0].id : hot.entries.length > 0 ? hot.entries[0].id : '000';
544
+ const preamble = rewriteHotPreamble(hot.preamble, oldestId);
545
+ const frontmatter = stampLastUpdated(hot.frontmatter, today);
546
+ writeFileSync(hot.path, renderTier(frontmatter, preamble, retained), 'utf8');
547
+ };
548
+
549
+ // ── CLI ─────────────────────────────────────────────────────────────────────────────────
550
+
551
+ const USAGE = 'Usage: archive-decisions.mjs [--check|--migrate [--apply]|--write-navigator|--dry-run] [--today=YYYY-MM-DD]';
283
552
 
284
553
  const parseArgs = (argv) => {
285
- const flags = { dryRun: false, check: false, help: false };
554
+ const flags = { check: false, migrate: false, apply: false, writeNavigator: false, dryRun: false, help: false };
286
555
  let today = null;
287
556
  for (const arg of argv) {
288
- if (arg === '--dry-run') flags.dryRun = true;
289
- else if (arg === '--check') flags.check = true;
557
+ if (arg === '--check') flags.check = true;
558
+ else if (arg === '--migrate') flags.migrate = true;
559
+ else if (arg === '--apply') flags.apply = true;
560
+ else if (arg === '--write-navigator') flags.writeNavigator = true;
561
+ else if (arg === '--dry-run') flags.dryRun = true;
290
562
  else if (arg === '--help' || arg === '-h') flags.help = true;
291
563
  else if (arg.startsWith('--today=')) today = arg.slice('--today='.length);
292
564
  else throw fail(2, `Unknown argument: ${arg}\n${USAGE}`);
@@ -294,6 +566,257 @@ const parseArgs = (argv) => {
294
566
  return { flags, today };
295
567
  };
296
568
 
569
+ // Store integrity: HOT ∪ adr/ ids are unique (an ADR lives in exactly one place) AND partitioned —
570
+ // every archived record holds a strictly-OLDER (smaller) id than every HOT entry. NUMERIC throughout.
571
+ const assertStoreIntegrity = (hotEntries, adrEntries) => {
572
+ const seen = new Map();
573
+ for (const e of [...adrEntries, ...hotEntries]) {
574
+ const where = e.fileName ? `adr/${e.fileName}` : HOT_REL;
575
+ if (seen.has(e.id)) {
576
+ throw fail(1, `duplicate ADR id AD-${e.id} across the store (${seen.get(e.id)} and ${where}) — an ADR must live in exactly one place; refusing`);
577
+ }
578
+ seen.set(e.id, where);
579
+ }
580
+ // The adr/ store is the past, the HOT window is the present; an interleaved id is corruption.
581
+ if (adrEntries.length > 0 && hotEntries.length > 0) {
582
+ const newestAdr = adrEntries.reduce((a, b) => (b.idNum > a.idNum ? b : a));
583
+ const oldestHot = hotEntries.reduce((a, b) => (b.idNum < a.idNum ? b : a));
584
+ if (newestAdr.idNum >= oldestHot.idNum) {
585
+ throw fail(1, `store partition violated: archived AD-${newestAdr.id} (adr/${newestAdr.fileName}) is not older than active AD-${oldestHot.id} (${HOT_REL}) — the adr/ store must hold strictly-older ids than the HOT window; refusing`);
586
+ }
587
+ }
588
+ };
589
+
590
+ const runMigrate = (root, flags, today, deps, log, logError) => {
591
+ const present = monolithsPresent(root);
592
+ const hasHot = existsSync(resolve(root, HOT_REL));
593
+ if (present.length === 0) {
594
+ if (existsSync(resolve(root, ADR_DIR_REL))) {
595
+ log('[archive-decisions] already migrated — no legacy monolith present; nothing to do.');
596
+ return 0;
597
+ }
598
+ log('[archive-decisions] nothing to migrate — no legacy decisions-archive monolith found.');
599
+ return 0;
600
+ }
601
+ if (!hasHot) throw fail(1, `${HOT_REL} not found but a legacy monolith is present — a broken tree; restore ${HOT_REL} before migrating.`);
602
+
603
+ const hot = loadHot(root);
604
+ const warm = loadMonolith(root, WARM_REL);
605
+ const cold = loadMonolith(root, COLD_REL);
606
+ const existingStore = loadAdrStore(root);
607
+
608
+ // Build the FULL ADR corpus as the UNION of every CURRENT source (HOT ∪ monoliths ∪ adr/ store),
609
+ // keyed by id — the crash-safe / idempotent core (internal-sweep major). The destructive apply
610
+ // trims HOT and deletes the monoliths NON-atomically, so on a resume a source may be gone while
611
+ // its already-written adr record survives; deriving the corpus from the union (not just the
612
+ // surviving monoliths + trimmed HOT) means a re-run reconstructs the SAME corpus and repartitions
613
+ // to the SAME target — it never accuses its own correct records of being "invented history".
614
+ // An id in two sources MUST carry a byte-identical block (else a corrupt / hand-edited resume
615
+ // artifact → FAIL before any write, never silently overwritten).
616
+ const corpusById = new Map();
617
+ const addEntry = (entry, where) => {
618
+ const seen = corpusById.get(entry.id);
619
+ if (seen) {
620
+ if (seen.block !== entry.block) {
621
+ throw fail(1, `AD-${entry.id} appears in ${seen.where} and ${where} with DIFFERENT bodies — a corrupt or hand-edited store/resume artifact; resolve it by hand, refusing to migrate`);
622
+ }
623
+ return;
624
+ }
625
+ corpusById.set(entry.id, { ...entry, where });
626
+ };
627
+ for (const entry of [...warm.entries, ...cold.entries]) addEntry(entry, 'a legacy monolith');
628
+ for (const entry of hot.entries) addEntry(entry, HOT_REL);
629
+ for (const entry of existingStore) addEntry(entry, entry.rel);
630
+ const allEntries = [...corpusById.values()].sort((a, b) => a.idNum - b.idNum);
631
+
632
+ // Retain the bounded CURRENT HOT window (never repopulated from archived entries); everything else
633
+ // in the corpus becomes an adr/ record. The preamble is rewritten first so the cap arithmetic
634
+ // reflects the post-migration (shorter) preamble.
635
+ const provisionalOldest = hot.entries.length > 0 ? hot.entries[0].id : allEntries.length > 0 ? allEntries[0].id : '000';
636
+ const rewrittenPreamble = rewriteHotPreamble(hot.preamble, provisionalOldest);
637
+ const { retained } = boundHot(hot.entries, hot.frontmatter, rewrittenPreamble, hot.cap ?? Infinity);
638
+ const retainedIds = new Set(retained.map((e) => e.id));
639
+ const toExplode = allEntries.filter((e) => !retainedIds.has(e.id));
640
+ const records = explode(toExplode, today);
641
+
642
+ // Conservation: a PURE repartition of the corpus — retained-HOT ∪ written records == the corpus,
643
+ // nothing lost, added, double-counted, or edited.
644
+ const oldItems = allEntries.map((e) => ({ id: e.id, block: e.block }));
645
+ const newItems = [
646
+ ...retained.map((e) => ({ id: e.id, block: e.block })),
647
+ ...records.map((r) => ({ id: r.id, block: r.block })),
648
+ ];
649
+ verifyConservation(oldItems, newItems);
650
+ // Integrity over the FULL final store — existing records ∪ freshly-exploded — not just the new
651
+ // writes: a pre-existing adr/ record whose id also stays RETAINED in HOT would otherwise leave the
652
+ // ADR in two places (codex R4). Same full-store pattern as runRotate.
653
+ const finalStoreById = new Map(existingStore.map((e) => [e.id, { id: e.id, idNum: e.idNum, fileName: e.fileName }]));
654
+ for (const r of records) finalStoreById.set(r.id, { id: r.id, idNum: r.idNum, fileName: r.fileName });
655
+ assertStoreIntegrity(retained, [...finalStoreById.values()]);
656
+
657
+ const summary = {
658
+ records: records.map((r) => r.fileName),
659
+ retainedHot: retained.map((e) => `AD-${e.id}`),
660
+ monolithsRetired: present,
661
+ conservation: `${oldItems.length} corpus blocks → ${retained.length} retained-HOT + ${records.length} records (conserved)`,
662
+ };
663
+
664
+ if (!flags.apply) {
665
+ log('[archive-decisions] --migrate DRY-RUN — no files will be changed.');
666
+ log(JSON.stringify(summary, null, 2));
667
+ return 0;
668
+ }
669
+
670
+ const snapshotFiles = [
671
+ { rel: HOT_REL, content: hot.raw },
672
+ ...(warm.exists ? [{ rel: WARM_REL, content: warm.raw }] : []),
673
+ ...(cold.exists ? [{ rel: COLD_REL, content: cold.raw }] : []),
674
+ ];
675
+ const snapshot = writeSnapshot(root, snapshotFiles, deps);
676
+
677
+ writeRecords(root, records);
678
+ const corpus = [...retained, ...loadAdrStore(root)];
679
+ writeNavigatorFile(root, corpus, today);
680
+ writeHot(root, hot, retained, today);
681
+ // Only NOW — conservation passed AND the snapshot exists — remove the monoliths.
682
+ for (const rel of present) rmSync(resolve(root, rel));
683
+
684
+ const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
685
+ log('[archive-decisions] migrated the 3-tier cascade → one-file-per-ADR store:');
686
+ log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'})`);
687
+ log(` records written: ${records.length} under ${ADR_DIR_REL}/`);
688
+ log(` retained HOT: ${summary.retainedHot.join(', ') || '(none)'}`);
689
+ log(` retired monoliths: ${present.join(', ')}`);
690
+ log(` navigator: ${NAV_REL}`);
691
+ if (regen.ok) log(' regenerated docs/ai/index.md');
692
+ else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
693
+ return 0;
694
+ };
695
+
696
+ const runWriteNavigator = (root, today, deps, log, logError) => {
697
+ if (!existsSync(resolve(root, HOT_REL)) && !existsSync(resolve(root, ADR_DIR_REL))) {
698
+ log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to write.`);
699
+ return 0;
700
+ }
701
+ const present = monolithsPresent(root);
702
+ if (present.length > 0) throw fail(1, `${present.join(', ')} present — a half-migrated tree; run \`--migrate\` first before regenerating the navigator.`);
703
+ const hotEntries = existsSync(resolve(root, HOT_REL)) ? loadHot(root).entries : [];
704
+ const adrEntries = loadAdrStore(root);
705
+ assertStoreIntegrity(hotEntries, adrEntries); // never emit a duplicate-row / corrupt navigator
706
+ const corpus = [...hotEntries, ...adrEntries];
707
+ writeNavigatorFile(root, corpus, today);
708
+ const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
709
+ log(`[archive-decisions] wrote ${NAV_REL} (${corpus.length} ADRs in the corpus).`);
710
+ if (regen.ok) log(' regenerated docs/ai/index.md');
711
+ else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
712
+ return 0;
713
+ };
714
+
715
+ // Reuse the on-disk navigator's OWN lastUpdated for the freshness diff so a mere day-rollover (no
716
+ // corpus change) is never flagged — only genuine drift makes it stale (the --check-index precedent).
717
+ const navLastUpdated = (text) => {
718
+ const m = text.match(/^lastUpdated:\s*(.*)$/m);
719
+ return m ? m[1].trim() : 'unknown';
720
+ };
721
+
722
+ const runCheck = (root, today, log, logError) => {
723
+ const hasHot = existsSync(resolve(root, HOT_REL));
724
+ const hasStore = existsSync(resolve(root, ADR_DIR_REL));
725
+ // The legacy guard runs BEFORE the substrate-absent skip: a lone monolith (HOT and adr/ both
726
+ // absent) is a half-migrated / half-deleted tree, never a clean "no substrate" skip.
727
+ const present = monolithsPresent(root);
728
+ if (present.length > 0) {
729
+ logError(`[archive-decisions] FAIL: legacy monolith present (${present.join(', ')}) — the ADR store is half-migrated; run \`node scripts/archive-decisions.mjs --migrate --apply\` first.`);
730
+ return 1;
731
+ }
732
+ if (!hasHot && !hasStore) {
733
+ log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to check.`);
734
+ return 0;
735
+ }
736
+ const hot = hasHot ? loadHot(root) : null;
737
+ const adrEntries = loadAdrStore(root);
738
+ const hotEntries = hot ? hot.entries : [];
739
+ assertStoreIntegrity(hotEntries, adrEntries);
740
+
741
+ const problems = [];
742
+ if (hot) {
743
+ log(`[archive-decisions] ${HOT_REL}: ${hot.rawLines}/${hot.cap}`);
744
+ 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`);
745
+ }
746
+ log(`[archive-decisions] ${ADR_DIR_REL}: ${adrEntries.length} record(s)`);
747
+
748
+ // Navigator freshness: regenerate in memory and diff against on-disk (the folded --check that never
749
+ // false-blocks — --write-navigator is the deterministic fix).
750
+ const navPath = resolve(root, NAV_REL);
751
+ const corpus = [...hotEntries, ...adrEntries];
752
+ const expected = buildNavigator(corpus, existsSync(navPath) ? navLastUpdated(readFileSync(navPath, 'utf8')) : today);
753
+ const onDisk = existsSync(navPath) ? readFileSync(navPath, 'utf8') : null;
754
+ if (onDisk !== expected) {
755
+ problems.push(`${NAV_REL} is stale (out of sync with the ADR corpus) — run \`node scripts/archive-decisions.mjs --write-navigator\` and commit it`);
756
+ }
757
+
758
+ if (problems.length > 0) {
759
+ for (const p of problems) logError(`[archive-decisions] FAIL: ${p}.`);
760
+ return 1;
761
+ }
762
+ log('[archive-decisions] OK — HOT within cap, store integrity intact, navigator fresh.');
763
+ return 0;
764
+ };
765
+
766
+ const runRotate = (root, flags, today, deps, log, logError) => {
767
+ const present = monolithsPresent(root);
768
+ if (present.length > 0) {
769
+ logError(`[archive-decisions] FAIL: legacy monolith present (${present.join(', ')}) — the ADR store is half-migrated; run \`node scripts/archive-decisions.mjs --migrate --apply\` first (a default rotate never half-explodes).`);
770
+ return 1;
771
+ }
772
+ const hot = loadHot(root);
773
+ const existingStore = loadAdrStore(root);
774
+ const { retained, toExplode } = boundHot(hot.entries, hot.frontmatter, hot.preamble, hot.cap ?? Infinity);
775
+ if (toExplode.length === 0 && hot.rawLines <= (hot.cap ?? Infinity)) {
776
+ assertStoreIntegrity(hot.entries, existingStore); // a no-op still refuses a corrupt store (dup / partition)
777
+ log(`[archive-decisions] nothing to rotate — HOT ${hot.rawLines}/${hot.cap}, ${existingStore.length} archived record(s).`);
778
+ return 0;
779
+ }
780
+ const rewrittenPreamble = rewriteHotPreamble(hot.preamble, retained.length > 0 ? retained[0].id : '000');
781
+ if (hot.cap !== null && lineCountOf(renderTier(hot.frontmatter, rewrittenPreamble, retained)) > hot.cap) {
782
+ throw fail(1, `${HOT_REL} is over its cap but cannot be reduced (the newest entry alone exceeds ${hot.cap} lines) — trim it or raise the cap (a maintainer decision; this script only moves entries).`);
783
+ }
784
+
785
+ const records = explode(toExplode, today);
786
+ // Crash-resume (fold-induced, codex R2): a record from a prior crashed rotate may already be on
787
+ // disk. A byte-identical one is done (deduped, not a duplicate-id error); a divergent-body one is
788
+ // corrupt → FAIL. The FINAL store = existing ∪ freshly-exploded, deduped by id.
789
+ const existingById = new Map(existingStore.map((e) => [e.id, e]));
790
+ for (const rec of records) {
791
+ const ex = existingById.get(rec.id);
792
+ if (ex && ex.block !== rec.block) {
793
+ throw fail(1, `${ex.rel}: the existing adr/ record for AD-${rec.id} diverges from the freshly-exploded block — a corrupt or hand-edited crash-resume artifact; resolve it by hand, refusing to overwrite`);
794
+ }
795
+ }
796
+ const finalStoreById = new Map(existingStore.map((e) => [e.id, { id: e.id, idNum: e.idNum, fileName: e.fileName }]));
797
+ for (const rec of records) finalStoreById.set(rec.id, { id: rec.id, idNum: rec.idNum, fileName: rec.fileName });
798
+ assertStoreIntegrity(retained, [...finalStoreById.values()]);
799
+
800
+ const summary = { explode: records.map((r) => r.fileName), retainedHot: retained.map((e) => `AD-${e.id}`) };
801
+ if (flags.dryRun) {
802
+ log('[archive-decisions] DRY-RUN — no files will be changed.');
803
+ log(JSON.stringify(summary, null, 2));
804
+ return 0;
805
+ }
806
+
807
+ writeRecords(root, records);
808
+ const corpus = [...retained, ...loadAdrStore(root)];
809
+ writeNavigatorFile(root, corpus, today);
810
+ writeHot(root, hot, retained, today);
811
+ const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
812
+ log('[archive-decisions] rotated:');
813
+ log(` exploded to adr/: ${summary.explode.join(', ') || '(none)'}`);
814
+ log(` retained HOT: ${summary.retainedHot.join(', ')}`);
815
+ if (regen.ok) log(' regenerated docs/ai/index.md');
816
+ else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
817
+ return 0;
818
+ };
819
+
297
820
  export const runCli = (argv, deps = {}) => {
298
821
  const { root = DEFAULT_ROOT, log = console.log, logError = console.error } = deps;
299
822
  try {
@@ -304,116 +827,15 @@ export const runCli = (argv, deps = {}) => {
304
827
  }
305
828
  const today = todayOpt ?? new Date().toISOString().slice(0, 10);
306
829
 
307
- // DELIBERATE divergence from archive-changelog.mjs (which crashes ENOENT): an absent ADR
308
- // substrate is a STATED SKIP on --check — the deployed pre-commit hook must never block a
309
- // commit over a decisions.md the project simply does not keep.
830
+ if (flags.migrate) return runMigrate(root, flags, today, deps, log, logError);
831
+ if (flags.writeNavigator) return runWriteNavigator(root, today, deps, log, logError);
832
+ if (flags.check) return runCheck(root, today, log, logError);
833
+
310
834
  if (!existsSync(resolve(root, HOT_REL))) {
311
- if (flags.check) {
312
- log(`[archive-decisions] SKIP — ${HOT_REL} not found (this project keeps no ADR file); nothing to check.`);
313
- return 0;
314
- }
315
835
  logError(`[archive-decisions] ${HOT_REL} not found — nothing to rotate.`);
316
836
  return 1;
317
837
  }
318
-
319
- const tiers = loadTiers(root, today);
320
- const usage = (tier, entries = tier.entries) => `${lineCountOf(renderTier(tier, entries))}/${tier.cap}`;
321
- // Cap checks count the REAL on-disk lines (what the docs cap-validator counts) — a normalized
322
- // render undercounts a template-shaped file with `---` separators and would false-green.
323
- const rawUsage = (tier) => (tier.exists ? tier.rawLines : lineCountOf(renderTier(tier, tier.entries)));
324
-
325
- if (flags.check) {
326
- const over = [];
327
- for (const tier of [tiers.hot, tiers.warm, tiers.cold]) {
328
- const lines = rawUsage(tier);
329
- log(`[archive-decisions] ${tier.rel}: ${lines}/${tier.cap}${tier.exists ? '' : ' (absent — would be created on rotation)'}`);
330
- if (lines > tier.cap) over.push(tier);
331
- }
332
- if (over.length > 0) {
333
- for (const tier of over) {
334
- const recovery =
335
- tier.rel === COLD_REL
336
- ? 'the COLD tier is exhausted — a cap raise or a new COLD file is a maintainer/agent decision'
337
- : 'run `node scripts/archive-decisions.mjs` to rotate';
338
- logError(`[archive-decisions] FAIL: ${tier.rel} is over its cap — ${recovery}.`);
339
- }
340
- return 1;
341
- }
342
- log('[archive-decisions] OK — every tier is within its cap.');
343
- return 0;
344
- }
345
-
346
- const plan = planRotation(tiers);
347
- // A tier over its cap on RAW lines needs a write even with zero entry moves — normalization
348
- // (the canonical rendered form) alone brings it back under (the moves loop already ensured
349
- // the RENDERED result fits; if it did not, moves would be non-empty).
350
- const normalizeOnly = [tiers.hot, tiers.warm, tiers.cold].filter((tier) => tier.exists && tier.rawLines > tier.cap);
351
- if (plan.moves.hotToWarm.length === 0 && plan.moves.warmToCold.length === 0 && normalizeOnly.length === 0) {
352
- log(`[archive-decisions] nothing to rotate — HOT ${usage(tiers.hot)}, WARM ${usage(tiers.warm)}, COLD ${usage(tiers.cold)}.`);
353
- return 0;
354
- }
355
- verifyConservation(
356
- [tiers.hot.entries, tiers.warm.entries, tiers.cold.entries],
357
- [plan.hotEntries, plan.warmEntries, plan.coldEntries],
358
- );
359
-
360
- const summary = {
361
- hotToWarm: plan.moves.hotToWarm.map((id) => `AD-${id}`),
362
- warmToCold: plan.moves.warmToCold.map((id) => `AD-${id}`),
363
- normalizeOnly: normalizeOnly.map((tier) => tier.rel),
364
- after: {
365
- hot: usage(tiers.hot, plan.hotEntries),
366
- warm: usage(tiers.warm, plan.warmEntries),
367
- cold: usage(tiers.cold, plan.coldEntries),
368
- },
369
- };
370
-
371
- if (flags.dryRun) {
372
- log('[archive-decisions] DRY-RUN — no files will be changed.');
373
- log(JSON.stringify(summary, null, 2));
374
- return 0;
375
- }
376
-
377
- const ranges = { hotEntries: plan.hotEntries, warmEntries: plan.warmEntries, coldEntries: plan.coldEntries };
378
- const writes = [
379
- { tier: tiers.hot, entries: plan.hotEntries, kind: 'hot' },
380
- { tier: tiers.warm, entries: plan.warmEntries, kind: 'warm' },
381
- { tier: tiers.cold, entries: plan.coldEntries, kind: 'cold' },
382
- ];
383
- // A rewrite must never claim success while a tier is STILL over its cap — normalization is
384
- // not a licence: if the planned rendered result exceeds the budget (a genuinely exhausted
385
- // COLD is the reachable case — the move loops already bound HOT and WARM), fail LOUD before
386
- // any write.
387
- for (const { tier, entries } of writes) {
388
- const plannedLines = lineCountOf(renderTier(tier, entries));
389
- if (plannedLines > tier.cap) {
390
- const recovery =
391
- tier.rel === COLD_REL
392
- ? 'the COLD tier is exhausted — a cap raise or a new COLD file is a maintainer/agent decision'
393
- : 'raise the cap or trim the offending entries (maintainer decision)';
394
- throw fail(1, `refusing BEFORE any write: ${tier.rel} would still be over its cap after rotation (${plannedLines}/${tier.cap}) — ${recovery}`);
395
- }
396
- }
397
- for (const { tier, entries, kind } of writes) {
398
- // Never materialize an ABSENT tier that still has nothing to hold — a normalize-only
399
- // rewrite of HOT must not seed empty WARM/COLD files into a project that never rotated.
400
- if (!tier.exists && entries.length === 0) continue;
401
- const updated = {
402
- ...tier,
403
- frontmatter: stampLastUpdated(tier.frontmatter, today),
404
- preamble: updateRangeTokens(tier.preamble, kind, ranges),
405
- };
406
- mkdirSync(dirname(tier.path), { recursive: true });
407
- writeFileSync(tier.path, renderTier(updated, entries), 'utf8');
408
- }
409
- log('[archive-decisions] rotated:');
410
- log(` HOT→WARM: ${summary.hotToWarm.join(', ') || '(none)'}`);
411
- log(` WARM→COLD: ${summary.warmToCold.join(', ') || '(none)'}`);
412
- if (summary.normalizeOnly.length > 0 && summary.hotToWarm.length === 0 && summary.warmToCold.length === 0) {
413
- log(` normalize-only rewrite (over cap on raw lines, no entry moves): ${summary.normalizeOnly.join(', ')}`);
414
- }
415
- log(` now: HOT ${summary.after.hot} · WARM ${summary.after.warm} · COLD ${summary.after.cold}`);
416
- return 0;
838
+ return runRotate(root, flags, today, deps, log, logError);
417
839
  } catch (err) {
418
840
  logError(`[archive-decisions] ${err.message}`);
419
841
  return err.exitCode ?? 1;