@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.
- package/CHANGELOG.md +20 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/upgrade.md +2 -2
- package/references/scripts/archive-changelog.mjs +300 -192
- package/references/scripts/archive-changelog.test.mjs +341 -0
- package/references/scripts/archive-conservation.test.mjs +466 -0
- package/references/scripts/archive-decisions.mjs +34 -17
- package/references/scripts/archive-decisions.test.mjs +93 -0
- package/references/scripts/archive-issues.mjs +344 -108
- package/references/scripts/archive-issues.test.mjs +762 -32
- package/references/scripts/archiver-structure.test.mjs +39 -0
- package/references/scripts/markdown-blocks.mjs +143 -0
- package/references/scripts/markdown-blocks.test.mjs +310 -0
- package/references/templates/changelog.md +3 -1
- package/references/templates/known_issues.md +13 -5
- package/tools/known-footprint.mjs +5 -1
- package/tools/migrate-adr-store.mjs +32 -5
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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;
|