@sabaiway/agent-workflow-memory 1.11.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +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,
25
+ defaultRegenerateIndex,
17
26
  } from './archive-decisions.mjs';
18
27
 
19
- // Hermetic by design: this test ships as deploy payload and runs inside CONSUMER repos via the
20
- // 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.
21
31
 
22
32
  const tempDirs = [];
23
33
  const makeRoot = () => {
@@ -32,70 +42,123 @@ afterEach(() => {
32
42
  const fm = (cap) =>
33
43
  `---\ntype: reference\nlastUpdated: 2026-01-01\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${cap}\n---\n`;
34
44
 
35
- // One canonical entry block: 3 fixed lines + extraLines body lines.
36
- const entryBlock = (id, extraLines = 2) =>
37
- [`## 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
+ };
38
59
 
39
60
  const tierText = (cap, preamble, blocks) => `${fm(cap)}\n${preamble}\n\n${blocks.join('\n\n')}\n`;
40
61
 
41
62
  const HOT_PREAMBLE = [
42
63
  '# Architecture Decision Records (ADRs)',
43
64
  '',
44
- '> Newest at the bottom.',
65
+ '> Newest at the bottom. Link related ADRs with `[[AD-XXX]]`.',
45
66
  '>',
46
- '> **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).',
47
68
  ].join('\n');
48
-
49
- 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.';
50
70
  const COLD_PREAMBLE = '# ADR Early Archive (AD-001 … AD-002)\n\n> COLD tier.';
51
71
 
52
- // Seed a project: HOT with `hotIds`, WARM with `warmIds`, COLD with `coldIds`; caps computed
53
- // from the measured rendered size + a delta (so fixtures stay robust to formatting arithmetic).
54
- 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 }) => {
55
74
  mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
56
- const write = (rel, preamble, ids, capDelta) => {
57
- const blocks = ids.map((id) => entryBlock(id));
58
- const probe = tierText(999, preamble, blocks);
75
+ const writeTier = (rel, preamble, blocks, capDelta) => {
76
+ const probe = tierText(9999, preamble, blocks);
59
77
  const cap = lineCountOf(probe) + capDelta;
60
78
  writeFileSync(join(root, rel), tierText(cap, preamble, blocks));
61
79
  return cap;
62
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)));
63
84
  return {
64
- hotCap: write(HOT_REL, HOT_PREAMBLE, hotIds, hotCapDelta),
65
- warmCap: write(WARM_REL, WARM_PREAMBLE, warmIds, warmCapDelta),
66
- 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,
67
88
  };
68
89
  };
69
90
 
70
- 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 = {}) => {
71
95
  const out = [];
72
96
  const err = [];
73
- const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l) });
74
- 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 };
75
109
  };
76
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() : []);
77
112
  const idsIn = (root, rel) => parseDecisionsText(readFileSync(join(root, rel), 'utf8'), rel).entries.map((e) => e.id);
78
113
 
79
- // ── 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
+ });
80
137
 
81
- describe('parseDecisionsText fail-loud on non-canonical headings', () => {
82
- it('parses canonical entries with ids, titles, and per-entry line counts', () => {
83
- const parsed = parseDecisionsText(tierText(500, HOT_PREAMBLE, [entryBlock('005'), entryBlock('006', 4)]), 'x');
84
- assert.deepEqual(parsed.entries.map((e) => e.id), ['005', '006']);
85
- assert.equal(parsed.entries[0].lineCount, 5);
86
- assert.equal(parsed.entries[1].lineCount, 7);
87
- 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');
88
151
  });
89
152
 
90
153
  const badHeadings = [
91
154
  ['a hyphen instead of the em-dash', '## AD-024 - Title'],
92
- ['a 2-digit id', '## AD-24 — Title'],
155
+ ['a 2-digit id (below the AD-\\d{3,} floor)', '## AD-24 — Title'],
93
156
  ['a missing title', '## AD-024 — '],
94
157
  ['an unrelated H2', '## Notes'],
95
158
  ];
96
159
  for (const [name, heading] of badHeadings) {
97
160
  it(`rejects ${name} naming file:line — never a silent merge`, () => {
98
- 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`;
99
162
  assert.throws(() => parseDecisionsText(text, 'docs/ai/decisions.md'), (e) => {
100
163
  assert.equal(e.exitCode, 1);
101
164
  assert.match(e.message, /docs\/ai\/decisions\.md:\d+/);
@@ -105,261 +168,655 @@ describe('parseDecisionsText — fail-loud on non-canonical headings', () => {
105
168
  });
106
169
  }
107
170
 
108
- it('rejects disordered ids (oldest must be at the top)', () => {
109
- const text = tierText(500, '# T', [entryBlock('007'), entryBlock('005')]);
110
- 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');
111
179
  });
112
180
  });
113
181
 
114
- describe('loadTiers cross-tier integrity', () => {
115
- it('rejects a duplicate id across tiers', () => {
116
- const root = makeRoot();
117
- seedProject(root, { hotIds: ['005'], warmIds: ['005'], coldIds: [] });
118
- 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');
190
+ });
191
+ });
192
+
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'));
119
229
  });
120
230
  });
121
231
 
122
- // ── the cascade ───────────────────────────────────────────────────────────────────────
232
+ // ── 1.3 --migrate + snapshot + retire monoliths + idempotence + legacy guard ─────────
123
233
 
124
- describe('rotation simple HOT→WARM roll', () => {
125
- 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', () => {
126
236
  const root = makeRoot();
127
- seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
128
- const before = loadTiers(root, '2026-01-02');
129
- 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
+ });
130
248
 
131
- 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);
132
254
  assert.equal(code, 0, text);
133
- assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008'], 'the oldest HOT entry rolled');
134
- assert.deepEqual(idsIn(root, WARM_REL), ['003', '004', '005'], 'appended to the WARM end');
135
- assert.deepEqual(idsIn(root, COLD_REL), ['001', '002'], 'COLD untouched');
136
255
 
137
- const after = loadTiers(root, '2026-01-02');
138
- const allAfter = [after.hot, after.warm, after.cold].flatMap((t) => t.entries.map((e) => `${e.id}:${e.lineCount}`)).sort();
139
- assert.deepEqual(allAfter, allBefore, 'id multiset + per-entry line counts conserved');
140
- 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');
141
282
  });
142
283
 
143
- 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)', () => {
144
285
  const root = makeRoot();
145
- seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
146
- const bytesBefore = readFileSync(join(root, HOT_REL), 'utf8');
147
- 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);
148
288
  assert.equal(code, 0);
149
- assert.match(text, /nothing to rotate/);
150
- 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');
151
292
  });
152
- });
153
293
 
154
- describe('rotation the CHAINED roll (WARM near cap rolls WARMCOLD first)', () => {
155
- 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)', () => {
156
295
  const root = makeRoot();
157
- seedProject(root, {
158
- hotIds: ['005', '006', '007'],
159
- warmIds: ['003', '004'],
160
- coldIds: ['001', '002'],
161
- hotCapDelta: -1, // force one HOT roll
162
- warmCapDelta: 3, // the incoming 6-line append does NOT fit → chain
163
- });
164
- 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);
165
300
  assert.equal(code, 0);
166
- assert.deepEqual(idsIn(root, HOT_REL), ['006', '007']);
167
- assert.deepEqual(idsIn(root, WARM_REL), ['004', '005'], 'AD-003 chained out, AD-005 appended');
168
- 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');
169
303
  });
170
- });
171
304
 
172
- describe('rotation COLD exhaustion fails LOUD before ANY write', () => {
173
- 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)', () => {
174
306
  const root = makeRoot();
175
- seedProject(root, {
176
- hotIds: ['005', '006', '007'],
177
- warmIds: ['003', '004'],
178
- coldIds: ['001', '002'],
179
- hotCapDelta: -1, // force a HOT roll
180
- warmCapDelta: 3, // force the chain into COLD
181
- coldCapDelta: 3, // the chained 6-line entry does NOT fit COLD
182
- });
183
- const snapshot = () => [HOT_REL, WARM_REL, COLD_REL].map((rel) => readFileSync(join(root, rel), 'utf8'));
184
- const before = snapshot();
185
- 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);
186
321
  assert.equal(code, 1);
187
- assert.match(errText, /refusing BEFORE any write/);
188
- assert.match(errText, /maintainer\/agent decision/);
189
- 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)');
190
325
  });
191
- });
192
326
 
193
- describe('rotation determinism + dry-run', () => {
194
- 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', () => {
195
328
  const root = makeRoot();
196
- seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003'], coldIds: ['001'], hotCapDelta: -1 });
197
- const tiers = loadTiers(root, '2026-01-02');
198
- const planA = planRotation(tiers);
199
- const planB = planRotation(loadTiers(root, '2026-01-02'));
200
- 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');
201
342
  });
202
343
 
203
- 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)', () => {
204
345
  const root = makeRoot();
205
- seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
206
- const before = readFileSync(join(root, HOT_REL), 'utf8');
207
- 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 });
208
389
  assert.equal(code, 0);
209
- assert.match(text, /DRY-RUN/);
210
- assert.match(text, /AD-005/);
211
- 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');
212
392
  });
213
393
  });
214
394
 
215
- describe('preamble range tokens', () => {
216
- 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)', () => {
217
397
  const root = makeRoot();
218
- seedProject(root, { hotIds: ['005', '006', '007'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
219
- const { code } = run(['--today=2026-01-02'], root);
220
- assert.equal(code, 0);
221
- const hotText = readFileSync(join(root, HOT_REL), 'utf8');
222
- assert.match(hotText, /\*\*AD-003 … AD-005\*\*/, 'WARM range token updated in HOT');
223
- assert.match(hotText, /\(AD-006 onward\)/, 'active-set token updated');
224
- const warmText = readFileSync(join(root, WARM_REL), 'utf8');
225
- 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/);
226
410
  });
227
411
 
228
- it('a preamble without tokens is left untouched (consumer wording preserved)', () => {
229
- const updated = updateRangeTokens('# My own ADR file\n\n> no tokens here', 'hot', {
230
- hotEntries: [{ id: '010' }],
231
- warmEntries: [{ id: '001' }, { id: '002' }],
232
- coldEntries: [],
233
- });
234
- 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/);
235
419
  });
236
420
  });
237
421
 
238
- // ── --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
+ };
239
439
 
240
- describe('--check', () => {
241
- 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', () => {
242
442
  const root = makeRoot();
243
- seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
244
- const { code, text } = run(['--check'], root);
245
- assert.equal(code, 0);
246
- assert.match(text, /decisions\.md: \d+\/\d+/);
247
- 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);
248
445
  });
249
446
 
250
- it('an over-cap HOT → exit 1 naming the rotation recovery', () => {
447
+ it('a duplicate id across HOT and adr/ → exit 1', () => {
251
448
  const root = makeRoot();
252
- seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
253
- 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);
254
451
  assert.equal(code, 1);
255
- assert.match(errText, /decisions\.md is over its cap/);
256
- assert.match(errText, /archive-decisions\.mjs` to rotate/);
452
+ assert.match(errText, /duplicate ADR id AD-002/);
257
453
  });
258
454
 
259
- 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)', () => {
260
456
  const root = makeRoot();
261
- seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001', '002'], coldCapDelta: -1 });
262
- 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);
263
461
  assert.equal(code, 1);
264
- assert.match(errText, /maintainer\/agent decision/);
462
+ assert.match(errText, /no maxLines cap/);
265
463
  });
266
464
 
267
- 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', () => {
268
466
  const root = makeRoot();
269
467
  mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
270
468
  const { code, text } = run(['--check'], root);
271
469
  assert.equal(code, 0);
272
- assert.match(text, /SKIP — docs\/ai\/decisions\.md not found/);
470
+ assert.match(text, /SKIP — no ADR substrate/);
273
471
  });
274
472
 
275
- 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)', () => {
276
474
  const root = makeRoot();
277
- mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
278
- 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);
279
479
  assert.equal(code, 1);
280
- assert.match(errText, /not found/);
480
+ assert.match(errText, /partition violated/);
281
481
  });
282
- });
283
482
 
284
- describe('cap accounting is on RAW file lines (the template-shaped `---` separator case)', () => {
285
- // A consumer file born from the shipped template joins entries with `\n\n---\n\n`. Normalized
286
- // rendering drops those separator lines, so a render-based count would false-green a file the
287
- // docs cap-validator (raw lines) fails. --check and the write trigger must count RAW lines.
288
- const seedSeparatorShaped = (root, { capDelta }) => {
289
- mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
290
- const blocks = [entryBlock('001'), entryBlock('002'), entryBlock('003')];
291
- const body = blocks.join('\n\n---\n\n'); // the template separator shape
292
- const probe = `${fm(999)}\n# ADRs\n\n${body}\n`;
293
- const cap = lineCountOf(probe) + capDelta;
294
- writeFileSync(join(root, HOT_REL), `${fm(cap)}\n# ADRs\n\n${body}\n`);
295
- return { cap, rawLines: lineCountOf(probe) };
296
- };
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
+ });
297
491
 
298
- 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)', () => {
299
493
  const root = makeRoot();
300
- // 3 separators × 2 lines = 6 lines the render drops; cap sits 2 under raw → render fits, raw does not.
301
- seedSeparatorShaped(root, { capDelta: -2 });
302
- const { code, errText } = run(['--check'], root);
303
- assert.equal(code, 1, 'raw lines are what the docs cap-validator counts — never false-green');
304
- 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/);
305
499
  });
306
500
 
307
- 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)', () => {
308
502
  const root = makeRoot();
309
- seedSeparatorShaped(root, { capDelta: -2 });
310
- const { code, text } = run(['--today=2026-01-02'], root);
311
- assert.equal(code, 0, text);
312
- assert.match(text, /normalize-only rewrite/);
313
- assert.match(text, /HOT→WARM: \(none\)/);
314
- const after = parseDecisionsText(readFileSync(join(root, HOT_REL), 'utf8'), HOT_REL);
315
- assert.deepEqual(after.entries.map((e) => e.id), ['001', '002', '003'], 'ids conserved, nothing moved');
316
- const { code: recheck } = run(['--check'], root);
317
- assert.equal(recheck, 0, 'the normalized file now fits its cap on raw lines');
318
- });
319
-
320
- it('a tier STILL over cap after the planned rewrite refuses BEFORE any write (a normalized rewrite is not a licence)', () => {
321
- const root = makeRoot();
322
- // COLD legitimately exceeds its cap (entries, not formatting): no move can fix it and a
323
- // normalize-only rewrite would still be over budget the run must refuse with files untouched.
324
- seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001', '002'], coldCapDelta: -3 });
325
- const snapshot = () => [HOT_REL, WARM_REL, COLD_REL].map((rel) => readFileSync(join(root, rel), 'utf8'));
326
- const before = snapshot();
327
- 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);
513
+ assert.equal(code, 1);
514
+ assert.match(errText, /two records for AD-001/);
515
+ });
516
+
517
+ it('a NESTED subdirectory in adr/ fails loud (the store is a flat directory codex R2)', () => {
518
+ const root = makeRoot();
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);
328
523
  assert.equal(code, 1);
329
- assert.match(errText, /would still be over its cap after rotation/);
330
- assert.match(errText, /maintainer\/agent decision/);
331
- assert.deepEqual(snapshot(), before, 'no file changed on the refused plan');
524
+ assert.match(errText, /FLAT directory/);
332
525
  });
333
526
 
334
- it('a normalize-only rewrite NEVER materializes absent WARM/COLD files (no premature archives)', () => {
527
+ it('adr/ present but HOT absent integrity is STILL checked (not skipped): a stale nav → exit 1', () => {
335
528
  const root = makeRoot();
336
- seedSeparatorShaped(root, { capDelta: -2 }); // HOT only — no history files exist
337
- const { code } = run(['--today=2026-01-02'], root);
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/);
535
+ });
536
+ });
537
+
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', () => {
540
+ const root = makeRoot();
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);
549
+ assert.equal(code, 0);
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)');
553
+ });
554
+
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);
338
559
  assert.equal(code, 0);
339
- assert.ok(!existsSync(join(root, WARM_REL)), 'an absent WARM with zero entries is not created');
340
- assert.ok(!existsSync(join(root, COLD_REL)), 'an absent COLD with zero entries is not created');
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/);
603
+ });
604
+ });
605
+
606
+ // ── 1.5 — navigator generator + --write-navigator ──────────────────────────────────────
607
+
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', () => {
610
+ const root = makeRoot();
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');
632
+ });
633
+
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)');
644
+ });
645
+
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', () => {
647
+ const root = makeRoot();
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');
660
+ });
661
+
662
+ it('--write-navigator refuses a duplicate-id store (never emits a corrupt / duplicate-row navigator)', () => {
663
+ const root = makeRoot();
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);
341
678
  });
342
679
  });
343
680
 
344
- describe('created tiers (a consumer with no history files yet)', () => {
345
- it('rolling into an absent WARM creates it with frontmatter + preamble', () => {
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);
755
+ assert.match(errText, /NOT regenerated/);
756
+ });
757
+
758
+ it('--write-navigator on an empty tree → a stated skip', () => {
346
759
  const root = makeRoot();
347
760
  mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
348
- const blocks = [entryBlock('001'), entryBlock('002')];
349
- const probe = tierText(999, '# ADRs', blocks);
761
+ const { code, text } = run(['--write-navigator'], root);
762
+ assert.equal(code, 0);
763
+ assert.match(text, /SKIP — no ADR substrate/);
764
+ });
765
+
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
+ });
773
+
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
+ });
784
+
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);
350
802
  writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
351
- const { code } = run(['--today=2026-01-02'], root);
803
+ const before = readFileSync(join(root, HOT_REL), 'utf8');
804
+ const { code, text } = run(['--dry-run', '--today=2026-07-09'], root);
352
805
  assert.equal(code, 0);
353
- assert.ok(existsSync(join(root, WARM_REL)), 'WARM created');
354
- const warm = parseDecisionsText(readFileSync(join(root, WARM_REL), 'utf8'), WARM_REL);
355
- assert.deepEqual(warm.entries.map((e) => e.id), ['001']);
356
- assert.equal(warm.cap, 500, 'created WARM carries the default cap');
357
- assert.match(warm.preamble, /AD-001 … AD-001/, 'created preamble range filled');
806
+ assert.match(text, /DRY-RUN/);
807
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before);
358
808
  });
809
+ });
359
810
 
360
- it('usage errors are loud (exit 2)', () => {
811
+ describe('usage', () => {
812
+ it('an unknown argument is a loud exit 2', () => {
361
813
  const { code, errText } = run(['--frobnicate'], makeRoot());
362
814
  assert.equal(code, 2);
363
815
  assert.match(errText, /Unknown argument/);
364
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/);
821
+ });
365
822
  });