@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.
@@ -0,0 +1,39 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ // Structural pin (green by construction, a tripwire not a proof): all three archivers read
8
+ // documents through the ONE shared tokenizer. A fourth archiver — or a future edit that reaches
9
+ // for a quick raw-line regex — reds here instead of quietly reintroducing the class the tokenizer
10
+ // retired: private frontmatter regexes, per-raw-line heading scans, fence blindness. Modeled on
11
+ // the release-scan / doc-parity precedent of asserting over the sources themselves.
12
+
13
+ const DIR = dirname(fileURLToPath(import.meta.url));
14
+ const ARCHIVERS = ['archive-changelog.mjs', 'archive-issues.mjs', 'archive-decisions.mjs'];
15
+
16
+ describe('every archiver reads through the tokenizer — no raw scan survives', () => {
17
+ for (const name of ARCHIVERS) {
18
+ const source = readFileSync(resolve(DIR, name), 'utf8');
19
+
20
+ it(`${name} imports and calls tokenizeMarkdown`, () => {
21
+ assert.match(source, /import \{[^}]*tokenizeMarkdown[^}]*\} from '\.\/markdown-blocks\.mjs'/);
22
+ assert.match(source, /tokenizeMarkdown\(/);
23
+ });
24
+
25
+ it(`${name} carries no private frontmatter regex`, () => {
26
+ // The CRLF-fragile `/^(---\n[\s\S]*?\n---\n)/` was triplicated beside each archiver; the
27
+ // tokenizer is now its only home.
28
+ assert.doesNotMatch(source, /---\\n\[\\s\\S\]/);
29
+ });
30
+
31
+ it(`${name} never scans raw split lines for headings`, () => {
32
+ // The historical form: iterate `text.split('\n')` and regex-test each raw line. Both loop
33
+ // shapes that carried it are banned; heading recognition belongs to the tokenizer, unit
34
+ // grammars apply to `heading.text` tokens only.
35
+ assert.doesNotMatch(source, /\.forEach\(\(line/);
36
+ assert.doesNotMatch(source, /for \(const line of lines\)/);
37
+ });
38
+ }
39
+ });
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // The ONE block model the archivers read through.
3
+ //
4
+ // Every archiver used to scan `text.split('\n')` itself and test regexes against raw lines. That is
5
+ // one defect with many faces: a heading inside a fenced sample counts as real content, an unclosed
6
+ // fence hides the rest of the file, CRLF and trailing spaces break strict matches, and each archiver
7
+ // is wrong in its own way because each was fixed against only the inputs its author imagined. This
8
+ // module makes that one place instead of three.
9
+ //
10
+ // It is deliberately SMALL. It models exactly what the archivers must not get wrong:
11
+ // - frontmatter as the leading block, CRLF-safe
12
+ // - fenced regions (``` / ~~~), with an unclosed fence at EOF a LOUD error
13
+ // - ATX headings, emitted only OUTSIDE fences, matched on a trailing-whitespace-free view
14
+ // - which lines are fenced, so a caller can find a paragraph break without bisecting a fence
15
+ //
16
+ // It deliberately does NOT model: inline syntax, setext headings, indented code blocks, lists,
17
+ // blockquotes, HTML, tables, or nesting. Anything ambiguous REFUSES rather than guesses — fail-closed
18
+ // is the product, so "refuse" is a correct answer everywhere "parse" is hard.
19
+ //
20
+ // Line content is returned BYTE-EXACT. Normalisation applies only to the view used for matching, so
21
+ // re-emitting a block reproduces the file. Dependency-free, Node >= 22. No side effects on import.
22
+
23
+ export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
24
+
25
+ const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/;
26
+ // Up to three leading spaces, then a run of at least three backticks or tildes (CommonMark).
27
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
28
+ // Indented 1-3 spaces is STILL a heading in CommonMark. Emitting it matters: a consumer's unit
29
+ // grammar is anchored at column 0, so the token exists, fails that grammar, and is refused — where
30
+ // dropping it here would silently glue it into the block above. The separator is spaces OR tabs
31
+ // (CommonMark): a tab-separated heading must become a token for the same reason.
32
+ const ATX_HEADING_RE = /^ {0,3}(#{1,6})[ \t]+(.*)$/;
33
+ const BACKTICK = '`';
34
+
35
+ // The matching view of a line: trailing whitespace and any CR are invisible in rendered markdown, so
36
+ // they must not decide whether something is a heading. A file written on Windows parses identically.
37
+ const toMatchable = (line) => line.replace(/\s+$/, '');
38
+
39
+ // A fence closes only on a bare run of the SAME marker, at least as long as the opener. An info
40
+ // string ("```markdown") opens; a trailing info string never closes.
41
+ const closesFence = (matchable, open) => {
42
+ const match = FENCE_RE.exec(matchable);
43
+ if (!match) return false;
44
+ const [, marker, info] = match;
45
+ return marker[0] === open.char && marker.length >= open.length && info.trim() === '';
46
+ };
47
+
48
+ // A BACKTICK fence may not carry a backtick in its info string — otherwise a line of inline code
49
+ // (```a``` used inline) would open a fence and swallow every heading after it. Tilde fences have no
50
+ // such restriction, which is exactly why CommonMark distinguishes them.
51
+ const opensFence = (matchable) => {
52
+ const match = FENCE_RE.exec(matchable);
53
+ if (!match) return null;
54
+ const [, marker, info] = match;
55
+ if (marker[0] === BACKTICK && info.includes(BACKTICK)) return null;
56
+ return { char: marker[0], length: marker.length };
57
+ };
58
+
59
+ // Split a document into { frontmatter, lines, headings, fencedLines }.
60
+ //
61
+ // frontmatter the leading `---` block, byte-exact and possibly ''
62
+ // frontLines how many lines it occupies (so a caller can report 1-based file lines)
63
+ // lines the body, byte-exact, exactly as `split('\n')` would give
64
+ // headings [{ index, level, text, raw }] for every ATX heading OUTSIDE a fence
65
+ // fencedLines Set of body-line indexes inside a fence, fence markers included
66
+ //
67
+ // `label` names the source in refusals; pass the repo-relative path.
68
+ export const tokenizeMarkdown = (text, label = 'document') => {
69
+ const frontMatch = FRONTMATTER_RE.exec(text);
70
+ // A leading `---` is ambiguous: real frontmatter, or a thematic break whose next `---` is an entry
71
+ // separator — in which case everything between them would be swallowed and its headings hidden.
72
+ // REFUSE rather than pick, because both guesses are silently wrong in opposite directions: demoting
73
+ // real frontmatter (a YAML comment starts with `#`) leaves it in the body to be re-emitted twice,
74
+ // and promoting a thematic break hides content. The caller fixes it by making the file unambiguous.
75
+ if (frontMatch) {
76
+ const suspectAt = frontMatch[1]
77
+ .split('\n')
78
+ .findIndex((line) => ATX_HEADING_RE.test(toMatchable(line)) || FENCE_RE.test(toMatchable(line)));
79
+ if (suspectAt !== -1) {
80
+ throw fail(
81
+ 1,
82
+ `${label}:${suspectAt + 1}: the leading \`---\` block contains "${frontMatch[1].split('\n')[suspectAt]}", ` +
83
+ 'so it is either frontmatter holding a heading or fence, or a thematic break whose closing `---` ' +
84
+ 'belongs to the body — and the two are indistinguishable here. Separate them: keep frontmatter ' +
85
+ 'free of `#` and fence lines, or put a blank line and prose before the first `---`.',
86
+ );
87
+ }
88
+ }
89
+ const frontmatter = frontMatch ? frontMatch[1] : '';
90
+ const frontLines = frontmatter === '' ? 0 : frontmatter.split('\n').length - 1;
91
+ const lines = text.slice(frontmatter.length).split('\n');
92
+
93
+ const headings = [];
94
+ const fencedLines = new Set();
95
+ let open = null;
96
+
97
+ for (let index = 0; index < lines.length; index += 1) {
98
+ const matchable = toMatchable(lines[index]);
99
+
100
+ if (open) {
101
+ fencedLines.add(index);
102
+ if (closesFence(matchable, open)) open = null;
103
+ continue;
104
+ }
105
+
106
+ const fence = opensFence(matchable);
107
+ if (fence) {
108
+ open = { ...fence, index };
109
+ fencedLines.add(index);
110
+ continue;
111
+ }
112
+
113
+ const heading = ATX_HEADING_RE.exec(matchable);
114
+ if (heading) {
115
+ headings.push({ index, level: heading[1].length, text: matchable, raw: lines[index] });
116
+ }
117
+ }
118
+
119
+ if (open) {
120
+ // Left open, the remainder of the file silently stops being scanned: every later heading
121
+ // disappears and its text ends up inside whichever block precedes it, where a compressor that
122
+ // keeps only an opening paragraph will drop it — a loss that lands long after a green check.
123
+ throw fail(
124
+ 1,
125
+ `${label}:${frontLines + open.index + 1}: "${lines[open.index]}" opens a code fence that is ` +
126
+ 'never closed, so every heading after it is invisible and its text is silently absorbed. ' +
127
+ `Close it with a bare \`${open.char.repeat(open.length)}\`, then re-run.`,
128
+ );
129
+ }
130
+
131
+ return { frontmatter, frontLines, lines, headings, fencedLines };
132
+ };
133
+
134
+ // The index of the first blank line at or after `from` that is NOT inside a fence, or -1. Callers
135
+ // that split a block into "opening paragraph" and "rest" must use this rather than a bare blank-line
136
+ // scan: a fenced sample contains blank lines, and cutting there writes a half-fence into an archive
137
+ // that the next run cannot read.
138
+ export const findParagraphBreak = (lines, fencedLines, from = 0) => {
139
+ for (let index = from; index < lines.length; index += 1) {
140
+ if (lines[index].trim() === '' && !fencedLines.has(index)) return index;
141
+ }
142
+ return -1;
143
+ };
@@ -0,0 +1,310 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { tokenizeMarkdown, findParagraphBreak } from './markdown-blocks.mjs';
4
+
5
+ const FM = '---\ntype: history\nlastUpdated: 2026-07-28\nmaxLines: 700\n---\n';
6
+ const B = '```';
7
+ const T = '~~~';
8
+
9
+ describe('frontmatter', () => {
10
+ it('splits the leading block and counts its lines, leaving the body byte-exact', () => {
11
+ const { frontmatter, frontLines, lines } = tokenizeMarkdown(`${FM}\n# Doc\n\n## 2026-07-20 — a\n`);
12
+ assert.equal(frontmatter, FM);
13
+ assert.equal(frontLines, 5);
14
+ assert.equal(lines[1], '# Doc');
15
+ });
16
+
17
+ it('recognises CRLF frontmatter', () => {
18
+ const { frontmatter, frontLines } = tokenizeMarkdown(`${FM}\n# Doc\n`.replace(/\n/g, '\r\n'));
19
+ assert.match(frontmatter, /^---\r\n/);
20
+ assert.equal(frontLines, 5);
21
+ });
22
+
23
+ it('tolerates a document with no frontmatter at all', () => {
24
+ const { frontmatter, frontLines, headings } = tokenizeMarkdown('# Doc\n\n## 2026-07-20 — a\n');
25
+ assert.equal(frontmatter, '');
26
+ assert.equal(frontLines, 0);
27
+ assert.equal(headings.length, 2);
28
+ });
29
+
30
+ it('REFUSES a leading `---` block that is indistinguishable from a thematic break', () => {
31
+ assert.throws(
32
+ () => tokenizeMarkdown('---\n\n# Doc\n\n## 2026-07-20 — a\n\n---\n\n## 2026-07-19 — b\n', 'docs/ai/changelog.md'),
33
+ (err) => {
34
+ assert.equal(err.exitCode, 1);
35
+ assert.match(err.message, /docs\/ai\/changelog\.md:3:/);
36
+ return true;
37
+ },
38
+ );
39
+ });
40
+
41
+ it('REFUSES rather than silently demoting frontmatter that holds a YAML comment', () => {
42
+ // Demoting it would leave the real frontmatter in the body, so a rebuild writes a fresh one and
43
+ // the original is duplicated. Guessing the other way hides content. Both are silent; refuse.
44
+ assert.throws(
45
+ () => tokenizeMarkdown('---\n# pinned by AD-084\ntype: history\n---\n\n## 2026-07-20 — a\n', 'f.md'),
46
+ /f\.md:2:/,
47
+ );
48
+ });
49
+ });
50
+
51
+ describe('headings', () => {
52
+ it('reports level, index and a trailing-whitespace-free text, keeping raw byte-exact', () => {
53
+ const { headings } = tokenizeMarkdown(`${FM}\n## 2026-07-20 — a \n\n### deeper\n`);
54
+ assert.deepEqual(headings.map((h) => h.level), [2, 3]);
55
+ assert.equal(headings[0].text, '## 2026-07-20 — a');
56
+ assert.equal(headings[0].raw, '## 2026-07-20 — a ');
57
+ });
58
+
59
+ it('sees a heading that carries a CR, so a Windows file parses identically', () => {
60
+ const { headings } = tokenizeMarkdown(`${FM}\n## 2026-07-20\n`.replace(/\n/g, '\r\n'));
61
+ assert.equal(headings.length, 1);
62
+ assert.equal(headings[0].text, '## 2026-07-20');
63
+ });
64
+
65
+ it('emits an indented heading as a TOKEN so a column-0 grammar can refuse it loudly', () => {
66
+ const { headings } = tokenizeMarkdown(`${FM}\n## 2026-07-20 — a\n\n ## 2026-07-19 — indented\n`);
67
+ assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — a', ' ## 2026-07-19 — indented']);
68
+ });
69
+
70
+ it('does not treat a bare hash run without a space as a heading', () => {
71
+ const { headings } = tokenizeMarkdown(`${FM}\n##notaheading\n`);
72
+ assert.equal(headings.length, 0);
73
+ });
74
+
75
+ it('a tab after the hashes still makes a heading token (CommonMark allows spaces or tabs)', () => {
76
+ const { headings } = tokenizeMarkdown(`${FM}\n##\t2026-07-18 — tab separated\n`);
77
+ assert.deepEqual(headings.map((h) => h.level), [2]);
78
+ assert.equal(headings[0].text, '##\t2026-07-18 — tab separated');
79
+ });
80
+ });
81
+
82
+ describe('fences', () => {
83
+ it('hides headings inside a backtick fence', () => {
84
+ const { headings, fencedLines } = tokenizeMarkdown(
85
+ `${FM}\n## 2026-07-20 — real\n\n${B}markdown\n## 2026-07-19 — a sample\n${B}\n\nend.\n`,
86
+ );
87
+ assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
88
+ assert.equal(fencedLines.has(3), true);
89
+ assert.equal(fencedLines.has(5), true);
90
+ });
91
+
92
+ it('a backtick run whose info string contains a backtick is inline code, not a fence', () => {
93
+ const { headings } = tokenizeMarkdown(
94
+ `${FM}\n${B}a${B} used inline\n\n## 2026-07-20 — a real entry\n\nbody.\n\n${B}\nsample\n${B}\n`,
95
+ );
96
+ assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — a real entry']);
97
+ });
98
+
99
+ it('a tilde fence MAY carry backticks in its info string', () => {
100
+ const { headings } = tokenizeMarkdown(`${FM}\n${T}a${B}\n## 2026-07-19 — fenced\n${T}\n\n## 2026-07-20 — real\n`);
101
+ assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
102
+ });
103
+
104
+ it('closes only on the same marker, so a backtick run inside a tilde fence stays content', () => {
105
+ const { headings } = tokenizeMarkdown(
106
+ `${FM}\n## 2026-07-20 — real\n\n${T}markdown\n${B}\n## 2026-07-19 — nested sample\n${B}\n${T}\n\nend.\n`,
107
+ );
108
+ assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
109
+ });
110
+
111
+ it('needs a closer at least as long as the opener', () => {
112
+ const { headings } = tokenizeMarkdown(
113
+ `${FM}\n## 2026-07-20 — real\n\n\`\`\`\`\n${B}\n## 2026-07-19 — still fenced\n\`\`\`\`\n\nend.\n`,
114
+ );
115
+ assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
116
+ });
117
+
118
+ it('does not let an info string close a fence', () => {
119
+ const { headings } = tokenizeMarkdown(
120
+ `${FM}\n## 2026-07-20 — real\n\n${B}markdown\n${B}js\n## 2026-07-19 — still fenced\n${B}\n\nend.\n`,
121
+ );
122
+ assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
123
+ });
124
+
125
+ it('REFUSES an unclosed fence, naming the 1-based file line that opened it', () => {
126
+ let caught;
127
+ try {
128
+ tokenizeMarkdown(`${FM}\n## 2026-07-20 — real\n\n${B}markdown\n## 2026-07-19 — hidden\n`, 'docs/ai/changelog.md');
129
+ } catch (err) {
130
+ caught = err;
131
+ }
132
+ assert.ok(caught, 'expected a refusal');
133
+ assert.equal(caught.exitCode, 1);
134
+ assert.match(caught.message, /docs\/ai\/changelog\.md:9:/);
135
+ assert.match(caught.message, /never closed/);
136
+ });
137
+ });
138
+
139
+ describe('findParagraphBreak', () => {
140
+ it('skips blank lines that live inside a fence', () => {
141
+ const { lines, fencedLines } = tokenizeMarkdown(`${FM}\n## 2026-07-20 — a\n${B}\nx\n\ny\n${B}\n\nafter.\n`);
142
+ assert.equal(fencedLines.has(4), true);
143
+ assert.equal(findParagraphBreak(lines, fencedLines, 1), 7);
144
+ });
145
+
146
+ it('returns -1 when no unfenced blank line follows', () => {
147
+ const { lines, fencedLines } = tokenizeMarkdown(`${FM}\n## 2026-07-20 — a\nbody.\n`);
148
+ assert.equal(findParagraphBreak(lines, fencedLines, 4), -1);
149
+ });
150
+ });
151
+
152
+ // ── properties over generated documents ───────────────────────────────────────────────
153
+ //
154
+ // The generator builds each document AND its ground truth in the SAME pass, by construction: when
155
+ // it emits a fenced region it records those line indexes directly, never by re-deriving them with a
156
+ // regex. So the oracle is independent of the tokenizer's logic, and a disagreement is a real
157
+ // failure rather than two copies of the same mistake agreeing.
158
+ //
159
+ // It deliberately emits the shapes that shipped as bugs: unclosed fences mid-document, closers that
160
+ // are too short, closers carrying an info string, the other marker inside a fence, backtick inline
161
+ // code at column 0, indented headings, trailing whitespace, and both line endings. Seeded, so a
162
+ // failure replays from its seed alone.
163
+
164
+ const buildDocument = (seed) => {
165
+ let state = seed;
166
+ // Drawn from the HIGH bits: this LCG's low bit strictly alternates, so `state % 2` is fixed by
167
+ // call parity and every other two-apart draw correlates. With the low bits, a backtick fence
168
+ // carrying an info string was unreachable in every seed — a generator blind spot that looks like
169
+ // coverage.
170
+ const rand = (n) => {
171
+ state = (state * 1103515245 + 12345) % 2147483648;
172
+ return Math.floor(state / 65536) % n;
173
+ };
174
+
175
+ const eol = rand(2) === 0 ? '\n' : '\r\n';
176
+ const rows = [];
177
+ const fenced = new Set();
178
+ const headings = [];
179
+ let unclosedAt = null;
180
+
181
+ const pushHeading = () => {
182
+ const indent = ' '.repeat(rand(4));
183
+ const hashes = '#'.repeat(2 + rand(2));
184
+ const body = rand(2) === 0 ? `2026-07-2${rand(9)} — entry` : `2026-7-${rand(9)} — malformed`;
185
+ const text = `${indent}${hashes} ${body}`;
186
+ headings.push({ index: rows.length, text });
187
+ rows.push(text + ' '.repeat(rand(3)));
188
+ };
189
+
190
+ const pushFence = (allowUnclosed) => {
191
+ const char = rand(2) === 0 ? '`' : '~';
192
+ const length = 3 + rand(3);
193
+ const marker = char.repeat(length);
194
+ const info = rand(2) === 0 ? '' : 'markdown';
195
+ const openIndex = rows.length;
196
+ fenced.add(openIndex);
197
+ rows.push(`${marker}${info}` + ' '.repeat(rand(3)));
198
+
199
+ const inner = [
200
+ `## 2026-07-1${rand(9)} — fenced sample`,
201
+ '',
202
+ char.repeat(length - 1), // too short to close
203
+ `${marker}js`, // info string never closes
204
+ (char === '`' ? '~' : '`').repeat(length + 1), // the other marker never closes
205
+ 'plain fenced text',
206
+ ];
207
+ const innerCount = 1 + rand(inner.length);
208
+ for (let i = 0; i < innerCount; i += 1) {
209
+ fenced.add(rows.length);
210
+ rows.push(inner[(i + rand(inner.length)) % inner.length]);
211
+ }
212
+
213
+ if (allowUnclosed && rand(4) === 0) {
214
+ unclosedAt = openIndex;
215
+ return;
216
+ }
217
+ fenced.add(rows.length);
218
+ rows.push(char.repeat(length + rand(2)) + ' '.repeat(rand(3)));
219
+ };
220
+
221
+ const frontmatter = rand(2) === 0 ? FM.replace(/\n/g, eol) : '';
222
+ const segmentCount = 3 + rand(5);
223
+ // An unclosed fence may open ANYWHERE, not only last: the generator keeps emitting after it and
224
+ // marks the remainder fenced, so the refusal is exercised with real content behind it rather than
225
+ // at a convenient end-of-file.
226
+ for (let i = 0; i < segmentCount; i += 1) {
227
+ if (unclosedAt !== null) {
228
+ fenced.add(rows.length);
229
+ rows.push(rand(2) === 0 ? `## 2026-07-0${i} — hidden behind the open fence` : `trailing text ${i}`);
230
+ continue;
231
+ }
232
+ const kind = rand(5);
233
+ if (kind === 0) pushHeading();
234
+ else if (kind === 1) rows.push('');
235
+ else if (kind === 2) rows.push(`${B}inline${B} used in prose`);
236
+ else if (kind === 3) pushFence(true);
237
+ else rows.push(`body text ${i}`);
238
+ }
239
+
240
+ return { text: frontmatter + rows.join(eol) + eol, frontmatter, fenced, headings, unclosedAt };
241
+ };
242
+
243
+ describe('properties over generated documents', () => {
244
+ const SEEDS = 400;
245
+
246
+ it('the fenced-line set equals the oracle the generator recorded', () => {
247
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
248
+ const doc = buildDocument(seed);
249
+ if (doc.unclosedAt !== null) continue;
250
+ const { fencedLines } = tokenizeMarkdown(doc.text, `seed-${seed}`);
251
+ assert.deepEqual([...fencedLines].sort((a, b) => a - b), [...doc.fenced].sort((a, b) => a - b), `seed ${seed}`);
252
+ }
253
+ });
254
+
255
+ it('the heading tokens equal the oracle — index and text', () => {
256
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
257
+ const doc = buildDocument(seed);
258
+ if (doc.unclosedAt !== null) continue;
259
+ const { headings } = tokenizeMarkdown(doc.text, `seed-${seed}`);
260
+ assert.deepEqual(
261
+ headings.map((h) => ({ index: h.index, text: h.text })),
262
+ doc.headings,
263
+ `seed ${seed}`,
264
+ );
265
+ }
266
+ });
267
+
268
+ it('an unclosed fence always refuses, naming the line the generator opened it on', () => {
269
+ let covered = 0;
270
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
271
+ const doc = buildDocument(seed);
272
+ if (doc.unclosedAt === null) continue;
273
+ covered += 1;
274
+ const frontLines = doc.frontmatter === '' ? 0 : doc.frontmatter.split('\n').length - 1;
275
+ assert.throws(
276
+ () => tokenizeMarkdown(doc.text, `seed-${seed}`),
277
+ (err) => {
278
+ assert.equal(err.exitCode, 1, `seed ${seed}`);
279
+ assert.ok(
280
+ err.message.startsWith(`seed-${seed}:${frontLines + doc.unclosedAt + 1}:`),
281
+ `seed ${seed}: refusal names the wrong line — ${err.message.slice(0, 60)}`,
282
+ );
283
+ return true;
284
+ },
285
+ );
286
+ }
287
+ assert.ok(covered >= 10, `the generator must actually produce unclosed fences (got ${covered})`);
288
+ });
289
+
290
+ it('line endings never change the block structure', () => {
291
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
292
+ const doc = buildDocument(seed);
293
+ if (doc.unclosedAt !== null) continue;
294
+ const lf = doc.text.replace(/\r\n/g, '\n');
295
+ const a = tokenizeMarkdown(lf, `seed-${seed}`);
296
+ const b = tokenizeMarkdown(lf.replace(/\n/g, '\r\n'), `seed-${seed}`);
297
+ assert.deepEqual(a.headings.map((h) => h.text), b.headings.map((h) => h.text), `seed ${seed}`);
298
+ assert.deepEqual([...a.fencedLines], [...b.fencedLines], `seed ${seed}`);
299
+ }
300
+ });
301
+
302
+ it('the body is returned byte-exact — rejoining reproduces the input', () => {
303
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
304
+ const doc = buildDocument(seed);
305
+ if (doc.unclosedAt !== null) continue;
306
+ const { frontmatter, lines } = tokenizeMarkdown(doc.text, `seed-${seed}`);
307
+ assert.equal(frontmatter + lines.join('\n'), doc.text, `seed ${seed}`);
308
+ }
309
+ });
310
+ });
@@ -10,7 +10,9 @@ maxLines: 700
10
10
  # AI Session Changelog
11
11
 
12
12
  > One entry per session. Newest at the top. Entries roll off to `history/recent.md` (WARM) then `history/YYYY-MM.md` (COLD) via the archive script.
13
- > Heading format is load-bearing for rotation: `## YYYY.MM.DD — <title>`.
13
+ > Heading format is load-bearing for rotation: `## YYYY-MM-DD — <title>`. The rotator also still
14
+ > reads the legacy dotted form `## YYYY.MM.DD`, so existing archives keep working — but write ISO,
15
+ > the form every other date in this substrate uses, including `lastUpdated:` above.
14
16
 
15
17
  ## {{DATE}} — Bootstrap
16
18
 
@@ -11,6 +11,19 @@ maxLines: 300
11
11
 
12
12
  > Every bug we hit. Status, workaround, impact, plan. Avoids re-discovering pain.
13
13
 
14
+ When an issue is resolved, move its section under `## 🟢 Resolved` and REPLACE its
15
+ `- **Status:**` line with a line-leading ISO-dated `**Resolved:**` field — that one dated line is
16
+ what the archive script reads (the legacy `**Status:** ✅ FIXED (YYYY-MM-DD)` form is still read;
17
+ ~~strikethrough~~ on the heading is optional decoration). Keeping an open `Status:` line next to a
18
+ dated `Resolved:` line refuses loudly. Write it like this:
19
+
20
+ ```markdown
21
+ ### ~~Issue-042 — Example resolved issue~~
22
+ - **Resolved:** 2026-01-15 — what fixed it ([[AD-NNN]])
23
+ - **Resolution:** the fix, one line
24
+ - **Commit:** abc1234
25
+ ```
26
+
14
27
  ## 🔴 Open
15
28
 
16
29
  ### Issue-001 — {{Title}}
@@ -23,11 +36,6 @@ maxLines: 300
23
36
 
24
37
  ## 🟢 Resolved
25
38
 
26
- ### Issue-XXX — {{Title}}
27
- - **Resolved:** {{DATE}}
28
- - **Resolution:** {{what fixed it}}
29
- - **Commit:** {{SHA}}
30
-
31
39
  ---
32
40
 
33
41
  > Resolved issues older than the window are rotated to `history/issues-resolved.md` by the issue-archive script.