@sabaiway/agent-workflow-memory 3.2.0 → 4.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,4 +1,8 @@
1
1
  import { describe, it } from 'node:test';
2
+ import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
3
+ import { join, resolve, dirname } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
2
6
  import { expect } from './_expect-shim.mjs';
3
7
  import {
4
8
  parseKnownIssues,
@@ -7,64 +11,777 @@ import {
7
11
  } from './archive-issues.mjs';
8
12
 
9
13
  const FM = '---\ntype: reference\nlastUpdated: 2026-05-24\nmaxLines: 240\n---\n';
14
+ const CUTOFF = new Date('2026-05-20T00:00:00Z');
15
+ // Past every real-corpus resolution date, so the exact real-world marker strings classify archivable.
16
+ const LATE_CUTOFF = new Date('2026-08-20T00:00:00Z');
17
+ const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'templates');
10
18
 
11
- describe('parseKnownIssues', () => {
12
- it('extracts frontmatter and each ### section', () => {
19
+ // A hand-built section in the parser's shape: the heading line is lines[0].
20
+ const sec = (heading, ...body) => ({ heading, lines: [heading, ...body] });
21
+
22
+ describe('parseKnownIssues — the section model', () => {
23
+ it('extracts frontmatter, file-structural chunks, and each ### section', () => {
13
24
  const text = `${FM}\n# Known Issues\n\n## High\n\n### Issue-001: foo\n\nbody one.\n\n### ~~Issue-002: bar~~\n\nbody two.\n`;
14
25
  const parsed = parseKnownIssues(text);
15
26
  expect(parsed.frontmatter).toBe(FM);
16
- const issueSections = parsed.sections.filter((s) => s.heading !== null);
27
+ const issueSections = parsed.sections.filter((s) => s.structural === false);
17
28
  expect(issueSections).toHaveLength(2);
18
29
  expect(issueSections[0].heading).toBe('### Issue-001: foo');
30
+ const category = parsed.sections.find((s) => s.heading === '## High');
31
+ expect(Boolean(category)).toBe(true);
32
+ expect(category.structural).toBe(true);
19
33
  });
20
34
 
21
- it('treats body before any ### as a preamble section', () => {
35
+ it('treats body before any boundary as a structural preamble chunk', () => {
22
36
  const text = `${FM}\n# Header\n\npreamble text\n\n### Issue-001: foo\n\nbody.\n`;
23
37
  const parsed = parseKnownIssues(text);
24
38
  expect(parsed.sections[0].heading).toBeNull();
39
+ expect(parsed.sections[0].structural).toBe(true);
25
40
  expect(parsed.sections[0].lines.join('\n')).toContain('preamble text');
26
41
  });
42
+
43
+ // Phase 3.1 (L8): category H2s, the preamble and the trailing footer belong to the FILE — an
44
+ // issue section contains only its own issue, so a rotation can never carry them into an archive.
45
+ it('a category H2 between two issues belongs to the file, not to the preceding issue', () => {
46
+ const text = `${FM}\n# Known Issues\n\n### ~~Issue-001 — done~~\n\n- **Status:** ✅ FIXED (2026.04.10)\n\n## 🟢 Resolved\n\n### Issue-002 — listed resolved\n\n- **Resolved:** 2026-04-11 — fixed.\n`;
47
+ const parsed = parseKnownIssues(text);
48
+ const first = parsed.sections.find((s) => s.heading !== null && s.heading.includes('Issue-001'));
49
+ expect(first.lines.join('\n')).not.toContain('## 🟢 Resolved');
50
+ const category = parsed.sections.find((s) => s.heading === '## 🟢 Resolved');
51
+ expect(Boolean(category)).toBe(true);
52
+ expect(category.structural).toBe(true);
53
+ });
54
+
55
+ it('the trailing separator and closing note parse as a FILE chunk, not the last issue body', () => {
56
+ const text = `${FM}\n# Known Issues\n\n### ~~Issue-001 — done~~\n\n- **Status:** ✅ FIXED (2026.04.10)\n\n---\n\n> Resolved issues older than the window are rotated to \`history/issues-resolved.md\` by the issue-archive script.\n`;
57
+ const parsed = parseKnownIssues(text);
58
+ const issue = parsed.sections.find((s) => s.heading !== null && s.heading.includes('Issue-001'));
59
+ expect(issue.lines.join('\n')).not.toContain('> Resolved issues');
60
+ const footer = parsed.sections[parsed.sections.length - 1];
61
+ expect(footer.structural).toBe(true);
62
+ expect(footer.lines.join('\n')).toContain('---');
63
+ expect(footer.lines.join('\n')).toContain('> Resolved issues');
64
+ });
65
+
66
+ // CommonMark allows 0-3 leading spaces on a thematic break — an indented separator before the
67
+ // canonical note is still the FILE footer, so a rotation can never carry the note away.
68
+ it('an indented CommonMark separator still introduces the canonical footer', async () => {
69
+ const text = `${FM}\n# Known Issues\n\n### ~~Issue-072 — done~~\n\n- **Status:** ✅ FIXED (2026-04-10)\n\n ---\n\n> Resolved issues older than the window are rotated to \`history/issues-resolved.md\` by the issue-archive script.\n`;
70
+ const parsed = parseKnownIssues(text);
71
+ const footer = parsed.sections[parsed.sections.length - 1];
72
+ expect(footer.structural).toBe(true);
73
+ expect(footer.lines.join('\n')).toContain('> Resolved issues');
74
+ const { runCli } = await import('./archive-issues.mjs');
75
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
76
+ try {
77
+ mkdirSync(join(dir, 'docs/ai'), { recursive: true });
78
+ writeFileSync(join(dir, 'docs/ai/known_issues.md'), text, 'utf8');
79
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
80
+ const kept = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
81
+ expect(kept).toContain(' ---');
82
+ expect(kept).toContain('> Resolved issues older than the window');
83
+ expect(kept).not.toContain('Issue-072');
84
+ expect(readFileSync(join(dir, 'docs/ai/history/issues-resolved.md'), 'utf8')).toContain('Issue-072');
85
+ } finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ // The footer anchor is the CANONICAL closing note, never a mere path mention — an issue-owned
91
+ // trailing quote (even one naming the archive) belongs to its section and archives WITH it.
92
+ it('a trailing quote that is not the canonical closing note stays with its issue, and archives with it', async () => {
93
+ const text = `${FM}\n# Known Issues\n\n### ~~Issue-071 — done~~\n\n- **Status:** ✅ FIXED (2026-04-10)\n\n---\n\n> This caveat is about Issue-071 itself, not the file — see history/issues-resolved.md.\n`;
94
+ const parsed = parseKnownIssues(text);
95
+ const last = parsed.sections[parsed.sections.length - 1];
96
+ expect(last.structural).toBe(false);
97
+ expect(last.lines.join('\n')).toContain('about Issue-071 itself');
98
+ const { runCli } = await import('./archive-issues.mjs');
99
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
100
+ try {
101
+ mkdirSync(join(dir, 'docs/ai'), { recursive: true });
102
+ writeFileSync(join(dir, 'docs/ai/known_issues.md'), text, 'utf8');
103
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
104
+ expect(readFileSync(join(dir, 'docs/ai/history/issues-resolved.md'), 'utf8')).toContain('about Issue-071 itself');
105
+ expect(readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8')).not.toContain('about Issue-071 itself');
106
+ } finally {
107
+ rmSync(dir, { recursive: true, force: true });
108
+ }
109
+ });
27
110
  });
28
111
 
29
112
  describe('classifySection', () => {
30
- const cutoff = new Date('2026-05-20T00:00:00Z'); // 14 days before today=2026-05-24 ... actually let's use real cutoff math
31
-
32
- it('returns preamble when heading is null', () => {
33
- expect(classifySection({ heading: null, lines: [] }, cutoff).kind).toBe('preamble');
113
+ it('returns structure for the preamble and any file-structural chunk', () => {
114
+ expect(classifySection({ heading: null, structural: true, lines: [] }, CUTOFF).kind).toBe('structure');
115
+ expect(classifySection({ heading: '## 🟢 Resolved', structural: true, lines: ['## 🟢 Resolved', ''] }, CUTOFF).kind).toBe('structure');
34
116
  });
35
117
 
36
- it('returns open when issue heading is not strikethrough', () => {
37
- const section = {
38
- heading: '### Issue-013: example open issue',
39
- lines: ['### Issue-013: example open issue', '', '**Status:** Accepted'],
40
- };
41
- expect(classifySection(section, cutoff).kind).toBe('open');
118
+ it('returns open when an issue carries no resolution marker', () => {
119
+ expect(classifySection(sec('### Issue-013: example open issue', '', '**Status:** Accepted'), CUTOFF).kind).toBe('open');
42
120
  });
43
121
 
44
- it('returns archivable when strikethrough AND FIXED date older than cutoff', () => {
45
- const section = {
46
- heading: '### ~~Issue-001: example fixed feature~~',
47
- lines: ['### ~~Issue-001: example fixed feature~~', '', '**Status:** ✅ FIXED (2026.04.10)'],
48
- };
49
- const result = classifySection(section, cutoff);
122
+ it('returns archivable when the legacy struck dotted marker is older than cutoff', () => {
123
+ const result = classifySection(sec('### ~~Issue-001: example fixed feature~~', '', '**Status:** ✅ FIXED (2026.04.10)'), CUTOFF);
50
124
  expect(result.kind).toBe('archivable');
51
125
  expect(result.fixedDate.toISOString().slice(0, 10)).toBe('2026-04-10');
52
126
  });
53
127
 
54
- it('returns fixed-recent when strikethrough AND FIXED date newer than cutoff', () => {
55
- const section = {
56
- heading: '### ~~Issue-015: example recently-fixed item~~',
57
- lines: ['### ~~Issue-015: example recently-fixed item~~', '', '**Status:** ✅ FIXED (2026.05.23)'],
58
- };
59
- expect(classifySection(section, cutoff).kind).toBe('fixed-recent');
128
+ it('returns fixed-recent when the resolution date is newer than cutoff', () => {
129
+ const section = sec('### ~~Issue-015: example recently-fixed item~~', '', '**Status:** ✅ FIXED (2026.05.23)');
130
+ expect(classifySection(section, CUTOFF).kind).toBe('fixed-recent');
60
131
  });
61
132
 
62
- it('returns fixed-undated when strikethrough has no FIXED date', () => {
63
- const section = {
64
- heading: '### ~~Issue-002: example undated-fixed item~~',
65
- lines: ['### ~~Issue-002: example undated-fixed item~~', '', '**Status:** ✅ FIXED'],
66
- };
67
- expect(classifySection(section, cutoff).kind).toBe('fixed-undated');
133
+ it('returns fixed-undated when a struck heading carries no marker date', () => {
134
+ const section = sec('### ~~Issue-002: example undated-fixed item~~', '', '**Status:** ✅ FIXED');
135
+ expect(classifySection(section, CUTOFF).kind).toBe('fixed-undated');
136
+ });
137
+ });
138
+
139
+ // Phase 3.2/3.3 (Decision 7): strikethrough stops gating archivability — a recognised, line-leading,
140
+ // dated resolution marker decides alone. Fixtures are the exact real-world strings.
141
+ describe('the marker contract — real-world resolution shapes', () => {
142
+ it('a struck heading with an ISO dated marker classifies archivable', () => {
143
+ const result = classifySection(
144
+ sec(
145
+ '### ~~Issue-013 — publish lane needed an env relay, and every relay shape eventually gets refused~~',
146
+ '- **Status:** **Resolved** (FIXED 2026-07-21, [[AD-066]]: `--token-file` on `dispatch-publish.mjs`).',
147
+ ),
148
+ LATE_CUTOFF,
149
+ );
150
+ expect(result.kind).toBe('archivable');
151
+ expect(result.fixedDate.toISOString().slice(0, 10)).toBe('2026-07-21');
152
+ });
153
+
154
+ it('an UNSTRUCK section with a dated resolution marker classifies archivable', () => {
155
+ const result = classifySection(
156
+ sec(
157
+ '### Issue-011 — seed-gates offer screening: three residuals closed by construction (AD-052)',
158
+ '- **Resolved:** 2026-07-10 — kit **1.43.0** ([[AD-052]]; the U2-DEBT closed-world offer-derivation plan).',
159
+ ),
160
+ LATE_CUTOFF,
161
+ );
162
+ expect(result.kind).toBe('archivable');
163
+ expect(result.fixedDate.toISOString().slice(0, 10)).toBe('2026-07-10');
164
+ });
165
+
166
+ it('the list-item prefix is optional — both prefixed and bare markers classify identically', () => {
167
+ const prefixed = classifySection(sec('### Issue-020 — prefixed', '- **Status:** ✅ FIXED (2026-04-10)'), CUTOFF);
168
+ const bare = classifySection(sec('### Issue-021 — bare', '**Status:** ✅ FIXED (2026-04-10)'), CUTOFF);
169
+ expect(prefixed.kind).toBe('archivable');
170
+ expect(bare.kind).toBe('archivable');
171
+ expect(bare.fixedDate.toISOString()).toBe(prefixed.fixedDate.toISOString());
172
+ });
173
+
174
+ it('both separator forms of one date classify to the same day', () => {
175
+ const dotted = classifySection(sec('### ~~Issue-022 — dotted~~', '- **Status:** ✅ FIXED (2026.04.10)'), CUTOFF);
176
+ const iso = classifySection(sec('### ~~Issue-023 — iso~~', '- **Status:** ✅ FIXED (2026-04-10)'), CUTOFF);
177
+ expect(dotted.kind).toBe('archivable');
178
+ expect(iso.kind).toBe('archivable');
179
+ expect(iso.fixedDate.toISOString()).toBe(dotted.fixedDate.toISOString());
180
+ });
181
+
182
+ it('a resolution marker with trailing prose classifies archivable on its leading date', () => {
183
+ const result = classifySection(
184
+ sec(
185
+ '### Issue-003 — the publish workflow lagged best practice',
186
+ '- **Resolved:** 2026-07-01 ([[AD-031]] release) — the **first live OIDC publish succeeded**, proving the token exchange.',
187
+ ),
188
+ LATE_CUTOFF,
189
+ );
190
+ expect(result.kind).toBe('archivable');
191
+ expect(result.fixedDate.toISOString().slice(0, 10)).toBe('2026-07-01');
192
+ });
193
+
194
+ it('a prose H3 with a dated resolution marker is archivable — the marker decides alone', () => {
195
+ const result = classifySection(
196
+ sec(
197
+ '### The 2026-07-02 upgrade-session misreport — four diagnosed defect classes closed (AD-034)',
198
+ '- **Resolved:** 2026-07-02 — engine **1.9.0** / kit **1.26.0** ([[AD-034]]).',
199
+ ),
200
+ LATE_CUTOFF,
201
+ );
202
+ expect(result.kind).toBe('archivable');
203
+ });
204
+
205
+ it('an open issue mentioning a date in prose does not classify archivable', () => {
206
+ const result = classifySection(
207
+ sec(
208
+ '### Issue-006 — preflight resolves under the ambient env',
209
+ '- **Discovered:** 2026-06-29 (Codex-bridge overhaul).',
210
+ 'Upstream shipped their fix on 2026-07-01; ours still reproduces.',
211
+ '- **Status:** Open — **deferred** (cosmetic edge case; recorded, not fixed).',
212
+ ),
213
+ LATE_CUTOFF,
214
+ );
215
+ expect(result.kind).toBe('open');
216
+ });
217
+
218
+ // The pre-4.0.0 template seeded this EXACT example section; a pristine legacy deployment must
219
+ // not red its gate forever over our own blank. Only the exact heading+placeholder pair is inert.
220
+ it('the pristine pre-4.0.0 template seed classifies template-blank and keeps the gate green', async () => {
221
+ expect(
222
+ classifySection(
223
+ sec('### Issue-XXX — {{Title}}', '- **Resolved:** {{DATE}}', '- **Resolution:** {{what fixed it}}', '- **Commit:** {{SHA}}'),
224
+ LATE_CUTOFF,
225
+ ).kind,
226
+ ).toBe('template-blank');
227
+ const LEGACY = `---\ntype: reference\nlastUpdated: {{DATE}}\nscope: permanent\nstaleAfter: 30d\nowner: none\nmaxLines: 300\n---\n\n# Known Issues\n\n> Every bug we hit. Status, workaround, impact, plan. Avoids re-discovering pain.\n\n## 🔴 Open\n\n### Issue-001 — {{Title}}\n- **Discovered:** {{DATE}}\n- **Status:** Open\n- **Impact:** {{user-facing? dev-only? blocking?}}\n- **Workaround:** {{if any}}\n- **Plan:** {{next action}}\n- **Related files:** \`{{src/...}}\`\n\n## 🟢 Resolved\n\n### Issue-XXX — {{Title}}\n- **Resolved:** {{DATE}}\n- **Resolution:** {{what fixed it}}\n- **Commit:** {{SHA}}\n\n---\n\n> Resolved issues older than the window are rotated to \`history/issues-resolved.md\` by the issue-archive script.\n`;
228
+ const { runCli } = await import('./archive-issues.mjs');
229
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
230
+ try {
231
+ mkdirSync(join(dir, 'docs/ai'), { recursive: true });
232
+ writeFileSync(join(dir, 'docs/ai/known_issues.md'), LEGACY, 'utf8');
233
+ const logs = [];
234
+ expect(runCli(['--check', '--today=2026-07-28'], { root: dir, log: (m) => logs.push(m), logError: (m) => logs.push(m) })).toBe(0);
235
+ expect(logs.join('\n')).toContain('template-blank 1');
236
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
237
+ expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
238
+ expect(readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8')).toBe(LEGACY);
239
+ } finally {
240
+ rmSync(dir, { recursive: true, force: true });
241
+ }
242
+ });
243
+
244
+ it('an unfilled date placeholder in a REAL section stays loud, never a template blank', () => {
245
+ expect(classifySection(sec('### ~~Issue-055 — real struck~~', '- **Resolved:** {{DATE}} — what fixed it'), LATE_CUTOFF).kind).toBe('fixed-undated');
246
+ expect(classifySection(sec('### Issue-055 — real unstruck', '- **Resolved:** {{DATE}}'), LATE_CUTOFF).kind).toBe('fixed-undated');
247
+ });
248
+
249
+ // The blank's identity is the exact literal heading — no real issue carries `{{Title}}` — so a
250
+ // half-substituted blank (an agent dated the placeholders) still never enters the archive.
251
+ it('the legacy blank stays inert after an agent substitutes the date placeholders', async () => {
252
+ expect(
253
+ classifySection(sec('### Issue-XXX — {{Title}}', '- **Resolved:** 2026-05-12', '- **Resolution:** {{what fixed it}}'), LATE_CUTOFF).kind,
254
+ ).toBe('template-blank');
255
+ const LEGACY = `---\ntype: reference\nlastUpdated: {{DATE}}\nscope: permanent\nstaleAfter: 30d\nowner: none\nmaxLines: 300\n---\n\n# Known Issues\n\n> Every bug we hit.\n\n## 🔴 Open\n\n### Issue-001 — {{Title}}\n- **Discovered:** {{DATE}}\n- **Status:** Open\n\n## 🟢 Resolved\n\n### Issue-XXX — {{Title}}\n- **Resolved:** {{DATE}}\n- **Resolution:** {{what fixed it}}\n- **Commit:** {{SHA}}\n\n---\n\n> Resolved issues older than the window are rotated to \`history/issues-resolved.md\` by the issue-archive script.\n`;
256
+ const substituted = LEGACY.replaceAll('{{DATE}}', '2026-05-12');
257
+ const { runCli } = await import('./archive-issues.mjs');
258
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
259
+ try {
260
+ mkdirSync(join(dir, 'docs/ai'), { recursive: true });
261
+ writeFileSync(join(dir, 'docs/ai/known_issues.md'), substituted, 'utf8');
262
+ const logs = [];
263
+ expect(runCli(['--check', '--today=2026-07-28'], { root: dir, log: (m) => logs.push(m), logError: (m) => logs.push(m) })).toBe(0);
264
+ expect(logs.join('\n')).toContain('template-blank 1');
265
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
266
+ expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
267
+ } finally {
268
+ rmSync(dir, { recursive: true, force: true });
269
+ }
270
+ });
271
+
272
+ // Contradictory state refuses: Resolved-priority would silently archive a genuinely reopened
273
+ // issue, Status-priority would silently skip a resolved one forever — loud is the only
274
+ // direction that hides nothing.
275
+ it('contradictory open status and dated resolution marker classify conflict, in either order', () => {
276
+ expect(
277
+ classifySection(sec('### Issue-080 — forgot to flip', '- **Status:** Open', '- **Resolved:** 2026-04-10 — fixed.'), LATE_CUTOFF)
278
+ .kind,
279
+ ).toBe('conflict');
280
+ expect(
281
+ classifySection(sec('### Issue-081 — stale resolved', '- **Resolved:** 2026-04-10 — old note.', '- **Status:** Open — reopened'), LATE_CUTOFF)
282
+ .kind,
283
+ ).toBe('conflict');
284
+ });
285
+
286
+ it('emphasis and emoji variants of the resolution signal classify archivable', () => {
287
+ for (const value of ['**FIXED** (2026-04-10)', '✅ **FIXED** (2026-04-10)', '✅ **Resolved** (2026-04-10)']) {
288
+ const result = classifySection(sec('### ~~Issue-070 — emphasised~~', `- **Status:** ${value}`), CUTOFF);
289
+ expect(result.kind).toBe('archivable');
290
+ }
291
+ });
292
+
293
+ it('a struck heading with an explicit open status classifies open — strikethrough is cosmetic in both directions', () => {
294
+ expect(classifySection(sec('### ~~Issue-060 — reopened~~', '- **Status:** Open — reopened 2026-07-25'), LATE_CUTOFF).kind).toBe('open');
295
+ expect(classifySection(sec('### ~~Issue-061 — mitigated~~', '- **Status:** Mitigated forward in kit `1.8.0`'), LATE_CUTOFF).kind).toBe('open');
296
+ // A bare struck heading with NO status/resolved field stays a loud undated resolution claim.
297
+ expect(classifySection(sec('### ~~Issue-062 — bare struck~~', 'prose only.'), LATE_CUTOFF).kind).toBe('fixed-undated');
298
+ });
299
+
300
+ // A struck heading is itself a resolution signal: an UNRECOGNISED status value under it is a
301
+ // resolution claim with no recognisable date (Arm C loud) — while UNSTRUCK sections keep a free
302
+ // status vocabulary, so an allowlist can never false-red a legitimately open issue.
303
+ it('a struck heading with an unrecognised status is a loud undated claim, not silently open', () => {
304
+ expect(classifySection(sec('### ~~Issue-085 — closed word~~', '- **Status:** Closed — long ago'), LATE_CUTOFF).kind).toBe('fixed-undated');
305
+ expect(classifySection(sec('### ~~Issue-086 — done word~~', '- **Status:** DONE'), LATE_CUTOFF).kind).toBe('fixed-undated');
306
+ });
307
+
308
+ it('unstruck status vocabulary stays free — Deferred, Wontfix, Investigating classify open', () => {
309
+ for (const value of ['Deferred until the next release', 'Wontfix — by design', 'Investigating']) {
310
+ expect(classifySection(sec('### Issue-087 — free vocabulary', `- **Status:** ${value}`), LATE_CUTOFF).kind).toBe('open');
311
+ }
312
+ });
313
+
314
+ it('a resolved field with no recognisable date classifies fixed-undated, never open', () => {
315
+ expect(classifySection(sec('### Issue-051 — undated resolved field', '- **Resolved:** kit **1.43.0** shipped it'), CUTOFF).kind).toBe('fixed-undated');
316
+ expect(classifySection(sec('### Issue-052 — undated status', '- **Status:** ✅ FIXED'), CUTOFF).kind).toBe('fixed-undated');
317
+ });
318
+
319
+ it('an impossible or mixed-separator marker date classifies bad-date, never normalises', () => {
320
+ expect(classifySection(sec('### ~~Issue-053 — impossible~~', '- **Status:** ✅ FIXED (2026.02.30)'), CUTOFF).kind).toBe('bad-date');
321
+ expect(classifySection(sec('### Issue-054 — mixed separators', '- **Resolved:** 2026-07.20 — x'), CUTOFF).kind).toBe('bad-date');
322
+ expect(classifySection(sec('### Issue-055 — single digit', '- **Resolved:** 2026-7-2 — x'), CUTOFF).kind).toBe('bad-date');
323
+ });
324
+
325
+ it('a struck section whose only marker sits inside a fence is fixed-undated, never archivable', () => {
326
+ const text = `${FM}\n# Known Issues\n\n### ~~Issue-030 — teaches the marker form~~\n\n\`\`\`markdown\n- **Status:** ✅ FIXED (2026.04.10)\n\`\`\`\n`;
327
+ const { sections } = parseKnownIssues(text);
328
+ const section = sections.find((s) => s.structural === false);
329
+ expect(Boolean(section)).toBe(true);
330
+ expect(classifySection(section, CUTOFF).kind).toBe('fixed-undated');
331
+ });
332
+
333
+ // In the PACKAGE this file sits beside references/templates/ and the seed is asserted; the
334
+ // DEPLOYED copy runs in a consumer's scripts/ where no ../templates exists — a stated skip, not
335
+ // an ENOENT crash (the canon-side kit template-parity suite still pins the seed every run).
336
+ it('the shape seeded by references/templates/known_issues.md classifies archivable', { skip: !existsSync(resolve(TEMPLATES_DIR, 'known_issues.md')) && 'deployed copy: the template ships in the package, not at the consumer' }, () => {
337
+ const template = readFileSync(resolve(TEMPLATES_DIR, 'known_issues.md'), 'utf8');
338
+ const fence = /```markdown\n([\s\S]*?)\n```/.exec(template);
339
+ expect(Boolean(fence)).toBe(true);
340
+ const { sections } = parseKnownIssues(`${FM}\n# Known Issues\n\n${fence[1]}\n`);
341
+ const section = sections.find((s) => s.structural === false);
342
+ expect(Boolean(section)).toBe(true);
343
+ expect(classifySection(section, LATE_CUTOFF).kind).toBe('archivable');
344
+ });
345
+
346
+ // The teaching block lives in the file PREAMBLE (structural, no user insertion point) — a real
347
+ // section inserted directly under the bare `## 🟢 Resolved` heading archives ALONE, never
348
+ // carrying the instructions, categories or footer with it.
349
+ it('the full template rotates cleanly with a real issue inserted directly under the Resolved category', { skip: !existsSync(resolve(TEMPLATES_DIR, 'known_issues.md')) && 'deployed copy: the template ships in the package, not at the consumer' }, async () => {
350
+ const { runCli } = await import('./archive-issues.mjs');
351
+ const template = readFileSync(resolve(TEMPLATES_DIR, 'known_issues.md'), 'utf8').replaceAll('{{DATE}}', '2026-01-10');
352
+ const inserted = template.replace(
353
+ '## 🟢 Resolved\n',
354
+ '## 🟢 Resolved\n\n### ~~Issue-090 — real resolved~~\n- **Resolved:** 2026-01-15 — done.\n',
355
+ );
356
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
357
+ try {
358
+ mkdirSync(join(dir, 'docs/ai'), { recursive: true });
359
+ writeFileSync(join(dir, 'docs/ai/known_issues.md'), inserted, 'utf8');
360
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
361
+ const kept = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
362
+ expect(kept).toContain('```markdown');
363
+ expect(kept).toContain('## 🔴 Open');
364
+ expect(kept).toContain('## 🟢 Resolved');
365
+ expect(kept).toContain('> Resolved issues older than the window');
366
+ expect(kept).toContain('Issue-001');
367
+ expect(kept).not.toContain('Issue-090');
368
+ expect(readFileSync(join(dir, 'docs/ai/history/issues-resolved.md'), 'utf8')).toContain('Issue-090');
369
+ } finally {
370
+ rmSync(dir, { recursive: true, force: true });
371
+ }
372
+ });
373
+
374
+ // Lockstep pin: the closing note the template seeds is byte-for-byte the anchor the parser's
375
+ // footer rule recognises — a reworded template would silently un-anchor every fresh project.
376
+ it('the template closing note is the canonical footer anchor the parser recognises', { skip: !existsSync(resolve(TEMPLATES_DIR, 'known_issues.md')) && 'deployed copy: the template ships in the package, not at the consumer' }, async () => {
377
+ const { CANONICAL_FOOTER_NOTE } = await import('./archive-issues.mjs');
378
+ expect(typeof CANONICAL_FOOTER_NOTE).toBe('string');
379
+ const template = readFileSync(resolve(TEMPLATES_DIR, 'known_issues.md'), 'utf8');
380
+ expect(template).toContain(`> ${CANONICAL_FOOTER_NOTE}`);
381
+ });
382
+ });
383
+
384
+ // Phase 2 (the tokenizer contract): section boundaries are heading TOKENS — column-0 headings
385
+ // outside fences — and an ISSUE-shaped heading anywhere else is loud, never silently glued or
386
+ // silently split.
387
+ describe('sections split only at real heading tokens', () => {
388
+ const FENCE = '```';
389
+
390
+ it('a prose H3 section is never classified as an issue', () => {
391
+ const text = `${FM}\n# Known Issues\n\n### Issue-001 — real\n\nbody.\n\n### The 2026-07-02 upgrade-session misreport — closed\n\nprose section body.\n`;
392
+ const { sections } = parseKnownIssues(text);
393
+ const prose = sections.find((s) => s.heading !== null && /misreport/.test(s.heading));
394
+ expect(Boolean(prose)).toBe(true);
395
+ expect(classifySection(prose, CUTOFF).kind).toBe('other');
396
+ });
397
+
398
+ it('an issue-shaped heading inside a fence never starts a section', () => {
399
+ const text = `${FM}\n# Known Issues\n\n### Issue-001 — teaches the form\n\nWrite issues like:\n\n${FENCE}markdown\n### Issue-999 — a fenced sample\n${FENCE}\n\nend.\n`;
400
+ const { sections } = parseKnownIssues(text);
401
+ const issueSections = sections.filter((s) => s.heading !== null);
402
+ expect(issueSections).toHaveLength(1);
403
+ expect(issueSections[0].lines.join('\n')).toContain('Issue-999');
404
+ });
405
+
406
+ it('an unclosed fence refuses loudly naming its opening line', () => {
407
+ const text = `${FM}\n# Known Issues\n\n### Issue-001 — x\n\n${FENCE}markdown\n### Issue-002 — hidden\n`;
408
+ let threw = null;
409
+ try {
410
+ parseKnownIssues(text, 'docs/ai/known_issues.md');
411
+ } catch (err) {
412
+ threw = err;
413
+ }
414
+ expect(threw).not.toBeNull();
415
+ expect(threw.exitCode).toBe(1);
416
+ expect(threw.message).toMatch(/never closed/);
417
+ });
418
+
419
+ it('a wrong-level or indented issue heading refuses naming file and line', () => {
420
+ for (const bad of ['## Issue-020 — wrong level', '#### Issue-021 — too deep', ' ### Issue-022 — indented', '### Issue-123 — double space', '###\tIssue-124 — tab separated']) {
421
+ const text = `${FM}\n# Known Issues\n\n### Issue-001 — good\n\nbody.\n\n${bad}\n\norphan body.\n`;
422
+ let threw = null;
423
+ try {
424
+ parseKnownIssues(text, 'ki.md');
425
+ } catch (err) {
426
+ threw = err;
427
+ }
428
+ expect(threw).not.toBeNull();
429
+ expect(threw.exitCode).toBe(1);
430
+ expect(threw.message).toMatch(/^ki\.md:\d+:/);
431
+ }
432
+ });
433
+
434
+ it('a CRLF file classifies identically to its LF twin', () => {
435
+ const lf = `${FM}\n# Known Issues\n\n### ~~Issue-002 — struck~~\n\n**Status:** ✅ FIXED (2026.04.10)\n\n### Issue-003 — open\n\nbody.\n`;
436
+ const kinds = (text) =>
437
+ parseKnownIssues(text)
438
+ .sections.filter((s) => s.heading !== null)
439
+ .map((s) => classifySection(s, CUTOFF).kind);
440
+ expect(kinds(lf.replace(/\n/g, '\r\n'))).toEqual(kinds(lf));
441
+ });
442
+ });
443
+
444
+ // Arm B at the CLI seam. `runCli` is imported dynamically so this file still LOADS against an
445
+ // archiver that predates it — the tests then fail as honest reds instead of taking the whole
446
+ // suite down with a module-load error.
447
+ describe('reading modes agree on refusal and write nothing', () => {
448
+ const seedTree = (dir, text) => {
449
+ mkdirSync(join(dir, 'docs/ai'), { recursive: true });
450
+ writeFileSync(join(dir, 'docs/ai/known_issues.md'), text, 'utf8');
451
+ };
452
+ const malformed = `${FM}\n# Known Issues\n\n### Issue-001 — good\n\nbody.\n\n## Issue-020 — wrong level\n\norphan body.\n`;
453
+
454
+ for (const mode of [['--check'], ['--dry-run'], []]) {
455
+ it(`${JSON.stringify(mode)} refuses the same malformed heading with exit 1 and leaves the tree untouched`, async () => {
456
+ const { runCli } = await import('./archive-issues.mjs');
457
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
458
+ try {
459
+ seedTree(dir, malformed);
460
+ const before = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
461
+ const errs = [];
462
+ const code = runCli(mode, { root: dir, log: () => {}, logError: (m) => errs.push(m) });
463
+ expect(code).toBe(1);
464
+ expect(errs.join('\n')).toContain('docs/ai/known_issues.md:');
465
+ expect(readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8')).toBe(before);
466
+ expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
467
+ } finally {
468
+ rmSync(dir, { recursive: true, force: true });
469
+ }
470
+ });
471
+ }
472
+
473
+ it('the check verdict names the counts it acted on', async () => {
474
+ const { runCli } = await import('./archive-issues.mjs');
475
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
476
+ try {
477
+ seedTree(dir, `${FM}\n# Known Issues\n\n### Issue-001 — open one\n\n**Status:** Open\n\n### ~~Issue-002 — struck~~\n\n**Status:** ✅ FIXED (2026.05.23)\n`);
478
+ const logs = [];
479
+ const code = runCli(['--check', '--today=2026-05-24'], { root: dir, log: (m) => logs.push(m), logError: (m) => logs.push(m) });
480
+ expect(code).toBe(0);
481
+ const out = logs.join('\n');
482
+ expect(out).toContain('2 issue sections');
483
+ expect(out).toContain('0 archivable');
484
+ } finally {
485
+ rmSync(dir, { recursive: true, force: true });
486
+ }
487
+ });
488
+ });
489
+
490
+ // Arm C at the CLI seam: the predicate (a resolution claim without a recognisable date, or a
491
+ // malformed date) AND the disposition (exit 1, file:line, nothing written) — in every mode.
492
+ describe('a resolved section with no recognisable date is reported LOUDLY, never silently skipped', () => {
493
+ const seedTree = (dir, text) => {
494
+ mkdirSync(join(dir, 'docs/ai'), { recursive: true });
495
+ writeFileSync(join(dir, 'docs/ai/known_issues.md'), text, 'utf8');
496
+ };
497
+ const undated = `${FM}\n# Known Issues\n\n### ~~Issue-007 — struck, no recognisable date~~\n\n- **Status:** ✅ FIXED\n\n### Issue-008 — open\n\n- **Status:** Open\n`;
498
+
499
+ for (const mode of [['--check'], ['--dry-run'], []]) {
500
+ it(`${JSON.stringify(mode)} refuses the undated resolution claim with exit 1 and writes nothing`, async () => {
501
+ const { runCli } = await import('./archive-issues.mjs');
502
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
503
+ try {
504
+ seedTree(dir, undated);
505
+ const before = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
506
+ const errs = [];
507
+ const code = runCli(mode, { root: dir, log: () => {}, logError: (m) => errs.push(m) });
508
+ expect(code).toBe(1);
509
+ expect(errs.join('\n')).toMatch(/known_issues\.md:\d+/);
510
+ expect(errs.join('\n')).toContain('✅ FIXED');
511
+ expect(readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8')).toBe(before);
512
+ expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
513
+ } finally {
514
+ rmSync(dir, { recursive: true, force: true });
515
+ }
516
+ });
517
+ }
518
+
519
+ it('a struck unrecognised status refuses without writing', async () => {
520
+ const { runCli } = await import('./archive-issues.mjs');
521
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
522
+ try {
523
+ seedTree(dir, `${FM}\n# Known Issues\n\n### ~~Issue-085 — closed word~~\n\n- **Status:** Closed — long ago\n`);
524
+ const before = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
525
+ const errs = [];
526
+ expect(runCli(['--check'], { root: dir, log: () => {}, logError: (m) => errs.push(m) })).toBe(1);
527
+ expect(errs.join('\n')).toMatch(/known_issues\.md:\d+/);
528
+ expect(errs.join('\n')).toContain('Closed');
529
+ expect(readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8')).toBe(before);
530
+ expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
531
+ } finally {
532
+ rmSync(dir, { recursive: true, force: true });
533
+ }
534
+ });
535
+
536
+ it('a status/resolution conflict refuses naming both lines and writes nothing', async () => {
537
+ const { runCli } = await import('./archive-issues.mjs');
538
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
539
+ try {
540
+ seedTree(dir, `${FM}\n# Known Issues\n\n### Issue-080 — forgot to flip\n\n- **Status:** Open\n- **Resolved:** 2026-04-10 — fixed.\n`);
541
+ const before = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
542
+ const errs = [];
543
+ const code = runCli(['--check'], { root: dir, log: () => {}, logError: (m) => errs.push(m) });
544
+ expect(code).toBe(1);
545
+ const out = errs.join('\n');
546
+ expect(out).toMatch(/known_issues\.md:\d+/);
547
+ expect(out).toContain('**Resolved:** 2026-04-10');
548
+ expect(out).toContain('**Status:** Open');
549
+ expect(readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8')).toBe(before);
550
+ expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
551
+ } finally {
552
+ rmSync(dir, { recursive: true, force: true });
553
+ }
554
+ });
555
+
556
+ it('an impossible or mixed-separator marker date is refused, not normalised', async () => {
557
+ const { runCli } = await import('./archive-issues.mjs');
558
+ for (const [marker, token] of [
559
+ ['- **Status:** ✅ FIXED (2026.02.30)', '2026.02.30'],
560
+ ['- **Resolved:** 2026-07.20 — x', '2026-07.20'],
561
+ ]) {
562
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
563
+ try {
564
+ seedTree(dir, `${FM}\n# Known Issues\n\n### ~~Issue-009 — bad date~~\n\n${marker}\n`);
565
+ const before = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
566
+ const errs = [];
567
+ const code = runCli(['--check'], { root: dir, log: () => {}, logError: (m) => errs.push(m) });
568
+ expect(code).toBe(1);
569
+ expect(errs.join('\n')).toMatch(/known_issues\.md:\d+/);
570
+ expect(errs.join('\n')).toContain(token);
571
+ expect(readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8')).toBe(before);
572
+ expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
573
+ } finally {
574
+ rmSync(dir, { recursive: true, force: true });
575
+ }
576
+ }
577
+ });
578
+ });
579
+
580
+ describe('rotation end to end through runCli', () => {
581
+ const seedTree = (dir, text) => {
582
+ mkdirSync(join(dir, 'docs/ai'), { recursive: true });
583
+ writeFileSync(join(dir, 'docs/ai/known_issues.md'), text, 'utf8');
584
+ };
585
+ const MIXED = `${FM}\n# Known Issues\n\n### ~~Issue-001 — done long ago~~\n\n- **Status:** ✅ FIXED (2026.04.10)\n\n### Issue-002 — still open\n\n**Status:** Open\n`;
586
+
587
+ it('a default run archives the struck dated issue and keeps the open one', async () => {
588
+ const { runCli } = await import('./archive-issues.mjs');
589
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
590
+ try {
591
+ seedTree(dir, MIXED);
592
+ const code = runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} });
593
+ expect(code).toBe(0);
594
+ const resolved = readFileSync(join(dir, 'docs/ai/history/issues-resolved.md'), 'utf8');
595
+ expect(resolved).toContain('Issue-001');
596
+ const kept = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
597
+ expect(kept).toContain('Issue-002');
598
+ expect(kept).not.toContain('Issue-001');
599
+ expect(runCli(['--check', '--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
600
+ } finally {
601
+ rmSync(dir, { recursive: true, force: true });
602
+ }
603
+ });
604
+
605
+ it('check fails naming the archivable set; dry-run prints the plan and writes nothing', async () => {
606
+ const { runCli } = await import('./archive-issues.mjs');
607
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
608
+ try {
609
+ seedTree(dir, MIXED);
610
+ const errs = [];
611
+ expect(runCli(['--check', '--today=2026-07-28'], { root: dir, log: () => {}, logError: (m) => errs.push(m) })).toBe(1);
612
+ expect(errs.join('\n')).toContain('Issue-001');
613
+ const logs = [];
614
+ expect(runCli(['--dry-run', '--today=2026-07-28'], { root: dir, log: (m) => logs.push(m), logError: () => {} })).toBe(0);
615
+ expect(logs.join('\n')).toContain('archivable: 1');
616
+ expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
617
+ } finally {
618
+ rmSync(dir, { recursive: true, force: true });
619
+ }
620
+ });
621
+
622
+ it('a nothing-to-archive run names the counts; help 0, unknown argument 2, missing file 1', async () => {
623
+ const { runCli } = await import('./archive-issues.mjs');
624
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
625
+ try {
626
+ seedTree(dir, `${FM}\n# Known Issues\n\n### Issue-002 — still open\n\n**Status:** Open\n`);
627
+ const logs = [];
628
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: (m) => logs.push(m), logError: () => {} })).toBe(0);
629
+ expect(logs.join('\n')).toContain('nothing to archive');
630
+ expect(logs.join('\n')).toContain('1 issue sections');
631
+ const help = [];
632
+ expect(runCli(['--help'], { root: dir, log: (m) => help.push(m), logError: () => {} })).toBe(0);
633
+ expect(help.join('\n')).toContain('Usage');
634
+ const errs = [];
635
+ expect(runCli(['--wat'], { root: dir, log: () => {}, logError: (m) => errs.push(m) })).toBe(2);
636
+ expect(errs.join('\n')).toContain('unknown argument');
637
+ const missing = [];
638
+ const empty = mkdtempSync(join(tmpdir(), 'archive-issues-'));
639
+ try {
640
+ expect(runCli([], { root: empty, log: () => {}, logError: (m) => missing.push(m) })).toBe(1);
641
+ expect(missing.join('\n')).toContain('not found');
642
+ } finally {
643
+ rmSync(empty, { recursive: true, force: true });
644
+ }
645
+ } finally {
646
+ rmSync(dir, { recursive: true, force: true });
647
+ }
648
+ });
649
+ });
650
+
651
+ // Phase 3.1 at the write path: structural lines survive, and a rewrite is verbatim — nothing
652
+ // silently dropped OR duplicated (the L8 data-loss class, pinned as conservation).
653
+ describe('rotation keeps the file structure and conserves every line', () => {
654
+ const seedTree = (dir, text) => {
655
+ mkdirSync(join(dir, 'docs/ai'), { recursive: true });
656
+ writeFileSync(join(dir, 'docs/ai/known_issues.md'), text, 'utf8');
657
+ };
658
+ // today=2026-07-28 → cutoff 2026-07-15: Issue-102 and Issue-103 archivable, Issue-101 open.
659
+ const ROTATION_CUTOFF = new Date('2026-07-15T00:00:00Z');
660
+ const STRUCTURED = `${FM}\n# Known Issues\n\n> Every bug we hit.\n\n## 🔴 Open\n\n### Issue-101 — still open\n- **Discovered:** 2026-04-01\n- **Status:** Open\n\n### ~~Issue-102 — struck resolved~~\n- **Status:** ✅ FIXED (2026.04.10)\n\n## 🟢 Resolved\n\n### Issue-103 — unstruck resolved\n- **Resolved:** 2026-07-01 — fixed for good ([[AD-031]]).\n\n---\n\n> Resolved issues older than the window are rotated to \`history/issues-resolved.md\` by the issue-archive script.\n`;
661
+
662
+ it('the trailing separator and closing note survive a rotation that archives the last issue', async () => {
663
+ const { runCli } = await import('./archive-issues.mjs');
664
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
665
+ try {
666
+ seedTree(dir, STRUCTURED);
667
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
668
+ const kept = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
669
+ expect(kept).toContain('## 🔴 Open');
670
+ expect(kept).toContain('## 🟢 Resolved');
671
+ expect(kept).toContain('---');
672
+ expect(kept).toContain('> Resolved issues older than the window');
673
+ expect(kept).toContain('Issue-101');
674
+ expect(kept).not.toContain('Issue-102');
675
+ expect(kept).not.toContain('Issue-103');
676
+ const archive = readFileSync(join(dir, 'docs/ai/history/issues-resolved.md'), 'utf8');
677
+ expect(archive).toContain('Issue-102');
678
+ expect(archive).toContain('Issue-103');
679
+ expect(archive).not.toContain('## 🟢 Resolved');
680
+ } finally {
681
+ rmSync(dir, { recursive: true, force: true });
682
+ }
683
+ });
684
+
685
+ it('rotation conserves content — every input line lands in exactly one of kept file and archive', async () => {
686
+ const { runCli } = await import('./archive-issues.mjs');
687
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
688
+ try {
689
+ const parsed = parseKnownIssues(STRUCTURED);
690
+ const archivedChunks = parsed.sections.filter((s) => classifySection(s, ROTATION_CUTOFF).kind === 'archivable');
691
+ expect(archivedChunks).toHaveLength(2);
692
+ const keptExpected =
693
+ parsed.frontmatter +
694
+ parsed.sections
695
+ .filter((s) => classifySection(s, ROTATION_CUTOFF).kind !== 'archivable')
696
+ .flatMap((s) => s.lines)
697
+ .join('\n');
698
+ seedTree(dir, STRUCTURED);
699
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
700
+ const kept = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
701
+ expect(kept).toBe(keptExpected);
702
+ const archive = readFileSync(join(dir, 'docs/ai/history/issues-resolved.md'), 'utf8');
703
+ for (const chunk of archivedChunks) expect(archive).toContain(chunk.lines.join('\n'));
704
+ // The accounting reads the ACTUAL written files, never the parsed chunks — a writer that
705
+ // duplicated blocks or invented lines must fail here. Blank lines are the stated droppable
706
+ // decoration (the changelog conservation harness accounting); everything else is a multiset.
707
+ const header = buildResolvedFile('', [], '2026-07-28');
708
+ expect(archive.startsWith(header)).toBe(true);
709
+ const counts = (text) => {
710
+ const map = {};
711
+ for (const line of text.split('\n')) {
712
+ if (line.trim() === '') continue;
713
+ map[line] = (map[line] ?? 0) + 1;
714
+ }
715
+ return map;
716
+ };
717
+ const merged = counts(kept.slice(parsed.frontmatter.length));
718
+ for (const [line, n] of Object.entries(counts(archive.slice(header.length)))) {
719
+ merged[line] = (merged[line] ?? 0) + n;
720
+ }
721
+ expect(merged).toEqual(counts(STRUCTURED.slice(parsed.frontmatter.length)));
722
+ } finally {
723
+ rmSync(dir, { recursive: true, force: true });
724
+ }
725
+ });
726
+
727
+ it('a CRLF corpus rotates with exact CRLF separators in the archive', async () => {
728
+ const { runCli } = await import('./archive-issues.mjs');
729
+ const lf = `${FM}\n# Known Issues\n\n### ~~Issue-201 — first done~~\n- **Status:** ✅ FIXED (2026.04.10)\n\n### ~~Issue-202 — second done~~\n- **Status:** ✅ FIXED (2026.04.11)\n`;
730
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
731
+ try {
732
+ seedTree(dir, lf.replace(/\n/g, '\r\n'));
733
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
734
+ const archive = readFileSync(join(dir, 'docs/ai/history/issues-resolved.md'), 'utf8');
735
+ // The between-block separator is EXACTLY one CRLF blank line — no lone-CR lines, no
736
+ // mixed-EOL runs between the archived CRLF blocks.
737
+ expect(archive).toContain('- **Status:** ✅ FIXED (2026.04.10)\r\n\r\n### ~~Issue-202 — second done~~');
738
+ expect(/\n\r(?!\n)/.test(archive)).toBe(false);
739
+ expect(/\n{3,}/.test(archive.replace(/\r\n/g, '\n'))).toBe(false);
740
+ } finally {
741
+ rmSync(dir, { recursive: true, force: true });
742
+ }
743
+ });
744
+
745
+ it('a second rotation is a no-op — the rotated tree is a fixed point', async () => {
746
+ const { runCli } = await import('./archive-issues.mjs');
747
+ const dir = mkdtempSync(join(tmpdir(), 'archive-issues-'));
748
+ try {
749
+ seedTree(dir, STRUCTURED);
750
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
751
+ const keptOnce = readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8');
752
+ const archiveOnce = readFileSync(join(dir, 'docs/ai/history/issues-resolved.md'), 'utf8');
753
+ expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
754
+ expect(readFileSync(join(dir, 'docs/ai/known_issues.md'), 'utf8')).toBe(keptOnce);
755
+ expect(readFileSync(join(dir, 'docs/ai/history/issues-resolved.md'), 'utf8')).toBe(archiveOnce);
756
+ expect(runCli(['--check', '--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
757
+ } finally {
758
+ rmSync(dir, { recursive: true, force: true });
759
+ }
760
+ });
761
+ });
762
+
763
+ // The partition tripwire's own refusal contract — predicate AND disposition. Imported dynamically
764
+ // so this file still loads against an archiver that predates the export.
765
+ describe('the section-model partition tripwire', () => {
766
+ it('a partition that drops, duplicates or reorders lines refuses with a typed error naming the file', async () => {
767
+ const { verifySectionPartition } = await import('./archive-issues.mjs');
768
+ verifySectionPartition([{ heading: null, structural: true, lines: ['a', 'b'] }], ['a', 'b'], 'ki.md'); // exact partition passes silently
769
+ const corruptions = [
770
+ [[{ lines: ['a', 'a'] }], ['a', 'b']], // equal-cardinality: one line lost, another duplicated
771
+ [[{ lines: ['a'] }], ['a', 'b']], // dropped line
772
+ [[{ lines: ['b', 'a'] }], ['a', 'b']], // reordered
773
+ ];
774
+ for (const [sections, body] of corruptions) {
775
+ let threw = null;
776
+ try {
777
+ verifySectionPartition(sections, body, 'ki.md');
778
+ } catch (err) {
779
+ threw = err;
780
+ }
781
+ expect(threw).not.toBeNull();
782
+ expect(threw.exitCode).toBe(1);
783
+ expect(threw.message).toContain('ki.md');
784
+ }
68
785
  });
69
786
  });
70
787
 
@@ -81,6 +798,19 @@ describe('buildResolvedFile', () => {
81
798
  expect(result).toMatch(/### ~~Issue-001~~/);
82
799
  });
83
800
 
801
+ // Appending to an EXISTING CRLF archive: the separator follows the header's own EOL flavor and
802
+ // the trailing run collapses to one terminator — never a mixed `\r\n\n` run.
803
+ it('appending to an existing CRLF archive never mixes EOL runs', () => {
804
+ const existing = '---\r\ntype: history\r\n---\r\n\r\n# Resolved Issues\r\n\r\n### ~~Issue-000~~\r\n- **Status:** ✅ FIXED (2026.01.01)\r\n';
805
+ const result = buildResolvedFile(
806
+ existing,
807
+ [{ heading: '### ~~Issue-099~~', lines: ['### ~~Issue-099~~\r', '- **Resolved:** 2026-01-02 — done.\r', '\r'] }],
808
+ '2026-07-28',
809
+ );
810
+ expect(result).toContain('(2026.01.01)\r\n\r\n### ~~Issue-099~~');
811
+ expect(/\r\n\n|\n\n\n/.test(result)).toBe(false);
812
+ });
813
+
84
814
  it('appends new sections to existing content without re-emitting the header', () => {
85
815
  const existing = '---\ntype: history\nlastUpdated: 2026-04-01\nmaxLines: 3500\n---\n\n# Resolved Issues\n\n### ~~Issue-000~~\n\nold body.\n';
86
816
  const result = buildResolvedFile(