@sabaiway/agent-workflow-memory 1.12.0 → 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,24 +1,33 @@
1
1
  import { describe, it, afterEach } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import {
7
7
  HOT_REL,
8
8
  WARM_REL,
9
9
  COLD_REL,
10
+ ADR_DIR_REL,
11
+ NAV_REL,
12
+ HEADING_RE,
13
+ RECORD_CAP,
10
14
  parseDecisionsText,
11
- loadTiers,
12
- renderTier,
15
+ slugify,
16
+ recordFileName,
17
+ explode,
18
+ blockHash,
19
+ verifyConservation,
20
+ computeSupersededSet,
21
+ buildNavigator,
22
+ loadAdrStore,
13
23
  lineCountOf,
14
- planRotation,
15
- updateRangeTokens,
16
24
  runCli,
17
25
  defaultRegenerateIndex,
18
26
  } from './archive-decisions.mjs';
19
27
 
20
- // Hermetic by design: this test ships as deploy payload and runs inside CONSUMER repos via the
21
- // pre-commit `node --test scripts/*.test.mjs` — it must never read the host repo's docs/ai.
28
+ // Hermetic: this test ships as deploy payload and runs inside CONSUMER repos via the pre-commit
29
+ // `node --test scripts/*.test.mjs` — it must never read the host repo's docs/ai. Every fixture lives
30
+ // in a fresh temp root; the index-regen hook + the git-dir snapshot are always injected.
22
31
 
23
32
  const tempDirs = [];
24
33
  const makeRoot = () => {
@@ -33,74 +42,123 @@ afterEach(() => {
33
42
  const fm = (cap) =>
34
43
  `---\ntype: reference\nlastUpdated: 2026-01-01\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${cap}\n---\n`;
35
44
 
36
- // One canonical entry block: 3 fixed lines + extraLines body lines.
37
- const entryBlock = (id, extraLines = 2) =>
38
- [`## AD-${id} Decision ${id}`, '', '**Date:** 2026-01-01', ...Array.from({ length: extraLines }, (_, i) => `body ${i + 1} of AD-${id}`)].join('\n');
45
+ // One canonical ADR block. `status: null` omits the status line (the 6-of-9-active default case);
46
+ // `separate: true` writes Date and Status on their own lines (the AD-001/AD-043 shape).
47
+ const adrBlock = (id, { title = `Decision ${id}`, date = '2026-01-01', status = 'Accepted', body = 2, separate = false } = {}) => {
48
+ const lines = [`## AD-${id} — ${title}`, ''];
49
+ if (separate) {
50
+ if (date) lines.push(`**Date:** ${date}`);
51
+ if (status) lines.push(`**Status:** ${status}`);
52
+ } else if (date || status) {
53
+ lines.push(`**Date:** ${date}${status ? ` · **Status:** ${status}` : ''}`);
54
+ }
55
+ lines.push('');
56
+ for (let i = 0; i < body; i += 1) lines.push(`body ${i + 1} of AD-${id}`);
57
+ return lines.join('\n');
58
+ };
39
59
 
40
60
  const tierText = (cap, preamble, blocks) => `${fm(cap)}\n${preamble}\n\n${blocks.join('\n\n')}\n`;
41
61
 
42
62
  const HOT_PREAMBLE = [
43
63
  '# Architecture Decision Records (ADRs)',
44
64
  '',
45
- '> Newest at the bottom.',
65
+ '> Newest at the bottom. Link related ADRs with `[[AD-XXX]]`.',
46
66
  '>',
47
- '> **Archive:** the stable ADRs **AD-003 … AD-004** now live in [`history/decisions-archive.md`](./history/decisions-archive.md) (the earliest **AD-001 … AD-002** rolled further to the COLD [`history/decisions-archive-early.md`](./history/decisions-archive-early.md)); this file carries the active set (AD-005 onward).',
67
+ '> **Archive:** the stable **AD-003 … AD-004** now live in [`history/decisions-archive.md`](./history/decisions-archive.md) (the earliest **AD-001 … AD-002** rolled further to the COLD [`history/decisions-archive-early.md`](./history/decisions-archive-early.md)); this file carries the active set (AD-005 onward).',
48
68
  ].join('\n');
49
-
50
- const WARM_PREAMBLE = '# ADR Archive (AD-003 … AD-004)\n\n> WARM tier. The earliest (AD-001 … AD-002) are COLD.';
69
+ const WARM_PREAMBLE = '# ADR Archive (AD-003 … AD-004)\n\n> WARM tier.';
51
70
  const COLD_PREAMBLE = '# ADR Early Archive (AD-001 … AD-002)\n\n> COLD tier.';
52
71
 
53
- // Seed a project: HOT with `hotIds`, WARM with `warmIds`, COLD with `coldIds`; caps computed
54
- // from the measured rendered size + a delta (so fixtures stay robust to formatting arithmetic).
55
- const seedProject = (root, { hotIds, warmIds, coldIds, hotCapDelta = 100, warmCapDelta = 100, coldCapDelta = 100 }) => {
72
+ // Seed a legacy 3-tier tree (pre-migration). Caps generous so nothing overflows unless asked.
73
+ const seedLegacy = (root, { hot, warm = [], cold = [], hotCapDelta = 200 }) => {
56
74
  mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
57
- const write = (rel, preamble, ids, capDelta) => {
58
- const blocks = ids.map((id) => entryBlock(id));
59
- const probe = tierText(999, preamble, blocks);
75
+ const writeTier = (rel, preamble, blocks, capDelta) => {
76
+ const probe = tierText(9999, preamble, blocks);
60
77
  const cap = lineCountOf(probe) + capDelta;
61
78
  writeFileSync(join(root, rel), tierText(cap, preamble, blocks));
62
79
  return cap;
63
80
  };
81
+ const hotBlocks = hot.map((spec) => (typeof spec === 'string' ? adrBlock(spec) : adrBlock(spec.id, spec)));
82
+ const warmBlocks = warm.map((spec) => (typeof spec === 'string' ? adrBlock(spec) : adrBlock(spec.id, spec)));
83
+ const coldBlocks = cold.map((spec) => (typeof spec === 'string' ? adrBlock(spec) : adrBlock(spec.id, spec)));
64
84
  return {
65
- hotCap: write(HOT_REL, HOT_PREAMBLE, hotIds, hotCapDelta),
66
- warmCap: write(WARM_REL, WARM_PREAMBLE, warmIds, warmCapDelta),
67
- coldCap: write(COLD_REL, COLD_PREAMBLE, coldIds, coldCapDelta),
85
+ hotCap: writeTier(HOT_REL, HOT_PREAMBLE, hotBlocks, hotCapDelta),
86
+ warmCap: warm.length ? writeTier(WARM_REL, WARM_PREAMBLE, warmBlocks, 200) : null,
87
+ coldCap: cold.length ? writeTier(COLD_REL, COLD_PREAMBLE, coldBlocks, 200) : null,
68
88
  };
69
89
  };
70
90
 
71
- // The existing rotation tests are about ROTATION, not the index-regen hook (item (h)) inject a
72
- // no-op regenerator so they stay hermetic + fast (no real check-docs-size subprocess). The hook's
73
- // own firing/degrade behavior is pinned by the dedicated (h) describe below; the real end-to-end
74
- // spawn is isolated to one integration test there.
75
- const run = (argv, root) => {
91
+ const fakeGit = (root) => (cmd) => (cmd === 'git' ? { status: 0, stdout: `${join(root, '.git')}\n` } : { status: 1 });
92
+ const noGit = () => ({ status: 1 });
93
+
94
+ const run = (argv, root, opts = {}) => {
76
95
  const out = [];
77
96
  const err = [];
78
- const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l), regenerateIndex: () => ({ ok: true, detail: '' }) });
79
- return { code, out, err, text: out.join('\n'), errText: err.join('\n') };
97
+ const calls = [];
98
+ const regen = opts.regen ?? ((r, t) => { calls.push([r, t]); return { ok: true, detail: '' }; });
99
+ const code = runCli(argv, {
100
+ root,
101
+ log: (l) => out.push(l),
102
+ logError: (l) => err.push(l),
103
+ regenerateIndex: regen,
104
+ stamp: opts.stamp ?? 'STAMP',
105
+ snapshotFallbackBase: opts.fallbackBase,
106
+ spawnSync: opts.spawnSync ?? fakeGit(root),
107
+ });
108
+ return { code, out, err, text: out.join('\n'), errText: err.join('\n'), regenCalls: calls };
80
109
  };
81
110
 
111
+ const adrFiles = (root) => (existsSync(join(root, ADR_DIR_REL)) ? readdirSync(join(root, ADR_DIR_REL)).filter((n) => /^AD-\d{3,}-/.test(n)).sort() : []);
82
112
  const idsIn = (root, rel) => parseDecisionsText(readFileSync(join(root, rel), 'utf8'), rel).entries.map((e) => e.id);
83
113
 
84
- // ── parsing: strict canonical headings (the Issue-009 lesson) ─────────────────────────
114
+ // ── 1.1 the widened grammar + real-corpus parser + status/date/lifecycle extraction ──
115
+
116
+ describe('1.1 parser — real-corpus formats, widened grammar, verbatim blocks', () => {
117
+ it('parses AD-001 (separate Date/Status lines), a same-line + wrapped-rich status, and AD-1000', () => {
118
+ const wrapped = ['## AD-042 — Same-line rich', '', '**Date:** 2026-07-04 · **Status:** Accepted (ships kit `1.34.0`;', 'wrapped continuation of the status prose)', '', 'body'].join('\n');
119
+ const text = tierText(500, '# T', [
120
+ adrBlock('001', { separate: true }),
121
+ wrapped,
122
+ adrBlock('1000', { title: 'Four digits' }),
123
+ ]);
124
+ const p = parseDecisionsText(text, 'x');
125
+ assert.deepEqual(p.entries.map((e) => e.id), ['001', '042', '1000']);
126
+ assert.equal(p.entries[1].status, 'accepted', 'the leading Status word wins even when the prose wraps');
127
+ assert.match(p.entries[1].block, /wrapped continuation of the status prose/, 'the block is preserved VERBATIM');
128
+ assert.equal(p.entries[0].date, '2026-01-01');
129
+ });
130
+
131
+ it('a MISSING status line defaults to accepted (6 of 9 active HOT ADRs carry none)', () => {
132
+ const noStatus = ['## AD-045 — No status line', '', '**Problem.** starts straight in.', '', 'body'].join('\n');
133
+ const p = parseDecisionsText(tierText(500, '# T', [noStatus]), 'x');
134
+ assert.equal(p.entries[0].status, 'accepted');
135
+ assert.equal(p.entries[0].date, null, 'no Date line → null');
136
+ });
85
137
 
86
- describe('parseDecisionsText fail-loud on non-canonical headings', () => {
87
- it('parses canonical entries with ids, titles, and per-entry line counts', () => {
88
- const parsed = parseDecisionsText(tierText(500, HOT_PREAMBLE, [entryBlock('005'), entryBlock('006', 4)]), 'x');
89
- assert.deepEqual(parsed.entries.map((e) => e.id), ['005', '006']);
90
- assert.equal(parsed.entries[0].lineCount, 5);
91
- assert.equal(parsed.entries[1].lineCount, 7);
92
- assert.equal(parsed.cap, 500);
138
+ it('backfills supersedes / supersededBy from the real corpus link forms', () => {
139
+ const blocks = [
140
+ adrBlock('006', { status: 'Amended by [[AD-014]] (later refinement)' }),
141
+ adrBlock('007', { status: 'Superseded by [[AD-011]] (Plan B realized)' }),
142
+ adrBlock('018', { status: 'Accepted — realized in kit 1.7.0. Supersedes [[AD-007]].' }),
143
+ ];
144
+ const p = parseDecisionsText(tierText(500, '# T', blocks), 'x');
145
+ assert.deepEqual(p.entries[0].supersededBy, ['014']);
146
+ assert.equal(p.entries[0].status, 'amended');
147
+ assert.deepEqual(p.entries[1].supersededBy, ['011']);
148
+ assert.equal(p.entries[1].status, 'superseded');
149
+ assert.deepEqual(p.entries[2].supersedes, ['007']);
150
+ assert.equal(p.entries[2].status, 'accepted');
93
151
  });
94
152
 
95
153
  const badHeadings = [
96
154
  ['a hyphen instead of the em-dash', '## AD-024 - Title'],
97
- ['a 2-digit id', '## AD-24 — Title'],
155
+ ['a 2-digit id (below the AD-\\d{3,} floor)', '## AD-24 — Title'],
98
156
  ['a missing title', '## AD-024 — '],
99
157
  ['an unrelated H2', '## Notes'],
100
158
  ];
101
159
  for (const [name, heading] of badHeadings) {
102
160
  it(`rejects ${name} naming file:line — never a silent merge`, () => {
103
- const text = `${fm(500)}\n# T\n\n${entryBlock('001')}\n\n${heading}\nbody\n`;
161
+ const text = `${fm(500)}\n# T\n\n${adrBlock('001')}\n\n${heading}\nbody\n`;
104
162
  assert.throws(() => parseDecisionsText(text, 'docs/ai/decisions.md'), (e) => {
105
163
  assert.equal(e.exitCode, 1);
106
164
  assert.match(e.message, /docs\/ai\/decisions\.md:\d+/);
@@ -110,373 +168,655 @@ describe('parseDecisionsText — fail-loud on non-canonical headings', () => {
110
168
  });
111
169
  }
112
170
 
113
- it('rejects disordered ids (oldest must be at the top)', () => {
114
- const text = tierText(500, '# T', [entryBlock('007'), entryBlock('005')]);
115
- assert.throws(() => parseDecisionsText(text, 'x'), /strictly ascending/);
171
+ it('rejects disordered ids (oldest at the top, NUMERIC order)', () => {
172
+ assert.throws(() => parseDecisionsText(tierText(500, '# T', [adrBlock('007'), adrBlock('005')]), 'x'), /strictly ascending/);
173
+ });
174
+
175
+ it('AD-200 precedes AD-1000 numerically (never lexically — Decision 10)', () => {
176
+ const ok = parseDecisionsText(tierText(500, '# T', [adrBlock('200'), adrBlock('1000')]), 'x');
177
+ assert.deepEqual(ok.entries.map((e) => e.id), ['200', '1000']);
178
+ assert.throws(() => parseDecisionsText(tierText(500, '# T', [adrBlock('1000'), adrBlock('200')]), 'x'), /strictly ascending/, 'AD-1000 before AD-200 is descending numerically');
116
179
  });
117
180
  });
118
181
 
119
- describe('loadTiers cross-tier integrity', () => {
120
- it('rejects a duplicate id across tiers', () => {
121
- const root = makeRoot();
122
- seedProject(root, { hotIds: ['005'], warmIds: ['005'], coldIds: [] });
123
- assert.throws(() => loadTiers(root, '2026-01-02'), /duplicate id across tiers/);
182
+ describe('slugify + recordFileName', () => {
183
+ it('lowercases, replaces non-alphanumerics, trims to a bounded length', () => {
184
+ assert.equal(slugify('Host-level bridge settings surface'), 'host-level-bridge-settings-surface');
185
+ assert.equal(slugify('Onboarding UX: batched asks (the seeding↔hook chain)'), 'onboarding-ux-batched-asks-the-seeding-hook-chain');
186
+ assert.ok(slugify('x'.repeat(200)).length <= 60);
187
+ });
188
+ it('the filename encodes the id (the O(1) by-id glob key)', () => {
189
+ assert.equal(recordFileName('042', 'a-slug'), 'AD-042-a-slug.md');
124
190
  });
125
191
  });
126
192
 
127
- // ── the cascade ───────────────────────────────────────────────────────────────────────
193
+ // ── 1.2 explode + conservation (pure) ────────────────────────────────────────────────
194
+
195
+ describe('1.2 explode — one immutable record per id, verbatim block, lifecycle frontmatter', () => {
196
+ it('builds a record per entry with inline lifecycle frontmatter and the verbatim block', () => {
197
+ const p = parseDecisionsText(tierText(500, '# T', [adrBlock('003', { status: 'Superseded by [[AD-009]]' })]), 'x');
198
+ const [rec] = explode(p.entries, '2026-07-09');
199
+ assert.equal(rec.fileName, 'AD-003-decision-003.md');
200
+ assert.match(rec.frontmatter, /type: adr/);
201
+ assert.match(rec.frontmatter, new RegExp(`maxLines: ${RECORD_CAP}`));
202
+ assert.match(rec.frontmatter, /status: superseded/);
203
+ assert.match(rec.frontmatter, /supersededBy: \[AD-009\]/);
204
+ assert.equal(rec.block, p.entries[0].block, 'the block is carried VERBATIM');
205
+ });
206
+ });
207
+
208
+ describe('1.2 verifyConservation — partition-preserving, extra-aware, fail-loud', () => {
209
+ const items = (pairs) => pairs.map(([id, block]) => ({ id, block }));
210
+ it('passes when the NEW side is exactly the OLD multiset', () => {
211
+ const old = items([['001', 'a'], ['002', 'b']]);
212
+ assert.doesNotThrow(() => verifyConservation(old, items([['001', 'a'], ['002', 'b']])));
213
+ });
214
+ it('fails on a DROPPED id (an ADR would be lost)', () => {
215
+ assert.throws(() => verifyConservation(items([['001', 'a'], ['002', 'b']]), items([['001', 'a']])), /absent from the migrated store/);
216
+ });
217
+ it('fails on an EDITED block (hash mismatch — the block must move verbatim)', () => {
218
+ assert.throws(() => verifyConservation(items([['001', 'a']]), items([['001', 'a-edited']])), /block changed during migration/);
219
+ });
220
+ it('fails on a STRAY new id absent from the OLD tiers (invented history)', () => {
221
+ assert.throws(() => verifyConservation(items([['001', 'a']]), items([['001', 'a'], ['009', 'x']])), /stray\/invented/);
222
+ });
223
+ it('fails on a RENUMBER (old 002 lost, new 003 stray)', () => {
224
+ assert.throws(() => verifyConservation(items([['001', 'a'], ['002', 'b']]), items([['001', 'a'], ['003', 'b']])), /lost|absent from the migrated store/);
225
+ });
226
+ it('blockHash is stable + content-addressed', () => {
227
+ assert.equal(blockHash('x'), blockHash('x'));
228
+ assert.notEqual(blockHash('x'), blockHash('y'));
229
+ });
230
+ });
231
+
232
+ // ── 1.3 — --migrate + snapshot + retire monoliths + idempotence + legacy guard ─────────
128
233
 
129
- describe('rotation simple HOT→WARM roll', () => {
130
- it('rolls the OLDEST whole entries until HOT fits; ids + line counts conserved', () => {
234
+ describe('1.3 --migrate dry-run writes nothing', () => {
235
+ it('prints the file set + conservation proof, mutates no file, creates no adr/ tree', () => {
131
236
  const root = makeRoot();
132
- seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
133
- const before = loadTiers(root, '2026-01-02');
134
- const allBefore = [before.hot, before.warm, before.cold].flatMap((t) => t.entries.map((e) => `${e.id}:${e.lineCount}`)).sort();
237
+ seedLegacy(root, { hot: ['005', '006'], warm: ['003', '004'], cold: ['001', '002'] });
238
+ const before = readFileSync(join(root, HOT_REL), 'utf8');
239
+ const { code, text } = run(['--migrate', '--today=2026-07-09'], root);
240
+ assert.equal(code, 0, text);
241
+ assert.match(text, /DRY-RUN/);
242
+ assert.match(text, /conserved/);
243
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
244
+ assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
245
+ assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no adr/ tree created on a dry-run');
246
+ });
247
+ });
135
248
 
136
- const { code, text } = run(['--today=2026-01-02'], root);
249
+ describe('1.3 --migrate --apply records + snapshot + retire monoliths + HOT rewrite', () => {
250
+ it('writes one record per archived id, a git-dir snapshot, a nav, retires monoliths, rewrites the HOT preamble', () => {
251
+ const root = makeRoot();
252
+ seedLegacy(root, { hot: ['005', '006', '007', '008'], warm: ['003', '004'], cold: ['001', '002'] });
253
+ const { code, text } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
137
254
  assert.equal(code, 0, text);
138
- assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008'], 'the oldest HOT entry rolled');
139
- assert.deepEqual(idsIn(root, WARM_REL), ['003', '004', '005'], 'appended to the WARM end');
140
- assert.deepEqual(idsIn(root, COLD_REL), ['001', '002'], 'COLD untouched');
141
255
 
142
- const after = loadTiers(root, '2026-01-02');
143
- const allAfter = [after.hot, after.warm, after.cold].flatMap((t) => t.entries.map((e) => `${e.id}:${e.lineCount}`)).sort();
144
- assert.deepEqual(allAfter, allBefore, 'id multiset + per-entry line counts conserved');
145
- assert.ok(lineCountOf(renderTier(after.hot, after.hot.entries)) <= after.hot.cap, 'HOT now fits its cap');
256
+ assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md', 'AD-002-decision-002.md', 'AD-003-decision-003.md', 'AD-004-decision-004.md']);
257
+ assert.deepEqual(idsIn(root, HOT_REL), ['005', '006', '007', '008'], 'HOT keeps the active window');
258
+ assert.ok(!existsSync(join(root, WARM_REL)) && !existsSync(join(root, COLD_REL)), 'both monoliths retired');
259
+ assert.ok(existsSync(join(root, NAV_REL)), 'navigator generated');
260
+
261
+ const hotText = readFileSync(join(root, HOT_REL), 'utf8');
262
+ assert.match(hotText, /adr\/log\.md/, 'the HOT preamble now points at the navigator');
263
+ assert.doesNotMatch(hotText, /decisions-archive/, 'the dead monolith links are dropped');
264
+ assert.match(hotText, /AD-005 onward/, 'the active-window token is rewritten to the retained oldest');
265
+
266
+ const snapDir = join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP');
267
+ assert.ok(existsSync(snapDir), 'the snapshot landed under the git dir (never the work tree)');
268
+ assert.equal(readdirSync(snapDir).sort().length, 3, 'snapshot captured decisions.md + both monoliths');
269
+ });
270
+
271
+ it('the snapshot captures the ORIGINAL decisions.md + monolith BYTES (recoverable content, not just names)', () => {
272
+ const root = makeRoot();
273
+ seedLegacy(root, { hot: ['005', '006'], warm: ['003'], cold: ['001'] });
274
+ const hotBefore = readFileSync(join(root, HOT_REL), 'utf8');
275
+ const warmBefore = readFileSync(join(root, WARM_REL), 'utf8');
276
+ const coldBefore = readFileSync(join(root, COLD_REL), 'utf8');
277
+ run(['--migrate', '--apply', '--today=2026-07-09'], root);
278
+ const snapDir = join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP');
279
+ assert.equal(readFileSync(join(snapDir, 'docs__ai__decisions.md'), 'utf8'), hotBefore, 'the ORIGINAL HOT is recoverable verbatim');
280
+ assert.equal(readFileSync(join(snapDir, 'docs__ai__history__decisions-archive.md'), 'utf8'), warmBefore, 'the ORIGINAL WARM monolith is recoverable verbatim');
281
+ assert.equal(readFileSync(join(snapDir, 'docs__ai__history__decisions-archive-early.md'), 'utf8'), coldBefore, 'the ORIGINAL COLD monolith is recoverable verbatim');
146
282
  });
147
283
 
148
- it('a tree already under cap is a no-op (nothing written)', () => {
284
+ it('migrating an OVER-CAP HOT explodes the HOT overflow into records too (not only the monoliths)', () => {
149
285
  const root = makeRoot();
150
- seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
151
- const bytesBefore = readFileSync(join(root, HOT_REL), 'utf8');
152
- const { code, text } = run([], root);
286
+ seedLegacy(root, { hot: ['005', '006', '007', '008'], warm: ['003'], cold: ['001'], hotCapDelta: -1 });
287
+ const { code } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
153
288
  assert.equal(code, 0);
154
- assert.match(text, /nothing to rotate/);
155
- assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), bytesBefore);
289
+ assert.ok(adrFiles(root).includes('AD-005-decision-005.md'), 'the HOT overflow AD-005 exploded to a record');
290
+ assert.ok(!idsIn(root, HOT_REL).includes('005'), 'AD-005 left the HOT window');
291
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'the migrated over-cap tree is green');
156
292
  });
157
- });
158
293
 
159
- describe('rotation the CHAINED roll (WARM near cap rolls WARMCOLD first)', () => {
160
- it('an incoming HOT entry that would overflow WARM pushes the oldest WARM entry to COLD', () => {
294
+ it('a second --apply no-ops (monoliths already retiredalready-migrated)', () => {
161
295
  const root = makeRoot();
162
- seedProject(root, {
163
- hotIds: ['005', '006', '007'],
164
- warmIds: ['003', '004'],
165
- coldIds: ['001', '002'],
166
- hotCapDelta: -1, // force one HOT roll
167
- warmCapDelta: 3, // the incoming 6-line append does NOT fit → chain
168
- });
169
- const { code } = run(['--today=2026-01-02'], root);
296
+ seedLegacy(root, { hot: ['005', '006'], warm: ['003'], cold: ['001'] });
297
+ run(['--migrate', '--apply', '--today=2026-07-09'], root);
298
+ const snapshotBefore = readdirSync(join(root, '.git'));
299
+ const { code, text } = run(['--migrate', '--apply', '--today=2026-07-10'], root);
170
300
  assert.equal(code, 0);
171
- assert.deepEqual(idsIn(root, HOT_REL), ['006', '007']);
172
- assert.deepEqual(idsIn(root, WARM_REL), ['004', '005'], 'AD-003 chained out, AD-005 appended');
173
- assert.deepEqual(idsIn(root, COLD_REL), ['001', '002', '003'], 'the chained entry landed in COLD');
301
+ assert.match(text, /already migrated/);
302
+ assert.deepEqual(readdirSync(join(root, '.git')), snapshotBefore, 'no second snapshot');
174
303
  });
175
- });
176
304
 
177
- describe('rotation COLD exhaustion fails LOUD before ANY write', () => {
178
- it('a roll that does not fit COLD headroom leaves all three files byte-identical', () => {
305
+ it('the migrated tree passes --check (green end-to-end)', () => {
179
306
  const root = makeRoot();
180
- seedProject(root, {
181
- hotIds: ['005', '006', '007'],
182
- warmIds: ['003', '004'],
183
- coldIds: ['001', '002'],
184
- hotCapDelta: -1, // force a HOT roll
185
- warmCapDelta: 3, // force the chain into COLD
186
- coldCapDelta: 3, // the chained 6-line entry does NOT fit COLD
187
- });
188
- const snapshot = () => [HOT_REL, WARM_REL, COLD_REL].map((rel) => readFileSync(join(root, rel), 'utf8'));
189
- const before = snapshot();
190
- const { code, errText } = run(['--today=2026-01-02'], root);
307
+ seedLegacy(root, { hot: ['005', '006', '007'], warm: ['003', '004'], cold: ['001', '002'] });
308
+ run(['--migrate', '--apply', '--today=2026-07-09'], root);
309
+ const { code, text } = run(['--check', '--today=2026-07-09'], root);
310
+ assert.equal(code, 0, text);
311
+ assert.match(text, /OK — HOT within cap, store integrity intact, navigator fresh/);
312
+ });
313
+
314
+ it('a stray adr record NEWER than the HOT window fails exit 1 (partition) BEFORE any snapshot/delete', () => {
315
+ const root = makeRoot();
316
+ seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
317
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
318
+ // AD-099 is newer than the HOT window (AD-005) → not a legitimate archived record → refuse.
319
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-099-stray.md'), `${fm(RECORD_CAP)}\n${adrBlock('099')}\n`);
320
+ const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
191
321
  assert.equal(code, 1);
192
- assert.match(errText, /refusing BEFORE any write/);
193
- assert.match(errText, /maintainer\/agent decision/);
194
- assert.deepEqual(snapshot(), before, 'no file changed on the refused plan');
322
+ assert.match(errText, /partition violated/);
323
+ assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths NOT deleted on a refused migration');
324
+ assert.ok(!existsSync(join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP')), 'no snapshot on a refusal (the integrity check precedes the snapshot)');
195
325
  });
196
- });
197
326
 
198
- describe('rotation determinism + dry-run', () => {
199
- it('the same input yields the same move-set (planRotation is pure)', () => {
327
+ it('resumes after a post-writeHot / post-partial-delete crash without wedging (idempotent) — internal-sweep major', () => {
200
328
  const root = makeRoot();
201
- seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003'], coldIds: ['001'], hotCapDelta: -1 });
202
- const tiers = loadTiers(root, '2026-01-02');
203
- const planA = planRotation(tiers);
204
- const planB = planRotation(loadTiers(root, '2026-01-02'));
205
- assert.deepEqual(planA.moves, planB.moves);
329
+ // Reconstruct a post-crash state: HOT ALREADY trimmed to [006,007,008] (AD-005 was the exploded
330
+ // overflow, its source no longer in HOT); adr/ holds records 001..005; monoliths STILL present
331
+ // (crash before rmSync). A naive conservation would accuse AD-005 of being "invented history".
332
+ seedLegacy(root, { hot: ['006', '007', '008'], warm: ['003', '004'], cold: ['001', '002'] });
333
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
334
+ const recEntries = ['001', '002', '003', '004', '005'].map((id) => parseDecisionsText(tierText(999, '# T', [adrBlock(id)]), 'x').entries[0]);
335
+ for (const rec of explode(recEntries, '2026-07-09')) writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
336
+ const { code } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
337
+ assert.equal(code, 0, 'the resume completes — it never accuses its own migrated records of being stray');
338
+ assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md', 'AD-002-decision-002.md', 'AD-003-decision-003.md', 'AD-004-decision-004.md', 'AD-005-decision-005.md']);
339
+ assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008']);
340
+ assert.ok(!existsSync(join(root, WARM_REL)) && !existsSync(join(root, COLD_REL)), 'monoliths retired on the completed resume');
341
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'the resumed tree passes --check');
206
342
  });
207
343
 
208
- it('--dry-run prints the move-set and writes nothing', () => {
344
+ it('refuses when a pre-existing adr/ record duplicates an ADR that stays in HOT (never two places — codex R4)', () => {
209
345
  const root = makeRoot();
210
- seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
211
- const before = readFileSync(join(root, HOT_REL), 'utf8');
212
- const { code, text } = run(['--dry-run'], root);
346
+ seedLegacy(root, { hot: ['005', '006'], warm: ['003'], cold: ['001'] });
347
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
348
+ // AD-006 stays in the HOT window, but a store record for it already exists (a corrupt partial state).
349
+ const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('006')]), 'x').entries, '2026-07-09')[0];
350
+ writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
351
+ const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
352
+ assert.equal(code, 1);
353
+ assert.match(errText, /duplicate ADR id AD-006/);
354
+ assert.ok(existsSync(join(root, WARM_REL)), 'monoliths NOT deleted on the refused migration');
355
+ });
356
+
357
+ it('resumes a half-migrated tree (a byte-identical record is skipped, the rest complete)', () => {
358
+ const root = makeRoot();
359
+ seedLegacy(root, { hot: ['005', '006'], warm: ['003', '004'], cold: ['001', '002'] });
360
+ run(['--migrate', '--today=2026-07-09'], root); // dry-run to observe (no writes)
361
+ // Simulate a crash mid-write: AD-001's record already on disk, monoliths still present.
362
+ const partial = explode(parseDecisionsText(readFileSync(join(root, COLD_REL), 'utf8'), COLD_REL).entries, '2026-07-09')[0];
363
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
364
+ writeFileSync(join(root, ADR_DIR_REL, partial.fileName), `${partial.frontmatter}\n${partial.block}\n`);
365
+ const { code } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
366
+ assert.equal(code, 0, 'the resume completes');
367
+ assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md', 'AD-002-decision-002.md', 'AD-003-decision-003.md', 'AD-004-decision-004.md']);
368
+ assert.ok(!existsSync(join(root, WARM_REL)), 'monoliths retired on the completed resume');
369
+ });
370
+
371
+ it('a corrupt crash-resume record (same id, DIFFERENT body) fails loud before any write (conservation is not bypassed)', () => {
372
+ const root = makeRoot();
373
+ seedLegacy(root, { hot: ['005', '006'], warm: ['003', '004'], cold: ['001', '002'] });
374
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
375
+ // An AD-003 record exists on disk but with a TAMPERED body (≠ the WARM monolith's AD-003).
376
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-003-decision-003.md'), `${fm(RECORD_CAP)}\n## AD-003 — Decision 003\n\n**Date:** 2026-01-01\n\nTAMPERED body not in the monolith\n`);
377
+ const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
378
+ assert.equal(code, 1);
379
+ assert.match(errText, /DIFFERENT body|corrupt or hand-edited/);
380
+ assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths NOT deleted — the edited record is never silently overwritten');
381
+ assert.ok(!existsSync(join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP')), 'no snapshot on a refused migration (conservation precedes the snapshot)');
382
+ });
383
+
384
+ it('off git, the snapshot falls back to a stated out-of-tree base (never the work tree)', () => {
385
+ const root = makeRoot();
386
+ const fallback = makeRoot();
387
+ seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
388
+ const { code } = run(['--migrate', '--apply', '--today=2026-07-09'], root, { spawnSync: noGit, fallbackBase: fallback });
213
389
  assert.equal(code, 0);
214
- assert.match(text, /DRY-RUN/);
215
- assert.match(text, /AD-005/);
216
- assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before);
390
+ assert.ok(existsSync(join(fallback, 'agent-workflow-adr-migration-snapshot-STAMP')), 'off-git snapshot landed in the fallback base');
391
+ assert.ok(!existsSync(join(root, '.git')), 'nothing written under a non-existent git dir');
217
392
  });
218
393
  });
219
394
 
220
- describe('preamble range tokens', () => {
221
- it('updates the recognizable range/onward tokens after a rotation', () => {
395
+ describe('1.3 legacy-substrate guard (Decision 6) — never half-explodes, never green on a monolith', () => {
396
+ it('a default rotate with a monolith present fails LOUD (run --migrate first)', () => {
222
397
  const root = makeRoot();
223
- seedProject(root, { hotIds: ['005', '006', '007'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
224
- const { code } = run(['--today=2026-01-02'], root);
225
- assert.equal(code, 0);
226
- const hotText = readFileSync(join(root, HOT_REL), 'utf8');
227
- assert.match(hotText, /\*\*AD-003 … AD-005\*\*/, 'WARM range token updated in HOT');
228
- assert.match(hotText, /\(AD-006 onward\)/, 'active-set token updated');
229
- const warmText = readFileSync(join(root, WARM_REL), 'utf8');
230
- assert.match(warmText, /AD-003 AD-005/, 'WARM file H1 range updated');
398
+ seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
399
+ const { code, errText } = run(['--today=2026-07-09'], root);
400
+ assert.equal(code, 1);
401
+ assert.match(errText, /legacy monolith present/);
402
+ assert.match(errText, /--migrate --apply/);
403
+ });
404
+ it('--check with a monolith present fails LOUD (half-migrated), never reports green', () => {
405
+ const root = makeRoot();
406
+ seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
407
+ const { code, errText } = run(['--check'], root);
408
+ assert.equal(code, 1);
409
+ assert.match(errText, /half-migrated/);
231
410
  });
232
411
 
233
- it('a preamble without tokens is left untouched (consumer wording preserved)', () => {
234
- const updated = updateRangeTokens('# My own ADR file\n\n> no tokens here', 'hot', {
235
- hotEntries: [{ id: '010' }],
236
- warmEntries: [{ id: '001' }, { id: '002' }],
237
- coldEntries: [],
238
- });
239
- assert.equal(updated, '# My own ADR file\n\n> no tokens here');
412
+ it('a LONE monolith (HOT and adr/ both absent) fails the legacy guard, never a clean substrate-absent skip', () => {
413
+ const root = makeRoot();
414
+ mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
415
+ writeFileSync(join(root, WARM_REL), tierText(500, WARM_PREAMBLE, [adrBlock('003')]));
416
+ const { code, errText } = run(['--check'], root);
417
+ assert.equal(code, 1);
418
+ assert.match(errText, /legacy monolith present/);
240
419
  });
241
420
  });
242
421
 
243
- // ── --check + the absent-substrate divergence ─────────────────────────────────────────
422
+ // ── 1.4 — --check + default rotate + item-(h) regen ────────────────────────────────────
423
+
424
+ // A fully-migrated tree (post-migration substrate) built directly for the check/rotate tests.
425
+ const seedMigrated = (root, { hotIds, storeIds = [], hotCapDelta = 200, today = '2026-07-09' }) => {
426
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
427
+ const hotBlocks = hotIds.map((id) => adrBlock(id));
428
+ const probe = tierText(9999, HOT_PREAMBLE.replace(/> \*\*Archive:\*\*.*/, ''), hotBlocks);
429
+ const cap = lineCountOf(probe) + hotCapDelta;
430
+ const preamble = ['# ADRs', '', '> Newest at the bottom.'].join('\n');
431
+ writeFileSync(join(root, HOT_REL), tierText(cap, preamble, hotBlocks));
432
+ const storeEntries = storeIds.map((id) => (typeof id === 'string' ? { id } : id));
433
+ const records = explode(storeEntries.map((s) => parseDecisionsText(tierText(999, '# T', [adrBlock(s.id, s)]), 'x').entries[0]), today);
434
+ for (const rec of records) writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
435
+ // A fresh navigator so --check is green from the start.
436
+ run(['--write-navigator', `--today=${today}`], root);
437
+ return { cap };
438
+ };
244
439
 
245
- describe('--check', () => {
246
- it('all tiers within caps → exit 0 with a per-tier usage report', () => {
440
+ describe('1.4 --check', () => {
441
+ it('a green migrated tree → exit 0', () => {
247
442
  const root = makeRoot();
248
- seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
249
- const { code, text } = run(['--check'], root);
250
- assert.equal(code, 0);
251
- assert.match(text, /decisions\.md: \d+\/\d+/);
252
- assert.match(text, /OK — every tier is within its cap/);
443
+ seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001', '002'] });
444
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
253
445
  });
254
446
 
255
- it('an over-cap HOT → exit 1 naming the rotation recovery', () => {
447
+ it('a duplicate id across HOT and adr/ → exit 1', () => {
256
448
  const root = makeRoot();
257
- seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
258
- const { code, errText } = run(['--check'], root);
449
+ seedMigrated(root, { hotIds: ['002', '005'], storeIds: ['001', '002'] }); // AD-002 in BOTH
450
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
259
451
  assert.equal(code, 1);
260
- assert.match(errText, /decisions\.md is over its cap/);
261
- assert.match(errText, /archive-decisions\.mjs` to rotate/);
452
+ assert.match(errText, /duplicate ADR id AD-002/);
262
453
  });
263
454
 
264
- it('an over-cap COLD exit 1 naming the maintainer decision (rotation cannot fix it)', () => {
455
+ it('a decisions.md with NO maxLines cap fails loud (never operates against an unknown budget codex R4)', () => {
265
456
  const root = makeRoot();
266
- seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001', '002'], coldCapDelta: -1 });
267
- const { code, errText } = run(['--check'], root);
457
+ mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
458
+ const noCapFm = '---\ntype: reference\nlastUpdated: 2026-01-01\nscope: permanent\nstaleAfter: never\nowner: none\n---\n';
459
+ writeFileSync(join(root, HOT_REL), `${noCapFm}\n# ADRs\n\n${adrBlock('005')}\n`);
460
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
268
461
  assert.equal(code, 1);
269
- assert.match(errText, /maintainer\/agent decision/);
462
+ assert.match(errText, /no maxLines cap/);
270
463
  });
271
464
 
272
- it('ABSENT decisions.md → --check exits 0 with a STATED skip (deliberate divergence from archive-changelog)', () => {
465
+ it('NO substrate (neither decisions.md nor adr/) exit 0 with a STATED skip', () => {
273
466
  const root = makeRoot();
274
467
  mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
275
468
  const { code, text } = run(['--check'], root);
276
469
  assert.equal(code, 0);
277
- assert.match(text, /SKIP — docs\/ai\/decisions\.md not found/);
470
+ assert.match(text, /SKIP — no ADR substrate/);
278
471
  });
279
472
 
280
- it('an absent decisions.md WITHOUT --check is still a loud non-zero (rotation has nothing to do)', () => {
473
+ it('an adr/ record NEWER than a HOT entry fails store integrity (partition the once-dead check is live)', () => {
281
474
  const root = makeRoot();
282
- mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
283
- const { code, errText } = run([], root);
475
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
476
+ const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('010')]), 'x').entries, '2026-07-09')[0];
477
+ writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`); // AD-010 > HOT AD-005
478
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
284
479
  assert.equal(code, 1);
285
- assert.match(errText, /not found/);
480
+ assert.match(errText, /partition violated/);
286
481
  });
287
- });
288
482
 
289
- describe('cap accounting is on RAW file lines (the template-shaped `---` separator case)', () => {
290
- // A consumer file born from the shipped template joins entries with `\n\n---\n\n`. Normalized
291
- // rendering drops those separator lines, so a render-based count would false-green a file the
292
- // docs cap-validator (raw lines) fails. --check and the write trigger must count RAW lines.
293
- const seedSeparatorShaped = (root, { capDelta }) => {
294
- mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
295
- const blocks = [entryBlock('001'), entryBlock('002'), entryBlock('003')];
296
- const body = blocks.join('\n\n---\n\n'); // the template separator shape
297
- const probe = `${fm(999)}\n# ADRs\n\n${body}\n`;
298
- const cap = lineCountOf(probe) + capDelta;
299
- writeFileSync(join(root, HOT_REL), `${fm(cap)}\n# ADRs\n\n${body}\n`);
300
- return { cap, rawLines: lineCountOf(probe) };
301
- };
483
+ it('an UNEXPECTED markdown file in adr/ fails loud (never silently hidden from the store)', () => {
484
+ const root = makeRoot();
485
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
486
+ writeFileSync(join(root, ADR_DIR_REL, 'notes.md'), '# stray notes with no frontmatter\n');
487
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
488
+ assert.equal(code, 1);
489
+ assert.match(errText, /unexpected markdown file/);
490
+ });
302
491
 
303
- it('--check fails on raw-over-cap even when the normalized render would fit', () => {
492
+ it('TWO files for one id (a corrupt duplicate-filled store) fail loud at the SOURCE never deduped away (council R3)', () => {
304
493
  const root = makeRoot();
305
- // 3 separators × 2 lines = 6 lines the render drops; cap sits 2 under raw → render fits, raw does not.
306
- seedSeparatorShaped(root, { capDelta: -2 });
307
- const { code, errText } = run(['--check'], root);
308
- assert.equal(code, 1, 'raw lines are what the docs cap-validator counts — never false-green');
309
- assert.match(errText, /decisions\.md is over its cap/);
494
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
495
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-001-divergent-slug.md'), `${fm(RECORD_CAP)}\n${adrBlock('001')}\n`); // a 2nd file for AD-001
496
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
497
+ assert.equal(code, 1);
498
+ assert.match(errText, /two records for AD-001/);
310
499
  });
311
500
 
312
- it('rotation performs a NORMALIZE-ONLY rewrite when raw is over cap but the render fits (no moves)', () => {
501
+ it('a rotate over a duplicate-filled store fails loud (the R2 crash-resume dedup no longer hides pre-existing dups — council R3)', () => {
313
502
  const root = makeRoot();
314
- seedSeparatorShaped(root, { capDelta: -2 });
315
- const { code, text } = run(['--today=2026-01-02'], root);
316
- assert.equal(code, 0, text);
317
- assert.match(text, /normalize-only rewrite/);
318
- assert.match(text, /HOT→WARM: \(none\)/);
319
- const after = parseDecisionsText(readFileSync(join(root, HOT_REL), 'utf8'), HOT_REL);
320
- assert.deepEqual(after.entries.map((e) => e.id), ['001', '002', '003'], 'ids conserved, nothing moved');
321
- const { code: recheck } = run(['--check'], root);
322
- assert.equal(recheck, 0, 'the normalized file now fits its cap on raw lines');
323
- });
324
-
325
- it('a tier STILL over cap after the planned rewrite refuses BEFORE any write (a normalized rewrite is not a licence)', () => {
326
- const root = makeRoot();
327
- // COLD legitimately exceeds its cap (entries, not formatting): no move can fix it and a
328
- // normalize-only rewrite would still be over budget — the run must refuse with files untouched.
329
- seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001', '002'], coldCapDelta: -3 });
330
- const snapshot = () => [HOT_REL, WARM_REL, COLD_REL].map((rel) => readFileSync(join(root, rel), 'utf8'));
331
- const before = snapshot();
332
- const { code, errText } = run(['--today=2026-01-02'], root);
503
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
504
+ const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
505
+ const preamble = '# ADRs\n\n> HOT.';
506
+ const probe = tierText(9999, preamble, blocks);
507
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks)); // over cap → rotate proceeds
508
+ for (const r of explode(['001'].map((id) => parseDecisionsText(tierText(999, '# T', [adrBlock(id)]), 'x').entries[0]), '2026-07-09')) {
509
+ writeFileSync(join(root, ADR_DIR_REL, r.fileName), `${r.frontmatter}\n${r.block}\n`);
510
+ }
511
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-001-second.md'), `${fm(RECORD_CAP)}\n${adrBlock('001')}\n`); // duplicate id
512
+ const { code, errText } = run(['--today=2026-07-09'], root);
333
513
  assert.equal(code, 1);
334
- assert.match(errText, /would still be over its cap after rotation/);
335
- assert.match(errText, /maintainer\/agent decision/);
336
- assert.deepEqual(snapshot(), before, 'no file changed on the refused plan');
514
+ assert.match(errText, /two records for AD-001/);
337
515
  });
338
516
 
339
- it('a normalize-only rewrite NEVER materializes absent WARM/COLD files (no premature archives)', () => {
517
+ it('a NESTED subdirectory in adr/ fails loud (the store is a flat directory — codex R2)', () => {
340
518
  const root = makeRoot();
341
- seedSeparatorShaped(root, { capDelta: -2 }); // HOT only — no history files exist
342
- const { code } = run(['--today=2026-01-02'], root);
343
- assert.equal(code, 0);
344
- assert.ok(!existsSync(join(root, WARM_REL)), 'an absent WARM with zero entries is not created');
345
- assert.ok(!existsSync(join(root, COLD_REL)), 'an absent COLD with zero entries is not created');
519
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
520
+ mkdirSync(join(root, ADR_DIR_REL, 'nested'), { recursive: true });
521
+ writeFileSync(join(root, ADR_DIR_REL, 'nested', 'AD-002-x.md'), `${fm(RECORD_CAP)}\n${adrBlock('002')}\n`);
522
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
523
+ assert.equal(code, 1);
524
+ assert.match(errText, /FLAT directory/);
525
+ });
526
+
527
+ it('adr/ present but HOT absent → integrity is STILL checked (not skipped): a stale nav → exit 1', () => {
528
+ const root = makeRoot();
529
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001', '002'] });
530
+ rmSync(join(root, HOT_REL));
531
+ writeFileSync(join(root, NAV_REL), readFileSync(join(root, NAV_REL), 'utf8') + '\nstale junk\n');
532
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
533
+ assert.equal(code, 1, 'the check runs (does not skip) even with HOT absent');
534
+ assert.match(errText, /stale/);
346
535
  });
347
536
  });
348
537
 
349
- describe('created tiers (a consumer with no history files yet)', () => {
350
- it('rolling into an absent WARM creates it with frontmatter + preamble', () => {
538
+ describe('1.4 default rotate explode the oldest beyond cap + regenerate the index (item h)', () => {
539
+ it('a HOT one line over cap explodes EXACTLY the oldest entry, keeps the rest, calls the index regen once', () => {
351
540
  const root = makeRoot();
352
- mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
353
- const blocks = [entryBlock('001'), entryBlock('002')];
354
- const probe = tierText(999, '# ADRs', blocks);
355
- writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
356
- const { code } = run(['--today=2026-01-02'], root);
541
+ // 4 HOT entries, cap = rendered - 1 → exactly one must roll out.
542
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
543
+ const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
544
+ const preamble = '# ADRs\n\n> Newest at the bottom.';
545
+ const probe = tierText(9999, preamble, blocks);
546
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks));
547
+ run(['--write-navigator', '--today=2026-07-09'], root); // seed a fresh nav
548
+ const { code, regenCalls } = run(['--today=2026-07-09'], root);
357
549
  assert.equal(code, 0);
358
- assert.ok(existsSync(join(root, WARM_REL)), 'WARM created');
359
- const warm = parseDecisionsText(readFileSync(join(root, WARM_REL), 'utf8'), WARM_REL);
360
- assert.deepEqual(warm.entries.map((e) => e.id), ['001']);
361
- assert.equal(warm.cap, 500, 'created WARM carries the default cap');
362
- assert.match(warm.preamble, /AD-001 … AD-001/, 'created preamble range filled');
550
+ assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008'], 'exactly the oldest rolled out');
551
+ assert.deepEqual(adrFiles(root), ['AD-005-decision-005.md'], 'AD-005 exploded to a record');
552
+ assert.deepEqual(regenCalls, [[root, '2026-07-09']], 'the index regen fired once with (root, today)');
363
553
  });
364
554
 
365
- it('usage errors are loud (exit 2)', () => {
366
- const { code, errText } = run(['--frobnicate'], makeRoot());
367
- assert.equal(code, 2);
368
- assert.match(errText, /Unknown argument/);
555
+ it('an under-cap migrated tree is a no-op (nothing written, no regen)', () => {
556
+ const root = makeRoot();
557
+ seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
558
+ const { code, text, regenCalls } = run(['--today=2026-07-09'], root);
559
+ assert.equal(code, 0);
560
+ assert.match(text, /nothing to rotate/);
561
+ assert.equal(regenCalls.length, 0, 'a no-op never regenerates the index');
562
+ });
563
+
564
+ it('is crash-resumable: a byte-identical record from a crashed prior rotate is deduped, not a fatal duplicate (codex R2)', () => {
565
+ const root = makeRoot();
566
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
567
+ const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
568
+ const preamble = '# ADRs\n\n> Newest at the bottom.';
569
+ const probe = tierText(9999, preamble, blocks);
570
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks)); // AD-005 overflows
571
+ // Crash after writeRecords, before writeHot: AD-005 already exploded, HOT untrimmed; plus archived AD-001.
572
+ for (const r of explode(['001', '005'].map((id) => parseDecisionsText(tierText(999, '# T', [adrBlock(id)]), 'x').entries[0]), '2026-07-09')) {
573
+ writeFileSync(join(root, ADR_DIR_REL, r.fileName), `${r.frontmatter}\n${r.block}\n`);
574
+ }
575
+ const { code } = run(['--today=2026-07-09'], root);
576
+ assert.equal(code, 0, 'the crashed rotate resumes cleanly — no fatal duplicate on the already-written record');
577
+ assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008']);
578
+ assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md', 'AD-005-decision-005.md']);
579
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
580
+ });
581
+
582
+ it('a no-op rotate still REFUSES a corrupt store (partition) — never a silent green no-op (codex R2)', () => {
583
+ const root = makeRoot();
584
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] }); // under cap
585
+ const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('010')]), 'x').entries, '2026-07-09')[0];
586
+ writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`); // AD-010 > HOT AD-005
587
+ const { code, errText } = run(['--today=2026-07-09'], root);
588
+ assert.equal(code, 1);
589
+ assert.match(errText, /partition violated/);
590
+ });
591
+
592
+ it('degrades LOUDLY when the index regen fails — the rotation itself still succeeds (exit 0)', () => {
593
+ const root = makeRoot();
594
+ const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
595
+ const preamble = '# ADRs\n\n> Newest at the bottom.';
596
+ const probe = tierText(9999, preamble, blocks);
597
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
598
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks));
599
+ run(['--write-navigator', '--today=2026-07-09'], root);
600
+ const { code, errText } = run(['--today=2026-07-09'], root, { regen: () => ({ ok: false, detail: 'the index generator is not beside this script' }) });
601
+ assert.equal(code, 0, 'the rotation succeeded — regen is best-effort');
602
+ assert.match(errText, /NOT regenerated/);
369
603
  });
370
604
  });
371
605
 
372
- // ── (h)a rotation regenerates docs/ai/index.md (BUGFREE-3 / AD-049) ─────────────────
373
- describe('(h) index regeneration after rotation', () => {
374
- const spyRun = (argv, root, regen) => {
375
- const out = [];
376
- const err = [];
377
- const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l), regenerateIndex: regen });
378
- return { code, out, err, text: out.join('\n'), errText: err.join('\n') };
379
- };
606
+ // ── 1.5navigator generator + --write-navigator ──────────────────────────────────────
380
607
 
381
- it('calls the regenerator once with (root, today) AFTER a successful rotation write', () => {
608
+ describe('1.5 navigator governing heads (computed), superseded drop out but stay reachable', () => {
609
+ it('a superseded ADR is absent from the governing list but present by filename + in the recent window', () => {
382
610
  const root = makeRoot();
383
- seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
384
- const calls = [];
385
- const { code } = spyRun(['--today=2026-01-02'], root, (r, t) => { calls.push([r, t]); return { ok: true, detail: '' }; });
386
- assert.equal(code, 0);
387
- assert.deepEqual(calls, [[root, '2026-01-02']], 'exactly one regen, with the rotation root + today');
611
+ // AD-002 is superseded by AD-005 (self-declared); it must drop out of governing.
612
+ seedMigrated(root, { hotIds: ['005', '006'], storeIds: [{ id: '001' }, { id: '002', status: 'Superseded by [[AD-005]]' }] });
613
+ const nav = readFileSync(join(root, NAV_REL), 'utf8');
614
+ const governingSection = nav.split('## Recent')[0];
615
+ assert.doesNotMatch(governingSection, /AD-002/, 'the superseded ADR is NOT a governing head');
616
+ assert.match(governingSection, /AD-001/, 'a governing ADR is listed');
617
+ assert.ok(adrFiles(root).some((f) => f.startsWith('AD-002-')), 'the superseded ADR is still reachable by filename');
618
+ assert.match(nav, /AD-002.*superseded/s, 'the recent window still surfaces it, marked superseded');
619
+ });
620
+
621
+ it('governance is computed across the corpus via inference (no predecessor mutation needed)', () => {
622
+ const corpus = [
623
+ { id: '001', idNum: 1, title: 'A', status: 'accepted', supersedes: [], supersededBy: [], fileName: 'AD-001-a.md' },
624
+ { id: '002', idNum: 2, title: 'B', status: 'accepted', supersedes: ['001'], supersededBy: [], fileName: 'AD-002-b.md' },
625
+ ];
626
+ const superseded = computeSupersededSet(corpus);
627
+ assert.ok(superseded.has('001'), 'AD-001 is inferred superseded because AD-002 declares Supersedes [[AD-001]]');
628
+ assert.ok(!superseded.has('002'));
629
+ const nav = buildNavigator(corpus, '2026-07-09');
630
+ assert.match(nav, /Governing \(1\)/);
631
+ assert.match(nav, /AD-001 … AD-002/, 'the navigator range is numeric min…max');
388
632
  });
389
633
 
390
- it('does NOT regenerate on a no-op, --check, --dry-run, or a pre-write refusal', () => {
391
- let called = 0;
392
- const spy = () => { called += 1; return { ok: true, detail: '' }; };
393
- // no-op (under cap)
394
- const r1 = makeRoot(); seedProject(r1, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
395
- spyRun([], r1, spy);
396
- // --check
397
- spyRun(['--check'], r1, spy);
398
- // --dry-run over a real rotation plan
399
- const r2 = makeRoot(); seedProject(r2, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
400
- spyRun(['--dry-run'], r2, spy);
401
- // a pre-write refusal (COLD exhausted)
402
- const r3 = makeRoot(); seedProject(r3, { hotIds: ['005', '006', '007'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1, warmCapDelta: 3, coldCapDelta: 3 });
403
- spyRun(['--today=2026-01-02'], r3, spy);
404
- assert.equal(called, 0, 'the hook fires only AFTER a real write — never on no-op/check/dry-run/refusal');
634
+ it('a Proposed head is NOT governing; a non-accepted Supersedes does NOT retire an accepted predecessor', () => {
635
+ const corpus = [
636
+ { id: '001', idNum: 1, title: 'A', status: 'accepted', supersedes: [], supersededBy: [], fileName: 'AD-001-a.md' },
637
+ { id: '002', idNum: 2, title: 'B', status: 'proposed', supersedes: ['001'], supersededBy: [], fileName: 'AD-002-b.md' },
638
+ ];
639
+ const superseded = computeSupersededSet(corpus);
640
+ assert.ok(!superseded.has('001'), 'a Proposed ADR does not effectively supersede its accepted predecessor');
641
+ const gov = buildNavigator(corpus, '2026-07-09').split('## Recent')[0];
642
+ assert.match(gov, /AD-001/, 'the accepted predecessor stays governing');
643
+ assert.doesNotMatch(gov, /\| AD-002 \|/, 'the Proposed ADR is NOT a governing head (accepted & not-superseded only)');
405
644
  });
406
645
 
407
- it('a NORMALIZE-ONLY rewrite (stampLastUpdated bumps lastUpdated, zero moves) still triggers regeneration', () => {
646
+ it('authoring a new HOT ADR then --write-navigator keeps --check green; a stale nav with NO write → exit 1, then --write-navigator fixes it', () => {
408
647
  const root = makeRoot();
409
- mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
410
- const blocks = [entryBlock('001'), entryBlock('002'), entryBlock('003')];
411
- const body = blocks.join('\n\n---\n\n'); // the template separator shape: raw > cap, normalized fits
412
- const probe = `${fm(999)}\n# ADRs\n\n${body}\n`;
413
- writeFileSync(join(root, HOT_REL), `${fm(lineCountOf(probe) - 2)}\n# ADRs\n\n${body}\n`);
414
- let called = 0;
415
- const { code, text } = spyRun(['--today=2026-01-02'], root, () => { called += 1; return { ok: true, detail: '' }; });
416
- assert.equal(code, 0, text);
417
- assert.match(text, /normalize-only rewrite/);
418
- assert.equal(called, 1, 'a normalize-only rewrite still leaves the index stale regenerate');
648
+ seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
649
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'starts green');
650
+
651
+ // Corrupt the navigator without regenerating → --check must flag it stale.
652
+ writeFileSync(join(root, NAV_REL), readFileSync(join(root, NAV_REL), 'utf8') + '\n<!-- drift -->\n');
653
+ const stale = run(['--check', '--today=2026-07-09'], root);
654
+ assert.equal(stale.code, 1);
655
+ assert.match(stale.errText, /navigator|stale|log\.md/i);
656
+
657
+ // --write-navigator is the deterministic fix (never a fixless false-block).
658
+ assert.equal(run(['--write-navigator', '--today=2026-07-09'], root).code, 0);
659
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, '--write-navigator restored freshness');
419
660
  });
420
661
 
421
- it('degrades LOUDLY to an instruct when regeneration fails the rotation still succeeds (exit 0)', () => {
662
+ it('--write-navigator refuses a duplicate-id store (never emits a corrupt / duplicate-row navigator)', () => {
422
663
  const root = makeRoot();
423
- seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
424
- const { code, errText } = spyRun(['--today=2026-01-02'], root, () => ({ ok: false, detail: 'the index generator is not beside this script — run `node scripts/check-docs-size.mjs --write-index` to refresh docs/ai/index.md' }));
425
- assert.equal(code, 0, 'the rotation itself succeeded — regen is best-effort');
664
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
665
+ writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs\n\n> HOT.', [adrBlock('002'), adrBlock('005')]));
666
+ const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('002')]), 'x').entries, '2026-07-09')[0];
667
+ writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`); // AD-002 in adr/ AND HOT
668
+ const { code, errText } = run(['--write-navigator', '--today=2026-07-09'], root);
669
+ assert.equal(code, 1);
670
+ assert.match(errText, /duplicate ADR id AD-002/);
671
+ });
672
+
673
+ it('--check does NOT false-block on a mere day-rollover (uses the on-disk nav lastUpdated)', () => {
674
+ const root = makeRoot();
675
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'], today: '2026-07-01' });
676
+ // "Today" is much later, but the corpus is unchanged → the nav must stay fresh.
677
+ assert.equal(run(['--check', '--today=2026-07-31'], root).code, 0);
678
+ });
679
+ });
680
+
681
+ // ── item (h) degrade branches on the REAL default regenerator (no injection) ───────────
682
+
683
+ describe('defaultRegenerateIndex — loud-degrade branches', () => {
684
+ it('an absent index generator sibling → ok:false with a recovery instruct', () => {
685
+ const r = defaultRegenerateIndex('/tmp/anyroot', '2026-07-09', { sibling: '/nonexistent/check-docs-size.mjs' });
686
+ assert.equal(r.ok, false);
687
+ assert.match(r.detail, /not beside this script/);
688
+ });
689
+ it('a subprocess that exits nonzero → ok:false with the recovery instruct', () => {
690
+ const r = defaultRegenerateIndex('/tmp/anyroot', '2026-07-09', { existsSync: () => true, spawnSync: () => ({ status: 1 }) });
691
+ assert.equal(r.ok, false);
692
+ assert.match(r.detail, /index regeneration failed/);
693
+ });
694
+ });
695
+
696
+ // ── coverage: defensive fail-loud branches + success/degrade paths (M3a) ───────────────
697
+
698
+ describe('defensive branches + success/degrade paths', () => {
699
+ it('a non-markdown stray in adr/ is IGNORED by the store (never an error)', () => {
700
+ const root = makeRoot();
701
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
702
+ writeFileSync(join(root, ADR_DIR_REL, 'notes.txt'), 'not markdown — ignored\n');
703
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
704
+ });
705
+
706
+ it('a record file holding TWO ADR blocks fails loud', () => {
707
+ const root = makeRoot();
708
+ seedMigrated(root, { hotIds: ['005'], storeIds: [] });
709
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-001-two.md'), `${fm(RECORD_CAP)}\n${adrBlock('001')}\n\n${adrBlock('002')}\n`);
710
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
711
+ assert.equal(code, 1);
712
+ assert.match(errText, /exactly one ADR block/);
713
+ });
714
+
715
+ it('a record whose filename id mismatches its heading id fails loud', () => {
716
+ const root = makeRoot();
717
+ seedMigrated(root, { hotIds: ['005'], storeIds: [] });
718
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-001-mismatch.md'), `${fm(RECORD_CAP)}\n${adrBlock('002')}\n`);
719
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
720
+ assert.equal(code, 1);
721
+ assert.match(errText, /does not match the heading id/);
722
+ });
723
+
724
+ it('a migration with NO writable snapshot base fails loud, monoliths untouched', () => {
725
+ const root = makeRoot();
726
+ seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
727
+ const fileAsBase = join(makeRoot(), 'i-am-a-file');
728
+ writeFileSync(fileAsBase, 'x'); // mkdir UNDER a file → ENOTDIR → both bases fail
729
+ const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root, { spawnSync: noGit, fallbackBase: fileAsBase });
730
+ assert.equal(code, 1);
731
+ assert.match(errText, /no writable snapshot location/);
732
+ assert.ok(existsSync(join(root, WARM_REL)), 'monoliths untouched when the snapshot cannot be written');
733
+ });
734
+
735
+ it('defaultRegenerateIndex: a successful subprocess (status 0) → ok:true with the trimmed stdout', () => {
736
+ const r = defaultRegenerateIndex('/tmp/anyroot', '2026-07-09', { existsSync: () => true, spawnSync: () => ({ status: 0, stdout: 'Wrote docs/ai/index.md\n' }) });
737
+ assert.equal(r.ok, true);
738
+ assert.match(r.detail, /Wrote docs\/ai\/index\.md/);
739
+ });
740
+
741
+ it('migrate on a tree with NO monolith and NO store → a stated "nothing to migrate"', () => {
742
+ const root = makeRoot();
743
+ mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
744
+ writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [adrBlock('005')]));
745
+ const { code, text } = run(['--migrate', '--today=2026-07-09'], root);
746
+ assert.equal(code, 0);
747
+ assert.match(text, /nothing to migrate/);
748
+ });
749
+
750
+ it('migrate --apply with a FAILING index regen degrades loudly (migration still exit 0)', () => {
751
+ const root = makeRoot();
752
+ seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
753
+ const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root, { regen: () => ({ ok: false, detail: 'boom' }) });
754
+ assert.equal(code, 0);
426
755
  assert.match(errText, /NOT regenerated/);
427
- assert.match(errText, /check-docs-size\.mjs --write-index/, 'the loud instruct names the recovery command');
428
- });
429
-
430
- it('the REAL default regenerator writes docs/ai/index.md end-to-end (no injection)', () => {
431
- const root = makeRoot();
432
- seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
433
- const out = [];
434
- const code = runCli(['--today=2026-01-02'], { root, log: (l) => out.push(l), logError: (l) => out.push(l) });
435
- assert.equal(code, 0, out.join('\n'));
436
- assert.ok(existsSync(join(root, 'docs', 'ai', 'index.md')), 'the index was regenerated by the sibling check-docs-size.mjs');
437
- const idx = readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
438
- assert.match(idx, /decisions\.md/, 'the regenerated index lists the rotated ADR file');
439
- assert.match(out.join('\n'), /regenerated docs\/ai\/index\.md/, 'the success log line fires');
440
- });
441
-
442
- // The index-write outcome must be ISOLATED from the docs-cap-CHECK outcome: check-docs-size
443
- // --write-index exits 1 on ANY over-cap co-located doc even after writing the index correctly, so
444
- // a benign over-cap sibling must NEVER read as an index-regeneration failure (cry-wolf on the very
445
- // loud-degrade channel item (h) is built around). The regen spawns --write-index --report.
446
- it('the REAL regenerator succeeds even when an UNRELATED co-located doc is over its cap (no cry-wolf)', () => {
447
- const root = makeRoot();
448
- seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
449
- // An unrelated doc over its OWN maxLines cap: --write-index writes the index fine, but without
450
- // --report it would exit 1 on this file → a false "NOT regenerated" instruct.
451
- writeFileSync(join(root, 'docs', 'ai', 'handover.md'), `---\ntype: state\nlastUpdated: 2026-01-02\nscope: session\nstaleAfter: 7d\nowner: none\nmaxLines: 3\n---\n\n# H\nl1\nl2\nl3\nl4\nl5\n`);
452
- const out = [];
453
- const err = [];
454
- const code = runCli(['--today=2026-01-02'], { root, log: (l) => out.push(l), logError: (l) => err.push(l) });
756
+ });
757
+
758
+ it('--write-navigator on an empty tree → a stated skip', () => {
759
+ const root = makeRoot();
760
+ mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
761
+ const { code, text } = run(['--write-navigator'], root);
455
762
  assert.equal(code, 0);
456
- assert.match(out.join('\n'), /regenerated docs\/ai\/index\.md/, 'a benign over-cap sibling must NOT read as a regen failure');
457
- assert.ok(!err.join('\n').includes('NOT regenerated'), 'no false cry-wolf on the loud-degrade channel');
458
- assert.ok(existsSync(join(root, 'docs', 'ai', 'index.md')), 'the index was actually written');
763
+ assert.match(text, /SKIP no ADR substrate/);
459
764
  });
460
765
 
461
- describe('defaultRegenerateIndex the loud-degrade branches', () => {
462
- it('an absent index generator sibling → a loud instruct (ok:false)', () => {
463
- const r = defaultRegenerateIndex('/tmp/anyroot', '2026-01-02', { sibling: '/nonexistent/check-docs-size.mjs' });
464
- assert.equal(r.ok, false);
465
- assert.match(r.detail, /not beside this script/);
466
- assert.match(r.detail, /check-docs-size\.mjs --write-index/);
467
- });
766
+ it('--write-navigator with a FAILING index regen degrades loudly (exit 0)', () => {
767
+ const root = makeRoot();
768
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
769
+ const { code, errText } = run(['--write-navigator', '--today=2026-07-09'], root, { regen: () => ({ ok: false, detail: 'boom' }) });
770
+ assert.equal(code, 0);
771
+ assert.match(errText, /NOT regenerated/);
772
+ });
468
773
 
469
- it('a regeneration subprocess that exits nonzero a loud instruct (ok:false)', () => {
470
- const r = defaultRegenerateIndex('/tmp/anyroot', '2026-01-02', { existsSync: () => true, spawnSync: () => ({ status: 1 }) });
471
- assert.equal(r.ok, false);
472
- assert.match(r.detail, /index regeneration failed/);
473
- assert.match(r.detail, /check-docs-size\.mjs --write-index/);
474
- });
774
+ it('a SINGLE HOT entry over cap fails loud (cannot be reduced below one)', () => {
775
+ const root = makeRoot();
776
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
777
+ const block = adrBlock('005', { body: 12 });
778
+ const probe = tierText(9999, '# ADRs', [block]);
779
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', [block]));
780
+ const { code, errText } = run(['--today=2026-07-09'], root);
781
+ assert.equal(code, 1);
782
+ assert.match(errText, /cannot be reduced/);
783
+ });
475
784
 
476
- it('a successful subprocess (status 0) → ok:true with the trimmed stdout', () => {
477
- const r = defaultRegenerateIndex('/tmp/anyroot', '2026-01-02', { existsSync: () => true, spawnSync: () => ({ status: 0, stdout: 'Wrote docs/ai/index.md\n' }) });
478
- assert.equal(r.ok, true);
479
- assert.match(r.detail, /Wrote docs\/ai\/index\.md/);
480
- });
785
+ it('rotate refuses a DIVERGENT-body crash-resume record', () => {
786
+ const root = makeRoot();
787
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
788
+ const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
789
+ const probe = tierText(9999, '# ADRs', blocks);
790
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks)); // AD-005 overflows
791
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-005-decision-005.md'), `${fm(RECORD_CAP)}\n## AD-005 — Decision 005\n\n**Date:** 2026-01-01\n\nDIVERGENT body\n`);
792
+ const { code, errText } = run(['--today=2026-07-09'], root);
793
+ assert.equal(code, 1);
794
+ assert.match(errText, /diverges from the freshly-exploded block/);
795
+ });
796
+
797
+ it('rotate --dry-run prints the move-set and writes nothing', () => {
798
+ const root = makeRoot();
799
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
800
+ const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
801
+ const probe = tierText(9999, '# ADRs', blocks);
802
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
803
+ const before = readFileSync(join(root, HOT_REL), 'utf8');
804
+ const { code, text } = run(['--dry-run', '--today=2026-07-09'], root);
805
+ assert.equal(code, 0);
806
+ assert.match(text, /DRY-RUN/);
807
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before);
808
+ });
809
+ });
810
+
811
+ describe('usage', () => {
812
+ it('an unknown argument is a loud exit 2', () => {
813
+ const { code, errText } = run(['--frobnicate'], makeRoot());
814
+ assert.equal(code, 2);
815
+ assert.match(errText, /Unknown argument/);
816
+ });
817
+ it('--help prints usage and exits 0', () => {
818
+ const { code, text } = run(['--help'], makeRoot());
819
+ assert.equal(code, 0);
820
+ assert.match(text, /Usage: archive-decisions/);
481
821
  });
482
822
  });