@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.
- package/CHANGELOG.md +72 -1
- package/README.md +2 -2
- package/SKILL.md +42 -21
- package/bin/install.mjs +32 -0
- package/capability.json +1 -1
- package/migrations/README.md +1 -1
- package/migrations/legacy-stamp-takeover.md +3 -3
- package/package.json +1 -1
- package/references/scripts/archive-decisions.mjs +705 -283
- package/references/scripts/archive-decisions.test.mjs +666 -209
- package/references/scripts/check-docs-size.mjs +85 -29
- package/references/scripts/check-docs-size.test.mjs +128 -1
- package/references/templates/adr/log.md +25 -0
- package/references/templates/adr-record.md +72 -0
- package/references/templates/agent_rules.md +1 -0
- package/references/templates/decisions.md +7 -18
- package/references/templates/verification-profile.json +10 -0
- package/scripts/stamp-takeover.mjs +2 -2
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
|
20
|
-
//
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
|
57
|
-
const
|
|
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:
|
|
65
|
-
warmCap:
|
|
66
|
-
coldCap:
|
|
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
|
|
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
|
|
74
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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${
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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('
|
|
115
|
-
it('
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
assert.
|
|
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
|
-
// ──
|
|
232
|
+
// ── 1.3 — --migrate + snapshot + retire monoliths + idempotence + legacy guard ─────────
|
|
123
233
|
|
|
124
|
-
describe('
|
|
125
|
-
it('
|
|
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
|
-
|
|
128
|
-
const before =
|
|
129
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
assert.
|
|
140
|
-
assert.ok(
|
|
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('
|
|
284
|
+
it('migrating an OVER-CAP HOT explodes the HOT overflow into records too (not only the monoliths)', () => {
|
|
144
285
|
const root = makeRoot();
|
|
145
|
-
|
|
146
|
-
const
|
|
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.
|
|
150
|
-
assert.
|
|
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
|
-
|
|
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 retired → already-migrated)', () => {
|
|
156
295
|
const root = makeRoot();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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.
|
|
167
|
-
assert.deepEqual(
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
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, /
|
|
188
|
-
assert.
|
|
189
|
-
assert.
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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('
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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.
|
|
210
|
-
assert.
|
|
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('
|
|
216
|
-
it('
|
|
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
|
-
|
|
219
|
-
const { code } = run(['--today=2026-
|
|
220
|
-
assert.equal(code,
|
|
221
|
-
|
|
222
|
-
assert.match(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
assert.
|
|
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 +
|
|
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('
|
|
440
|
+
describe('1.4 --check', () => {
|
|
441
|
+
it('a green migrated tree → exit 0', () => {
|
|
242
442
|
const root = makeRoot();
|
|
243
|
-
|
|
244
|
-
|
|
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('
|
|
447
|
+
it('a duplicate id across HOT and adr/ → exit 1', () => {
|
|
251
448
|
const root = makeRoot();
|
|
252
|
-
|
|
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, /
|
|
256
|
-
assert.match(errText, /archive-decisions\.mjs` to rotate/);
|
|
452
|
+
assert.match(errText, /duplicate ADR id AD-002/);
|
|
257
453
|
});
|
|
258
454
|
|
|
259
|
-
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
|
-
|
|
262
|
-
const
|
|
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, /
|
|
462
|
+
assert.match(errText, /no maxLines cap/);
|
|
265
463
|
});
|
|
266
464
|
|
|
267
|
-
it('
|
|
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 —
|
|
470
|
+
assert.match(text, /SKIP — no ADR substrate/);
|
|
273
471
|
});
|
|
274
472
|
|
|
275
|
-
it('an
|
|
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
|
-
|
|
278
|
-
const
|
|
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, /
|
|
480
|
+
assert.match(errText, /partition violated/);
|
|
281
481
|
});
|
|
282
|
-
});
|
|
283
482
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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('
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
const { code, errText } = run(['--check'], root);
|
|
303
|
-
assert.equal(code, 1
|
|
304
|
-
assert.match(errText, /
|
|
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('
|
|
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
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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, /
|
|
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('
|
|
527
|
+
it('adr/ present but HOT absent → integrity is STILL checked (not skipped): a stale nav → exit 1', () => {
|
|
335
528
|
const root = makeRoot();
|
|
336
|
-
|
|
337
|
-
|
|
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.
|
|
340
|
-
assert.
|
|
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
|
-
|
|
345
|
-
|
|
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
|
|
349
|
-
|
|
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
|
|
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.
|
|
354
|
-
|
|
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
|
-
|
|
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
|
});
|