@sabaiway/agent-workflow-kit 4.5.0 → 5.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.
@@ -29,7 +29,7 @@ export const stop = (message, fields = {}) =>
29
29
  // ── registries ────────────────────────────────────────────────────────────────
30
30
 
31
31
  // The kit's OWN footprint — canonical anchored patterns. `/docs/ai/` subsumes the deployment stamp
32
- // (`.workflow-version`); the 8 enforcement scripts are enumerated (no bare `/scripts/` — a host repo
32
+ // (`.workflow-version`); the enforcement scripts are enumerated (no bare `/scripts/` — a host repo
33
33
  // may have unrelated scripts). `/.claude/settings.json` is carried HIDDEN-ONLY: in hidden mode the
34
34
  // kit's own attribution file is a footprint; in visible mode the kit commits it and never runs this
35
35
  // tool. It passes the same tracked→ASK classifier, so a project that already commits it gets an ASK,
@@ -43,13 +43,17 @@ export const KIT_OWN_PATHS = [
43
43
  '/scripts/_expect-shim.mjs',
44
44
  '/scripts/archive-changelog.mjs',
45
45
  '/scripts/archive-changelog.test.mjs',
46
+ '/scripts/archive-conservation.test.mjs',
46
47
  '/scripts/archive-decisions.mjs',
47
48
  '/scripts/archive-decisions.test.mjs',
48
49
  '/scripts/archive-issues.mjs',
49
50
  '/scripts/archive-issues.test.mjs',
51
+ '/scripts/archiver-structure.test.mjs',
50
52
  '/scripts/check-docs-size.mjs',
51
53
  '/scripts/check-docs-size.test.mjs',
52
54
  '/scripts/install-git-hooks.mjs',
55
+ '/scripts/markdown-blocks.mjs',
56
+ '/scripts/markdown-blocks.test.mjs',
53
57
  '/docs/plans/',
54
58
  '/.claude/settings.local.json',
55
59
  '/.claude/settings.json',
@@ -119,6 +119,26 @@ export const planScriptRefresh = (cwd, deps = {}) => {
119
119
  return out;
120
120
  };
121
121
 
122
+ // COMPANION seeds: modules the refreshed archivers IMPORT. The refresh above is deliberately
123
+ // directional (never ADDS a basename the consumer lacks), but refreshing an OLD deployment's
124
+ // archivers to this kit's canon without their runtime dependency would leave every refreshed
125
+ // script crashing on a missing `./markdown-blocks.mjs` import until a separate upgrade run — so
126
+ // the dependency rides the SAME apply, atomically, written before its importers.
127
+ const COMPANION_SEEDS = ['markdown-blocks.mjs', 'markdown-blocks.test.mjs'];
128
+ export const planCompanionSeeds = (cwd, refresh, deps = {}) => {
129
+ if (refresh.length === 0) return [];
130
+ const exists = deps.exists ?? existsSync;
131
+ const kitScripts = deps.kitScripts ?? KIT_SCRIPTS;
132
+ const consumerScripts = join(cwd, CONSUMER_SCRIPTS_REL);
133
+ const out = [];
134
+ for (const name of COMPANION_SEEDS) {
135
+ const canon = join(kitScripts, name);
136
+ const dst = join(consumerScripts, name);
137
+ if (exists(canon) && !exists(dst)) out.push({ name, canon, dst });
138
+ }
139
+ return out;
140
+ };
141
+
122
142
  const gitDirOf = (cwd, spawn) => {
123
143
  const r = spawn('git', ['rev-parse', '--absolute-git-dir'], { cwd, encoding: 'utf8' });
124
144
  return r && r.status === 0 && r.stdout ? r.stdout.trim() : null;
@@ -206,10 +226,15 @@ const applyScriptRefresh = (cwd, refresh, deps = {}) => {
206
226
  const read = deps.read ?? readFileSync;
207
227
  const chmod = deps.chmod ?? chmodSync;
208
228
  const stat = deps.stat ?? statSync;
209
- for (const { canon, dst, name } of refreshOrder(refresh)) {
229
+ // Companion modules FIRST (a dependency must land before its importers), refresh order after —
230
+ // the discriminator still last, so an interrupted apply always re-plans in full. Returns the
231
+ // seeded names (computed pre-write; recomputing after would see them present and report none).
232
+ const seeds = planCompanionSeeds(cwd, refresh, deps);
233
+ for (const { canon, dst, name } of [...seeds, ...refreshOrder(refresh)]) {
210
234
  writeContainedFileAtomic(cwd, dst, read(canon, 'utf8'), deps, { stop, label: `${CONSUMER_SCRIPTS_REL}/${name}` });
211
235
  chmod(dst, stat(canon).mode & 0o777); // the exec bit is the git-tracked axis the mirror guard pins
212
236
  }
237
+ return seeds.map((s) => s.name);
213
238
  };
214
239
 
215
240
  // ── the no-monolith crossing ─────────────────────────────────────────────────────
@@ -307,6 +332,7 @@ const crossWithoutMonolith = (cwd, args, stamp, { log, error, runMigrate, deps }
307
332
  log(` ${ADR_DIR_REL}/: ${hasStore ? 'present, but the crossing has not been completed' : 'absent'}`);
308
333
  log(` snapshot → ${preview.dir ? `${preview.dir} (${preview.viaGitDir ? 'git dir' : 'out-of-tree fallback'})` : 'NONE — no out-of-tree location; run inside a git repo (apply would refuse otherwise)'}`);
309
334
  log(` refresh ${refresh.length} enforcement script(s) to this kit's version${drifted.length ? ` (${drifted.length} locally differ: ${drifted.map((r) => r.name).join(', ')})` : ''}`);
335
+ for (const s of planCompanionSeeds(cwd, refresh, deps)) log(` seed companion module ${CONSUMER_SCRIPTS_REL}/${s.name} (imported by the refreshed archivers; absent at the consumer)`);
310
336
  log(` then seed the store: create ${ADR_DIR_REL}/, write ${NAV_REL} and regenerate docs/ai/index.md`);
311
337
  const code = preflight((m) => error(` ${m}`));
312
338
  if (code !== EXIT_OK) {
@@ -328,7 +354,7 @@ const crossWithoutMonolith = (cwd, args, stamp, { log, error, runMigrate, deps }
328
354
  const snapshot = writeSnapshot(cwd, refresh, stamp, deps);
329
355
  // The FULL refresh is re-planned and re-applied on every entry, so an interrupted one always
330
356
  // completes; the discriminator script is written last (see refreshOrder).
331
- applyScriptRefresh(cwd, refresh, deps);
357
+ const seededNames = applyScriptRefresh(cwd, refresh, deps);
332
358
 
333
359
  // Capture the index-regeneration verdict instead of matching log prose: the rotator logs a failed
334
360
  // regeneration and still returns 0, so "the gates are green" would not mean the index is fresh.
@@ -359,7 +385,7 @@ const crossWithoutMonolith = (cwd, args, stamp, { log, error, runMigrate, deps }
359
385
  // interrupted crossing whose scripts were already current, which no "old-scheme" claim covers.
360
386
  log('[migrate-adr-store] crossing complete — the one-file-per-ADR store is in place (no legacy monolith was present):');
361
387
  log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'}, ${snapshot.fileCount} file(s))`);
362
- log(` refreshed ${refresh.length} enforcement script(s) to this kit's version`);
388
+ log(` refreshed ${refresh.length} enforcement script(s) to this kit's version${seededNames.length ? ` + seeded ${seededNames.join(', ')}` : ''}`);
363
389
  log(` seeded ${ADR_DIR_REL}/ with ${NAV_REL} and regenerated docs/ai/index.md`);
364
390
  log(' next: run the normal upgrade (it re-stamps the deployment lineage to the current head),');
365
391
  log(' then review the migrated docs/ai/ tree and the re-stamp together and commit them yourself — this command never commits.');
@@ -394,6 +420,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
394
420
  log(` old layout: ${monoliths.join(', ')} (will be exploded into ${ADR_DIR_REL}/ then retired)`);
395
421
  log(` snapshot → ${preview.dir ? `${preview.dir} (${preview.viaGitDir ? 'git dir' : 'out-of-tree fallback'})` : 'NONE — no out-of-tree location; run inside a git repo (apply would refuse otherwise)'}`);
396
422
  log(` refresh ${refresh.length} enforcement script(s) to this kit's version${drifted.length ? ` (${drifted.length} locally differ: ${drifted.map((r) => r.name).join(', ')})` : ''}`);
423
+ for (const s of planCompanionSeeds(cwd, refresh, deps)) log(` seed companion module ${CONSUMER_SCRIPTS_REL}/${s.name} (imported by the refreshed archivers; absent at the consumer)`);
397
424
  log(' then the conservation-checked rotation:');
398
425
  // Surface the rotation's own exit code: a failed dry-run must NOT print the
399
426
  // "run with --apply" go-ahead nor exit 0 — it would send the user to --apply on an unsafe tree.
@@ -419,14 +446,14 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
419
446
  }
420
447
 
421
448
  const snapshot = writeSnapshot(cwd, refresh, stamp, deps);
422
- applyScriptRefresh(cwd, refresh, deps);
449
+ const seededNames = applyScriptRefresh(cwd, refresh, deps);
423
450
  const code = runMigrate(['--migrate', '--apply'], { root: cwd, log, logError: error });
424
451
  if (code !== EXIT_OK) {
425
452
  throw stop(`the rotation failed (exit ${code}) — the pre-migration snapshot is at ${snapshot.dir}; resolve the reported problem and re-run (the migration is idempotent).`);
426
453
  }
427
454
  log('[migrate-adr-store] migrated the 3-tier ADR cascade → one-file-per-ADR store:');
428
455
  log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'}, ${snapshot.fileCount} file(s))`);
429
- log(` refreshed ${refresh.length} enforcement script(s) to this kit's version`);
456
+ log(` refreshed ${refresh.length} enforcement script(s) to this kit's version${seededNames.length ? ` + seeded ${seededNames.join(', ')}` : ''}`);
430
457
  log(' next: run the normal upgrade (it re-stamps the deployment lineage to the current head),');
431
458
  log(' then review the migrated docs/ai/ tree and the re-stamp together and commit them yourself — this command never commits.');
432
459
  return EXIT_OK;