@sabaiway/agent-workflow-memory 1.12.0 → 2.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.
@@ -1,95 +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), then regenerate
14
- // docs/ai/index.md so the rotation never leaves the index stale (item (h))
15
- // --dry-run print the planned move-set, change nothing
16
- // --check report per-tier lines/cap; exit 1 if any tier is over its cap
17
- // --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).
18
35
  //
19
36
  // FAIL-LOUD invariants (the Issue-009 lesson — never silently glue an entry to the previous body):
20
- // • every `## ` heading in every tier MUST parse canonically as `## AD-0NN — <title>` — a
21
- // malformed heading is exit 1 naming the file + line, never a silent merge;
22
- // • ADR ids must be strictly ascending within a tier and unique across tiers;
23
- // • a COLD tier at its cap or a roll that would not fit COLD's remaining headroom — fails
24
- // LOUD **before any write** (a cap raise / a new COLD file is a maintainer/agent decision;
25
- // this script only moves entries);
26
- // • conservation is self-verified before writing: the multiset of AD ids across all three
27
- // tiers and every entry's line count are unchanged by the plan.
28
- //
29
- // DELIBERATE divergence from the siblings: on a project WITHOUT docs/ai/decisions.md, `--check`
30
- // reports the absence and exits 0 — the deployed pre-commit hook must never block a commit over
31
- // an absent ADR substrate. (archive-changelog.mjs reads its source unconditionally and crashes
32
- // 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).
33
44
  //
34
- // Cap accounting is on the REAL on-disk line count (what the docs cap-validator counts), never
35
- // on a normalized render a template-shaped file with `---` separators between entries must not
36
- // false-green near its cap. A write NORMALIZES formatting (entries joined by one blank line;
37
- // separators dropped), so when a tier is over cap on raw lines but already fits after
38
- // 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.
39
48
  //
40
49
  // Dependency-free, Node >= 18. Deployed into a consumer's scripts/ like its siblings.
41
50
 
42
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
43
- 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';
44
53
  import { fileURLToPath, pathToFileURL } from 'node:url';
45
54
  import { spawnSync } from 'node:child_process';
55
+ import { createHash } from 'node:crypto';
56
+ import { tmpdir } from 'node:os';
46
57
 
47
58
  const __dirname = dirname(fileURLToPath(import.meta.url));
48
59
  const DEFAULT_ROOT = resolve(__dirname, '..');
49
60
 
50
- // (h) — after a rotation write, docs/ai/index.md would silently go stale (a moved ADR, or just the
51
- // bumped lastUpdated), tripping the SEPARATE `--check-index` gate mid-release-matrix. So the
52
- // rotation regenerates the index by REUSING the ONE generator in the sibling check-docs-size.mjs
53
- // (spawned with --write-index --root=<root>). The subprocess bridges that script's ASYNC generator,
54
- // so this runCli stays SYNCHRONOUS (spawnSync) — the existing sync callers/tests never ripple. It
55
- // DEGRADES LOUDLY to an instruct (never a silent failure) when the sibling is absent or the
56
- // regeneration fails; the --check-index gate still catches a stale index. Injectable for tests.
57
- const CHECK_DOCS_SIBLING = resolve(__dirname, 'check-docs-size.mjs');
58
- const INDEX_INSTRUCT = 'run `node scripts/check-docs-size.mjs --write-index` to refresh docs/ai/index.md';
59
- // Exported + its filesystem edges injectable (deps) so BOTH degrade branches are unit-testable.
60
- export const defaultRegenerateIndex = (root, today, deps = {}) => {
61
- const exists = deps.existsSync ?? existsSync;
62
- const spawn = deps.spawnSync ?? spawnSync;
63
- const sibling = deps.sibling ?? CHECK_DOCS_SIBLING;
64
- if (!exists(sibling)) {
65
- return { ok: false, detail: `the index generator is not beside this script — ${INDEX_INSTRUCT}` };
66
- }
67
- // `--report` ISOLATES the index-WRITE outcome from the docs-cap-CHECK outcome: check-docs-size
68
- // --write-index still WRITES the index (and still exits 2 on an empty write / rejects a genuine
69
- // throw), but --report suppresses its exit-1 on an unrelated over-cap co-located doc — otherwise a
70
- // benign over-cap sibling would read as an index-regeneration FAILURE (a cry-wolf on this very
71
- // loud-degrade channel).
72
- const r = spawn(process.execPath, [sibling, '--write-index', '--report', `--root=${root}`, `--today=${today}`], { encoding: 'utf8' });
73
- if (r.error || r.status !== 0) {
74
- return { ok: false, detail: `index regeneration failed (${(r.error && r.error.message) || `exit ${r.status}`}) — ${INDEX_INSTRUCT}` };
75
- }
76
- return { ok: true, detail: (r.stdout || '').trim() };
77
- };
78
-
79
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.
80
64
  export const WARM_REL = 'docs/ai/history/decisions-archive.md';
81
65
  export const COLD_REL = 'docs/ai/history/decisions-archive-early.md';
82
-
83
- const DEFAULT_WARM_CAP = 500;
84
- const DEFAULT_COLD_CAP = 400;
85
-
86
- 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,}) — (.+)$/;
87
78
  const ANY_H2_RE = /^## /;
88
79
  const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/;
80
+ const RECORD_FILE_RE = /^AD-(\d{3,})-.*\.md$/;
89
81
 
90
82
  export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
91
83
 
92
- // ── parsing (strict; malformed headings are LOUD) ─────────────────────────────────────
84
+ // ── parsing (strict; malformed headings are LOUD) + lifecycle extraction ───────────────
93
85
 
94
86
  const stripTrailingSeparators = (blockLines) => {
95
87
  const lines = [...blockLines];
@@ -99,8 +91,40 @@ const stripTrailingSeparators = (blockLines) => {
99
91
  return lines;
100
92
  };
101
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
+
102
126
  // Parse one tier's text → { frontmatter, cap, preamble, entries }. Every `## ` line must be a
103
- // 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).
104
128
  export const parseDecisionsText = (text, label) => {
105
129
  const fmMatch = text.match(FRONTMATTER_RE);
106
130
  const frontmatter = fmMatch ? fmMatch[1] : '';
@@ -114,7 +138,7 @@ export const parseDecisionsText = (text, label) => {
114
138
  if (!HEADING_RE.test(line)) {
115
139
  throw fail(
116
140
  1,
117
- `${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)`,
118
142
  );
119
143
  }
120
144
  startIdxs.push(i);
@@ -127,12 +151,14 @@ export const parseDecisionsText = (text, label) => {
127
151
  const end = i + 1 < startIdxs.length ? startIdxs[i + 1] : lines.length;
128
152
  const blockLines = stripTrailingSeparators(lines.slice(idx, end));
129
153
  const m = HEADING_RE.exec(lines[idx]);
154
+ const block = blockLines.join('\n');
130
155
  return {
131
156
  id: m[1],
132
157
  idNum: Number(m[1]),
133
158
  title: m[2],
134
- block: blockLines.join('\n'),
159
+ block,
135
160
  lineCount: blockLines.length,
161
+ ...extractLifecycle(block),
136
162
  };
137
163
  });
138
164
 
@@ -140,7 +166,7 @@ export const parseDecisionsText = (text, label) => {
140
166
  if (entries[i].idNum <= entries[i - 1].idNum) {
141
167
  throw fail(
142
168
  1,
143
- `${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`,
144
170
  );
145
171
  }
146
172
  }
@@ -149,175 +175,390 @@ export const parseDecisionsText = (text, label) => {
149
175
  return { frontmatter, cap: capMatch ? Number(capMatch[1]) : null, preamble, entries };
150
176
  };
151
177
 
152
- // ── tier IO ───────────────────────────────────────────────────────────────────────────
153
-
154
- const CREATED_FRONTMATTER = (cap, today) =>
155
- `---\ntype: history\nlastUpdated: ${today}\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${cap}\n---\n`;
156
-
157
- const CREATED_WARM_PREAMBLE = [
158
- '# Architecture Decision Records — Archive (AD-000 … AD-000)',
159
- '',
160
- '> Stable ADRs rotated out of the active [`decisions.md`](../decisions.md) per the 3-tier archive',
161
- '> discipline. The earliest entries roll further to the COLD',
162
- '> [`decisions-archive-early.md`](./decisions-archive-early.md). Cross-links (`[[AD-XXX]]`) resolve',
163
- '> by id across all three decision files.',
164
- ].join('\n');
165
-
166
- const CREATED_COLD_PREAMBLE = [
167
- '# Architecture Decision Records Early Archive (AD-000 … AD-000)',
168
- '',
169
- '> The earliest foundational ADRs, rolled out of [`decisions-archive.md`](./decisions-archive.md) (WARM)',
170
- '> into this COLD tier when the WARM archive neared its cap. Cross-links (`[[AD-XXX]]`) still resolve',
171
- '> by id across all three decision files.',
172
- ].join('\n');
173
-
174
- export const loadTiers = (root, today) => {
175
- const load = (rel, { createdCap, createdPreamble }) => {
176
- const path = resolve(root, rel);
177
- if (existsSync(path)) {
178
- const raw = readFileSync(path, 'utf8');
179
- const parsed = parseDecisionsText(raw, rel);
180
- if (parsed.cap === null) throw fail(1, `${rel}: frontmatter carries no maxLines cap — refusing to rotate against an unknown budget`);
181
- return { rel, path, exists: true, rawLines: lineCountOf(raw), ...parsed };
182
- }
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);
183
222
  return {
184
- rel,
185
- path,
186
- exists: false,
187
- rawLines: 0,
188
- frontmatter: CREATED_FRONTMATTER(createdCap, today),
189
- cap: createdCap,
190
- preamble: createdPreamble,
191
- 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,
192
229
  };
193
- };
194
- const hot = load(HOT_REL, { createdCap: 0, createdPreamble: '' }); // HOT is never created here
195
- const warm = load(WARM_REL, { createdCap: DEFAULT_WARM_CAP, createdPreamble: CREATED_WARM_PREAMBLE });
196
- const cold = load(COLD_REL, { createdCap: DEFAULT_COLD_CAP, createdPreamble: CREATED_COLD_PREAMBLE });
230
+ });
197
231
 
198
- const seen = new Map();
199
- for (const tier of [hot, warm, cold]) {
200
- for (const entry of tier.entries) {
201
- 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`);
202
- seen.set(entry.id, tier.rel);
203
- }
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));
204
241
  }
205
- return { hot, warm, cold };
242
+ return map;
206
243
  };
207
244
 
208
- // ── rendering ─────────────────────────────────────────────────────────────────────────
209
-
210
- export const renderTier = (tier, entries) => {
211
- const blocks = entries.map((entry) => entry.block).join('\n\n');
212
- const body = [tier.preamble, blocks].filter((part) => part !== '').join('\n\n');
213
- 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
+ }
214
258
  };
215
259
 
216
- export const lineCountOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
260
+ // ── tier / store IO ─────────────────────────────────────────────────────────────────────
217
261
 
218
- // ── 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);
219
263
 
220
- export const planRotation = ({ hot, warm, cold }) => {
221
- const hotEntries = [...hot.entries];
222
- const warmEntries = [...warm.entries];
223
- const coldEntries = [...cold.entries];
224
- 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
+ };
225
273
 
226
- 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
+ };
227
281
 
228
- const rollWarmToCold = () => {
229
- if (warmEntries.length === 0) {
230
- 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`);
231
300
  }
232
- const moved = warmEntries[0];
233
- const next = [...coldEntries, moved];
234
- if (linesOf(cold, next) > cold.cap) {
235
- throw fail(
236
- 1,
237
- `refusing BEFORE any write: rolling AD-${moved.id} (${moved.lineCount} lines) into ${COLD_REL} would exceed its cap ` +
238
- `(${linesOf(cold, coldEntries)}/${cold.cap} now, ${linesOf(cold, next)} after) — the COLD tier is exhausted; ` +
239
- 'a cap raise or a new COLD file is a maintainer/agent decision, this script only moves entries',
240
- );
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)
241
307
  }
242
- warmEntries.shift();
243
- coldEntries.push(moved);
244
- moves.warmToCold.push(moved.id);
245
- };
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
+ };
246
328
 
247
- const ensureWarmFits = () => {
248
- while (linesOf(warm, warmEntries) > warm.cap) rollWarmToCold();
249
- };
329
+ // ── HOT rendering + preamble rewrite (Decision 15) ──────────────────────────────────────
250
330
 
251
- ensureWarmFits(); // a pre-existing WARM overflow chains down first
252
- while (linesOf(hot, hotEntries) > hot.cap) {
253
- if (hotEntries.length <= 1) {
254
- 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)`);
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
+ };
336
+
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;
255
358
  }
256
- const moved = hotEntries.shift();
257
- warmEntries.push(moved);
258
- moves.hotToWarm.push(moved.id);
259
- ensureWarmFits();
359
+ out.push(line);
260
360
  }
361
+ return out.join('\n');
362
+ };
261
363
 
262
- return { moves, hotEntries, warmEntries, coldEntries };
364
+ const stampLastUpdated = (frontmatter, today) => frontmatter.replace(/^lastUpdated: .*$/m, `lastUpdated: ${today}`);
365
+
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 };
263
375
  };
264
376
 
265
- // Conservation self-verify: the multiset of (id lineCount) across all tiers is unchanged.
266
- export const verifyConservation = (before, after) => {
267
- const snapshot = (tiers) =>
268
- tiers
269
- .flat()
270
- .map((entry) => `${entry.id}:${entry.lineCount}`)
271
- .sort()
272
- .join('|');
273
- const beforeKey = snapshot(before);
274
- const afterKey = snapshot(after);
275
- if (beforeKey !== afterKey) {
276
- 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
+ }
277
392
  }
393
+ return superseded;
394
+ };
395
+
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));
278
401
  };
279
402
 
280
- // ── preamble range-token maintenance ──────────────────────────────────────────────────
281
- // The hand-authored preambles carry range tokens ("AD-014 … AD-023", "(AD-024 onward)",
282
- // "from **AD-024**"). After a rotation those would silently lie, so the recognizable tokens are
283
- // updated in place; a preamble without them (a consumer's own wording) is left untouched.
284
-
285
- const RANGE_TOKEN_RE = /AD-\d{3} AD-\d{3}/g;
286
-
287
- const formatRange = (entries) => `AD-${entries[0].id} … AD-${entries[entries.length - 1].id}`;
288
-
289
- export const updateRangeTokens = (preamble, kind, { hotEntries, warmEntries, coldEntries }) => {
290
- // Token order per tier file: HOT + WARM preambles mention the WARM range then the COLD range;
291
- // COLD mentions only its own. This ASSUMES the hand-authored order (WARM before COLD) — the
292
- // shape all three files here carry. A maintainer who rewrites a preamble with the ranges
293
- // swapped would get the bounds injected into the wrong slots; if you reorder the ranges,
294
- // update this sequence too (a preamble WITHOUT the tokens is simply left untouched).
295
- const sequence = kind === 'cold' ? [coldEntries] : [warmEntries, coldEntries];
296
- let occurrence = 0;
297
- let out = preamble.replace(RANGE_TOKEN_RE, (token) => {
298
- const tierEntries = sequence[Math.min(occurrence, sequence.length - 1)];
299
- occurrence += 1;
300
- return tierEntries.length > 0 ? formatRange(tierEntries) : token;
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}\``;
301
425
  });
302
- if (hotEntries.length > 0) {
303
- out = out.replace(/\(AD-\d{3} onward\)/, `(AD-${hotEntries[0].id} onward)`);
304
- 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
+ }
305
479
  }
306
- 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`);
307
481
  };
308
482
 
309
- 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
+ };
501
+
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
+ };
310
535
 
311
- // ── CLI ───────────────────────────────────────────────────────────────────────────────
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
+ };
312
541
 
313
- const USAGE = 'Usage: archive-decisions.mjs [--dry-run|--check] [--today=YYYY-MM-DD]';
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]';
314
552
 
315
553
  const parseArgs = (argv) => {
316
- const flags = { dryRun: false, check: false, help: false };
554
+ const flags = { check: false, migrate: false, apply: false, writeNavigator: false, dryRun: false, help: false };
317
555
  let today = null;
318
556
  for (const arg of argv) {
319
- if (arg === '--dry-run') flags.dryRun = true;
320
- 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;
321
562
  else if (arg === '--help' || arg === '-h') flags.help = true;
322
563
  else if (arg.startsWith('--today=')) today = arg.slice('--today='.length);
323
564
  else throw fail(2, `Unknown argument: ${arg}\n${USAGE}`);
@@ -325,8 +566,259 @@ const parseArgs = (argv) => {
325
566
  return { flags, today };
326
567
  };
327
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
+
328
820
  export const runCli = (argv, deps = {}) => {
329
- const { root = DEFAULT_ROOT, log = console.log, logError = console.error, regenerateIndex = defaultRegenerateIndex } = deps;
821
+ const { root = DEFAULT_ROOT, log = console.log, logError = console.error } = deps;
330
822
  try {
331
823
  const { flags, today: todayOpt } = parseArgs(argv);
332
824
  if (flags.help) {
@@ -335,122 +827,15 @@ export const runCli = (argv, deps = {}) => {
335
827
  }
336
828
  const today = todayOpt ?? new Date().toISOString().slice(0, 10);
337
829
 
338
- // DELIBERATE divergence from archive-changelog.mjs (which crashes ENOENT): an absent ADR
339
- // substrate is a STATED SKIP on --check — the deployed pre-commit hook must never block a
340
- // 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
+
341
834
  if (!existsSync(resolve(root, HOT_REL))) {
342
- if (flags.check) {
343
- log(`[archive-decisions] SKIP — ${HOT_REL} not found (this project keeps no ADR file); nothing to check.`);
344
- return 0;
345
- }
346
835
  logError(`[archive-decisions] ${HOT_REL} not found — nothing to rotate.`);
347
836
  return 1;
348
837
  }
349
-
350
- const tiers = loadTiers(root, today);
351
- const usage = (tier, entries = tier.entries) => `${lineCountOf(renderTier(tier, entries))}/${tier.cap}`;
352
- // Cap checks count the REAL on-disk lines (what the docs cap-validator counts) — a normalized
353
- // render undercounts a template-shaped file with `---` separators and would false-green.
354
- const rawUsage = (tier) => (tier.exists ? tier.rawLines : lineCountOf(renderTier(tier, tier.entries)));
355
-
356
- if (flags.check) {
357
- const over = [];
358
- for (const tier of [tiers.hot, tiers.warm, tiers.cold]) {
359
- const lines = rawUsage(tier);
360
- log(`[archive-decisions] ${tier.rel}: ${lines}/${tier.cap}${tier.exists ? '' : ' (absent — would be created on rotation)'}`);
361
- if (lines > tier.cap) over.push(tier);
362
- }
363
- if (over.length > 0) {
364
- for (const tier of over) {
365
- const recovery =
366
- tier.rel === COLD_REL
367
- ? 'the COLD tier is exhausted — a cap raise or a new COLD file is a maintainer/agent decision'
368
- : 'run `node scripts/archive-decisions.mjs` to rotate';
369
- logError(`[archive-decisions] FAIL: ${tier.rel} is over its cap — ${recovery}.`);
370
- }
371
- return 1;
372
- }
373
- log('[archive-decisions] OK — every tier is within its cap.');
374
- return 0;
375
- }
376
-
377
- const plan = planRotation(tiers);
378
- // A tier over its cap on RAW lines needs a write even with zero entry moves — normalization
379
- // (the canonical rendered form) alone brings it back under (the moves loop already ensured
380
- // the RENDERED result fits; if it did not, moves would be non-empty).
381
- const normalizeOnly = [tiers.hot, tiers.warm, tiers.cold].filter((tier) => tier.exists && tier.rawLines > tier.cap);
382
- if (plan.moves.hotToWarm.length === 0 && plan.moves.warmToCold.length === 0 && normalizeOnly.length === 0) {
383
- log(`[archive-decisions] nothing to rotate — HOT ${usage(tiers.hot)}, WARM ${usage(tiers.warm)}, COLD ${usage(tiers.cold)}.`);
384
- return 0;
385
- }
386
- verifyConservation(
387
- [tiers.hot.entries, tiers.warm.entries, tiers.cold.entries],
388
- [plan.hotEntries, plan.warmEntries, plan.coldEntries],
389
- );
390
-
391
- const summary = {
392
- hotToWarm: plan.moves.hotToWarm.map((id) => `AD-${id}`),
393
- warmToCold: plan.moves.warmToCold.map((id) => `AD-${id}`),
394
- normalizeOnly: normalizeOnly.map((tier) => tier.rel),
395
- after: {
396
- hot: usage(tiers.hot, plan.hotEntries),
397
- warm: usage(tiers.warm, plan.warmEntries),
398
- cold: usage(tiers.cold, plan.coldEntries),
399
- },
400
- };
401
-
402
- if (flags.dryRun) {
403
- log('[archive-decisions] DRY-RUN — no files will be changed.');
404
- log(JSON.stringify(summary, null, 2));
405
- return 0;
406
- }
407
-
408
- const ranges = { hotEntries: plan.hotEntries, warmEntries: plan.warmEntries, coldEntries: plan.coldEntries };
409
- const writes = [
410
- { tier: tiers.hot, entries: plan.hotEntries, kind: 'hot' },
411
- { tier: tiers.warm, entries: plan.warmEntries, kind: 'warm' },
412
- { tier: tiers.cold, entries: plan.coldEntries, kind: 'cold' },
413
- ];
414
- // A rewrite must never claim success while a tier is STILL over its cap — normalization is
415
- // not a licence: if the planned rendered result exceeds the budget (a genuinely exhausted
416
- // COLD is the reachable case — the move loops already bound HOT and WARM), fail LOUD before
417
- // any write.
418
- for (const { tier, entries } of writes) {
419
- const plannedLines = lineCountOf(renderTier(tier, entries));
420
- if (plannedLines > tier.cap) {
421
- const recovery =
422
- tier.rel === COLD_REL
423
- ? 'the COLD tier is exhausted — a cap raise or a new COLD file is a maintainer/agent decision'
424
- : 'raise the cap or trim the offending entries (maintainer decision)';
425
- throw fail(1, `refusing BEFORE any write: ${tier.rel} would still be over its cap after rotation (${plannedLines}/${tier.cap}) — ${recovery}`);
426
- }
427
- }
428
- for (const { tier, entries, kind } of writes) {
429
- // Never materialize an ABSENT tier that still has nothing to hold — a normalize-only
430
- // rewrite of HOT must not seed empty WARM/COLD files into a project that never rotated.
431
- if (!tier.exists && entries.length === 0) continue;
432
- const updated = {
433
- ...tier,
434
- frontmatter: stampLastUpdated(tier.frontmatter, today),
435
- preamble: updateRangeTokens(tier.preamble, kind, ranges),
436
- };
437
- mkdirSync(dirname(tier.path), { recursive: true });
438
- writeFileSync(tier.path, renderTier(updated, entries), 'utf8');
439
- }
440
- // (h) — the write loop completed (a full successful rotation OR a normalize-only rewrite): the
441
- // docs index is now stale, so regenerate it here (never on --check / --dry-run / the
442
- // nothing-to-rotate no-op / a pre-write refusal — those return before this point).
443
- const regen = regenerateIndex(root, today);
444
- log('[archive-decisions] rotated:');
445
- log(` HOT→WARM: ${summary.hotToWarm.join(', ') || '(none)'}`);
446
- log(` WARM→COLD: ${summary.warmToCold.join(', ') || '(none)'}`);
447
- if (summary.normalizeOnly.length > 0 && summary.hotToWarm.length === 0 && summary.warmToCold.length === 0) {
448
- log(` normalize-only rewrite (over cap on raw lines, no entry moves): ${summary.normalizeOnly.join(', ')}`);
449
- }
450
- log(` now: HOT ${summary.after.hot} · WARM ${summary.after.warm} · COLD ${summary.after.cold}`);
451
- if (regen.ok) log(' regenerated docs/ai/index.md (the rotation kept the index fresh)');
452
- else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
453
- return 0;
838
+ return runRotate(root, flags, today, deps, log, logError);
454
839
  } catch (err) {
455
840
  logError(`[archive-decisions] ${err.message}`);
456
841
  return err.exitCode ?? 1;