@sabaiway/agent-workflow-memory 3.2.0 → 4.1.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 +114 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- 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 +374 -32
- package/references/scripts/archive-decisions.test.mjs +615 -2
- 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
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
// COLD (history/YYYY-MM.md) — entries older than WARM_DAYS, compressed
|
|
7
7
|
// META (history/condensed-index.md) — one-line TL;DRs of every archived entry
|
|
8
8
|
//
|
|
9
|
+
// The file is read through the shared block tokenizer (markdown-blocks.mjs): headings are
|
|
10
|
+
// recognised only OUTSIDE fenced regions, CRLF and trailing whitespace never change the block
|
|
11
|
+
// structure, and an unclosed fence is a loud error instead of a silent absorber. On top of the
|
|
12
|
+
// tokens this archiver applies its own unit grammar and FAILS CLOSED: a date-shaped heading that
|
|
13
|
+
// does not parse as an entry refuses with file:line — it is never glued into the previous entry,
|
|
14
|
+
// never duplicated into the footer, never normalised into a different calendar date.
|
|
15
|
+
//
|
|
9
16
|
// NOTE (multi-year scaling): condensed-index.md grows O(total archived entries),
|
|
10
17
|
// so on a multi-year horizon it approaches its cap (~1159 lines over 2y in a stress
|
|
11
18
|
// test). When it nears the cap, shard it per-year (condensed-index-YYYY.md) or switch
|
|
@@ -17,15 +24,18 @@
|
|
|
17
24
|
// --dry-run print planned distribution, do not change files
|
|
18
25
|
// --check exit 1 if changelog.md still holds entries that should be archived
|
|
19
26
|
//
|
|
27
|
+
// Every mode parses every source BEFORE any write, so a refusal fires identically for the
|
|
28
|
+
// default run, --dry-run and --check, and nothing is written on a refused input.
|
|
29
|
+
//
|
|
20
30
|
// CLI overrides:
|
|
21
|
-
// --hot-days=N (default
|
|
31
|
+
// --hot-days=N (default 3)
|
|
22
32
|
// --warm-days=N (default 30)
|
|
23
33
|
// --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
|
|
24
34
|
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import { dirname, resolve, relative, basename } from 'node:path';
|
|
35
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'node:fs';
|
|
36
|
+
import { dirname, resolve, basename } from 'node:path';
|
|
28
37
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
38
|
+
import { tokenizeMarkdown, findParagraphBreak, fail } from './markdown-blocks.mjs';
|
|
29
39
|
|
|
30
40
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
41
|
const __dirname = dirname(__filename);
|
|
@@ -42,92 +52,48 @@ const readProjectName = () => {
|
|
|
42
52
|
};
|
|
43
53
|
const PROJECT_NAME = readProjectName();
|
|
44
54
|
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
55
|
+
const CHANGELOG_REL = 'docs/ai/changelog.md';
|
|
56
|
+
const HISTORY_REL = 'docs/ai/history';
|
|
57
|
+
const RECENT_REL = 'docs/ai/history/recent.md';
|
|
58
|
+
const LEGACY_REL = 'docs/ai/changelog-archive.md';
|
|
49
59
|
|
|
50
60
|
const DEFAULT_HOT_DAYS = 3;
|
|
51
61
|
const DEFAULT_WARM_DAYS = 30;
|
|
52
62
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
53
63
|
|
|
54
|
-
|
|
64
|
+
// Both separators are ACCEPTED on read: deployed archives on disk are dotted, while every other
|
|
65
|
+
// date surface in the family — `lastUpdated:` included — is ISO. The backreference forbids a mixed
|
|
66
|
+
// form (`2026-07.20`). ISO is what gets WRITTEN into new templates; re-emission preserves each
|
|
67
|
+
// entry's source form verbatim.
|
|
68
|
+
const ENTRY_HEADING_RE = /^## (\d{4})([.-])(\d{2})\2(\d{2})(?: [—–] (.*))?$/;
|
|
69
|
+
// Kept exactly as shipped (Decision 3, L5): once the loud path exists this pattern is inert on the
|
|
70
|
+
// entry side — an entry heading never reaches it — and it still names what a footer boundary IS.
|
|
71
|
+
// Widening it was proven to convert a visible defect into a silent one; do not touch it.
|
|
55
72
|
const NON_ENTRY_H2_RE = /^## (?!\d{4}\.\d{2}\.\d{2})/;
|
|
73
|
+
// Unit-shape: a heading whose text begins with a plausible date attempt — deliberately WIDER than
|
|
74
|
+
// the entry grammar, at ANY heading level and indent, so `### 2026-07-20`, ` ## 2026.07.20`,
|
|
75
|
+
// `## 2026-07` and `## 20260720` all refuse loudly instead of being absorbed, while a prose
|
|
76
|
+
// heading that merely starts with a year (`## 2026 vision`) stays prose.
|
|
77
|
+
const DATE_LIKE_RE = /^\s*#{1,6}[ \t]+\d{4}[./-]?\d/;
|
|
78
|
+
|
|
79
|
+
const USAGE =
|
|
80
|
+
'Usage: archive-changelog.mjs [--dry-run|--check] [--hot-days=N] [--warm-days=N] [--today=YYYY-MM-DD]';
|
|
56
81
|
|
|
57
82
|
const parseArgs = (argv) => {
|
|
58
|
-
const flags = { dryRun: false, check: false };
|
|
83
|
+
const flags = { dryRun: false, check: false, help: false };
|
|
59
84
|
const opts = { hotDays: DEFAULT_HOT_DAYS, warmDays: DEFAULT_WARM_DAYS, today: null };
|
|
60
|
-
for (const arg of argv
|
|
85
|
+
for (const arg of argv) {
|
|
61
86
|
if (arg === '--dry-run') flags.dryRun = true;
|
|
62
87
|
else if (arg === '--check') flags.check = true;
|
|
88
|
+
else if (arg === '--help' || arg === '-h') flags.help = true;
|
|
63
89
|
else if (arg.startsWith('--hot-days=')) opts.hotDays = Number(arg.slice('--hot-days='.length));
|
|
64
90
|
else if (arg.startsWith('--warm-days=')) opts.warmDays = Number(arg.slice('--warm-days='.length));
|
|
65
91
|
else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
|
|
66
|
-
else
|
|
67
|
-
console.log(
|
|
68
|
-
'Usage: archive-changelog.mjs [--dry-run|--check] [--hot-days=N] [--warm-days=N] [--today=YYYY-MM-DD]',
|
|
69
|
-
);
|
|
70
|
-
process.exit(0);
|
|
71
|
-
} else {
|
|
72
|
-
console.error(`Unknown argument: ${arg}`);
|
|
73
|
-
process.exit(2);
|
|
74
|
-
}
|
|
92
|
+
else throw fail(2, `unknown argument: ${arg}\n${USAGE}`);
|
|
75
93
|
}
|
|
76
94
|
return { flags, opts };
|
|
77
95
|
};
|
|
78
96
|
|
|
79
|
-
export const parseChangelogText = (text) => {
|
|
80
|
-
const fmMatch = text.match(/^(---\n[\s\S]*?\n---\n)/);
|
|
81
|
-
const frontmatter = fmMatch ? fmMatch[1] : '';
|
|
82
|
-
const rest = text.slice(frontmatter.length);
|
|
83
|
-
const lines = rest.split('\n');
|
|
84
|
-
|
|
85
|
-
const entryStartIdxs = [];
|
|
86
|
-
let firstNonEntryH2Idx = -1;
|
|
87
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
88
|
-
if (ENTRY_HEADING_RE.test(lines[i])) {
|
|
89
|
-
entryStartIdxs.push(i);
|
|
90
|
-
} else if (
|
|
91
|
-
firstNonEntryH2Idx === -1 &&
|
|
92
|
-
entryStartIdxs.length > 0 &&
|
|
93
|
-
NON_ENTRY_H2_RE.test(lines[i])
|
|
94
|
-
) {
|
|
95
|
-
// Only treat a non-entry H2 as the footer boundary if it appears AFTER at least one date
|
|
96
|
-
// entry. Otherwise a previously-inserted "## History" pointer in the preamble would be
|
|
97
|
-
// mis-detected and cause every entry to be slurped into `footer`.
|
|
98
|
-
firstNonEntryH2Idx = i;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const preambleEnd = entryStartIdxs.length > 0 ? entryStartIdxs[0] : lines.length;
|
|
103
|
-
const preamble = lines.slice(0, preambleEnd).join('\n');
|
|
104
|
-
|
|
105
|
-
const entries = entryStartIdxs.map((idx, i) => {
|
|
106
|
-
const isFollowedByEntry = i + 1 < entryStartIdxs.length;
|
|
107
|
-
const tentativeEnd = isFollowedByEntry
|
|
108
|
-
? entryStartIdxs[i + 1]
|
|
109
|
-
: firstNonEntryH2Idx !== -1 && firstNonEntryH2Idx > idx
|
|
110
|
-
? firstNonEntryH2Idx
|
|
111
|
-
: lines.length;
|
|
112
|
-
const block = lines.slice(idx, tentativeEnd).join('\n').replace(/\n+$/, '');
|
|
113
|
-
const cleanedBlock = stripTrailingSeparator(block);
|
|
114
|
-
const m = ENTRY_HEADING_RE.exec(lines[idx]);
|
|
115
|
-
return {
|
|
116
|
-
dateStr: `${m[1]}.${m[2]}.${m[3]}`,
|
|
117
|
-
dateObj: new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00Z`),
|
|
118
|
-
year: m[1],
|
|
119
|
-
month: m[2],
|
|
120
|
-
day: m[3],
|
|
121
|
-
title: m[4] ?? '',
|
|
122
|
-
block: cleanedBlock,
|
|
123
|
-
};
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
const footer = firstNonEntryH2Idx !== -1 ? lines.slice(firstNonEntryH2Idx).join('\n').trim() : '';
|
|
127
|
-
|
|
128
|
-
return { frontmatter, preamble: preamble.trim(), entries, footer };
|
|
129
|
-
};
|
|
130
|
-
|
|
131
97
|
const TRAILING_FOOTER_PATTERNS = [
|
|
132
98
|
/^\*\*Last Updated:/i,
|
|
133
99
|
];
|
|
@@ -143,6 +109,95 @@ export const stripTrailingSeparator = (block) => {
|
|
|
143
109
|
return lines.join('\n');
|
|
144
110
|
};
|
|
145
111
|
|
|
112
|
+
// Parse one tier's text → { frontmatter, preamble, entries, footer }. Fails closed: any
|
|
113
|
+
// unit-shaped heading that does not parse as an entry is exit 1 naming `label:line`, and an entry
|
|
114
|
+
// appearing after the footer boundary refuses rather than being duplicated into the footer.
|
|
115
|
+
export const parseChangelogText = (text, label = 'changelog') => {
|
|
116
|
+
const { frontmatter, frontLines, lines, headings } = tokenizeMarkdown(text, label);
|
|
117
|
+
const fileLine = (index) => frontLines + index + 1;
|
|
118
|
+
|
|
119
|
+
const entryHeadings = [];
|
|
120
|
+
let footerIdx = -1;
|
|
121
|
+
for (const heading of headings) {
|
|
122
|
+
const m = ENTRY_HEADING_RE.exec(heading.text);
|
|
123
|
+
if (m) {
|
|
124
|
+
const [, year, , month, day] = m;
|
|
125
|
+
const dateObj = new Date(`${year}-${month}-${day}T00:00:00Z`);
|
|
126
|
+
const roundTrips =
|
|
127
|
+
dateObj.getUTCFullYear() === Number(year) &&
|
|
128
|
+
dateObj.getUTCMonth() + 1 === Number(month) &&
|
|
129
|
+
dateObj.getUTCDate() === Number(day);
|
|
130
|
+
if (!roundTrips) {
|
|
131
|
+
throw fail(
|
|
132
|
+
1,
|
|
133
|
+
`${label}:${fileLine(heading.index)}: "${heading.text}" is date-shaped but ` +
|
|
134
|
+
`${year}-${month}-${day} is not a real calendar date — it would previously have been ` +
|
|
135
|
+
'silently normalised into a different month; fix the date, then re-run.',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (footerIdx !== -1) {
|
|
139
|
+
throw fail(
|
|
140
|
+
1,
|
|
141
|
+
`${label}:${fileLine(heading.index)}: entry heading "${heading.text}" appears after the ` +
|
|
142
|
+
`footer boundary at line ${fileLine(footerIdx)} — the footer must be the last section; ` +
|
|
143
|
+
'it would previously have been silently duplicated into the footer; move the entry ' +
|
|
144
|
+
'above the footer, then re-run.',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
entryHeadings.push({ index: heading.index, match: m });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (DATE_LIKE_RE.test(heading.text)) {
|
|
151
|
+
throw fail(
|
|
152
|
+
1,
|
|
153
|
+
`${label}:${fileLine(heading.index)}: "${heading.text}" is date-shaped but does not parse ` +
|
|
154
|
+
'as an entry heading — expected `## YYYY-MM-DD — title` or `## YYYY.MM.DD — title` at ' +
|
|
155
|
+
'column 0 with a real calendar date. It would previously have been silently absorbed ' +
|
|
156
|
+
'into the previous entry; fix the heading, then re-run.',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (heading.level === 2 && entryHeadings.length > 0 && footerIdx === -1 && NON_ENTRY_H2_RE.test(heading.text)) {
|
|
160
|
+
// Only a non-entry H2 AFTER at least one date entry is the footer boundary. Otherwise a
|
|
161
|
+
// previously-inserted "## History" pointer in the preamble would be mis-detected and cause
|
|
162
|
+
// every entry to be slurped into `footer`.
|
|
163
|
+
footerIdx = heading.index;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const preambleEnd = entryHeadings.length > 0 ? entryHeadings[0].index : lines.length;
|
|
168
|
+
// The trailing `---` before the entries block is the BUILDER's structural separator, not
|
|
169
|
+
// preamble content — keeping it made every rebuild of an archive-less tree add one more.
|
|
170
|
+
const preamble = stripTrailingSeparator(lines.slice(0, preambleEnd).join('\n')).trim();
|
|
171
|
+
|
|
172
|
+
const entries = entryHeadings.map(({ index, match }, i) => {
|
|
173
|
+
const end =
|
|
174
|
+
i + 1 < entryHeadings.length
|
|
175
|
+
? entryHeadings[i + 1].index
|
|
176
|
+
: footerIdx !== -1
|
|
177
|
+
? footerIdx
|
|
178
|
+
: lines.length;
|
|
179
|
+
const block = stripTrailingSeparator(lines.slice(index, end).join('\n'));
|
|
180
|
+
const [, year, separator, month, day, title] = match;
|
|
181
|
+
return {
|
|
182
|
+
// dateStr is the DEDUPE IDENTITY and the grouping key, so it stays separator-insensitive —
|
|
183
|
+
// one entry written both ways collapses to one. dateSource preserves what the file said, and
|
|
184
|
+
// is what gets RENDERED, so an index line matches the heading it links to.
|
|
185
|
+
dateStr: `${year}.${month}.${day}`,
|
|
186
|
+
dateSource: `${year}${separator}${month}${separator}${day}`,
|
|
187
|
+
dateObj: new Date(`${year}-${month}-${day}T00:00:00Z`),
|
|
188
|
+
year,
|
|
189
|
+
month,
|
|
190
|
+
day,
|
|
191
|
+
title: title ?? '',
|
|
192
|
+
block,
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const footer = footerIdx !== -1 ? lines.slice(footerIdx).join('\n').trim() : '';
|
|
197
|
+
|
|
198
|
+
return { frontmatter, preamble, entries, footer };
|
|
199
|
+
};
|
|
200
|
+
|
|
146
201
|
export const stripBlockquoteHistoryNotice = (preamble) => {
|
|
147
202
|
const filtered = preamble
|
|
148
203
|
.split('\n')
|
|
@@ -195,18 +250,33 @@ export const categorize = (entries, cutoffs) => {
|
|
|
195
250
|
};
|
|
196
251
|
|
|
197
252
|
export const compressEntry = (entry) => {
|
|
198
|
-
|
|
253
|
+
// The block came out of a tokenized document, so its fences are balanced by construction; the
|
|
254
|
+
// paragraph split below is fence-aware, so compression can no longer cut a fenced block in half
|
|
255
|
+
// and write an archive the next run refuses.
|
|
256
|
+
const { lines, fencedLines } = tokenizeMarkdown(entry.block, 'entry block');
|
|
199
257
|
const heading = lines[0];
|
|
200
258
|
const body = lines.slice(1).join('\n');
|
|
201
259
|
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
260
|
+
const paragraphs = [];
|
|
261
|
+
let cursor = 1;
|
|
262
|
+
while (cursor < lines.length) {
|
|
263
|
+
const brk = findParagraphBreak(lines, fencedLines, cursor);
|
|
264
|
+
const end = brk === -1 ? lines.length : brk;
|
|
265
|
+
const para = lines.slice(cursor, end).join('\n').trim();
|
|
266
|
+
if (para !== '') paragraphs.push(para);
|
|
267
|
+
cursor = end + 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A previously-generated `**Result:**` line is never summary material and is PRESERVED as the
|
|
271
|
+
// metric below instead of being re-extracted — re-harvesting metrics from our own output made
|
|
272
|
+
// every COLD rewrite append another copy (`12 tests` → `12 tests, 12 tests`).
|
|
273
|
+
const isResultLine = (p) => /^\*\*Result:\*\*/.test(p);
|
|
274
|
+
const summary =
|
|
275
|
+
paragraphs.find(
|
|
276
|
+
(p) => !p.startsWith('#') && !isResultLine(p) && /^(\*\*Goal|\*\*Problem|\*\*Context|\*\*Why|\*\*Session)/i.test(p),
|
|
277
|
+
) ??
|
|
278
|
+
paragraphs.find((p) => !p.startsWith('#') && !isResultLine(p)) ??
|
|
279
|
+
'';
|
|
210
280
|
|
|
211
281
|
const extractFileBullets = (text) => {
|
|
212
282
|
const filesSectionMatch = text.match(/\*\*(?:Changes|Files|Files touched|Files changed|Touched)[^\n]*\*\*([\s\S]*?)(?:\n\s*\n|\n##|$)/i);
|
|
@@ -225,16 +295,23 @@ export const compressEntry = (entry) => {
|
|
|
225
295
|
return `**Result:** ${metricsMatch.slice(0, 3).join(', ')}`;
|
|
226
296
|
};
|
|
227
297
|
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
298
|
+
const existingResult = paragraphs.find(isResultLine);
|
|
299
|
+
const metric = existingResult ?? extractMetric(body);
|
|
300
|
+
|
|
301
|
+
// The writer never emits what the reader refuses, by construction: the summary is whole
|
|
302
|
+
// fence-aware paragraphs (balanced fences), bullet lines and the metric line cannot open a
|
|
303
|
+
// fence, and the heading is a heading. The harness's self-consumption property pins this, and
|
|
304
|
+
// the WARM/COLD byte-fixed-point property pins that re-compression changes nothing.
|
|
305
|
+
return [heading, '', summary, extractFileBullets(body), metric]
|
|
306
|
+
.filter(Boolean)
|
|
307
|
+
.join('\n\n')
|
|
308
|
+
.trim();
|
|
233
309
|
};
|
|
234
310
|
|
|
235
311
|
const summarizeEntry = (entry, sourceLink) => {
|
|
236
312
|
const titleSnippet = entry.title.slice(0, 110).replace(/\n/g, ' ');
|
|
237
|
-
|
|
313
|
+
// Render the form the entry was WRITTEN in, so an index line matches the heading it links to.
|
|
314
|
+
return `- **${entry.dateSource ?? entry.dateStr}** — ${titleSnippet} — [${sourceLink}](./${sourceLink})`;
|
|
238
315
|
};
|
|
239
316
|
|
|
240
317
|
const renderEntries = (entries) =>
|
|
@@ -314,125 +391,156 @@ export const groupByMonth = (entries) => {
|
|
|
314
391
|
return map;
|
|
315
392
|
};
|
|
316
393
|
|
|
317
|
-
const
|
|
318
|
-
const {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
394
|
+
export const runCli = (argv, deps = {}) => {
|
|
395
|
+
const { root = ROOT, log = console.log, logError = console.error } = deps;
|
|
396
|
+
try {
|
|
397
|
+
const { flags, opts } = parseArgs(argv);
|
|
398
|
+
if (flags.help) {
|
|
399
|
+
log(USAGE);
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
402
|
+
const cutoffs = computeCutoffs(opts.today, opts.hotDays, opts.warmDays);
|
|
403
|
+
const todayStr = cutoffs.today.toISOString().slice(0, 10);
|
|
404
|
+
|
|
405
|
+
const changelogPath = resolve(root, CHANGELOG_REL);
|
|
406
|
+
const historyDir = resolve(root, HISTORY_REL);
|
|
407
|
+
const recentPath = resolve(root, RECENT_REL);
|
|
408
|
+
const indexPath = resolve(historyDir, 'condensed-index.md');
|
|
409
|
+
const legacyPath = resolve(root, LEGACY_REL);
|
|
410
|
+
|
|
411
|
+
if (!existsSync(changelogPath)) {
|
|
412
|
+
logError(`[archive-changelog] ${CHANGELOG_REL} not found — nothing to do.`);
|
|
413
|
+
return 1;
|
|
414
|
+
}
|
|
332
415
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
416
|
+
// EVERY mode parses EVERY source before any write: a refusal in the main file, the legacy
|
|
417
|
+
// archive, recent.md or a monthly COLD file fires identically for the default run, --dry-run
|
|
418
|
+
// and --check, naming that file's own file:line — and nothing is written.
|
|
419
|
+
const perFile = {};
|
|
420
|
+
const parsed = parseChangelogText(readFileSync(changelogPath, 'utf8'), CHANGELOG_REL);
|
|
421
|
+
perFile[CHANGELOG_REL] = parsed.entries.length;
|
|
422
|
+
|
|
423
|
+
let legacyEntries = [];
|
|
424
|
+
if (existsSync(legacyPath)) {
|
|
425
|
+
legacyEntries = parseChangelogText(readFileSync(legacyPath, 'utf8'), LEGACY_REL).entries;
|
|
426
|
+
perFile[LEGACY_REL] = legacyEntries.length;
|
|
427
|
+
}
|
|
428
|
+
let warmExistingEntries = [];
|
|
429
|
+
if (existsSync(recentPath)) {
|
|
430
|
+
warmExistingEntries = parseChangelogText(readFileSync(recentPath, 'utf8'), RECENT_REL).entries;
|
|
431
|
+
perFile[RECENT_REL] = warmExistingEntries.length;
|
|
432
|
+
}
|
|
433
|
+
const coldExistingEntries = [];
|
|
434
|
+
if (existsSync(historyDir)) {
|
|
435
|
+
for (const name of readdirSync(historyDir)) {
|
|
436
|
+
if (!/^\d{4}-\d{2}\.md$/.test(name)) continue;
|
|
437
|
+
const rel = `${HISTORY_REL}/${name}`;
|
|
438
|
+
const entries = parseChangelogText(readFileSync(resolve(historyDir, name), 'utf8'), rel).entries;
|
|
439
|
+
perFile[rel] = entries.length;
|
|
440
|
+
coldExistingEntries.push(...entries);
|
|
441
|
+
}
|
|
347
442
|
}
|
|
348
|
-
}
|
|
349
443
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
444
|
+
// Dedupe by (date + title) — favour the freshest occurrence by file source order.
|
|
445
|
+
const seen = new Set();
|
|
446
|
+
const allEntries = [
|
|
447
|
+
...parsed.entries,
|
|
448
|
+
...legacyEntries,
|
|
449
|
+
...warmExistingEntries,
|
|
450
|
+
...coldExistingEntries,
|
|
451
|
+
]
|
|
452
|
+
.filter((e) => {
|
|
453
|
+
const key = `${e.dateStr}|${e.title}`;
|
|
454
|
+
if (seen.has(key)) return false;
|
|
455
|
+
seen.add(key);
|
|
456
|
+
return true;
|
|
457
|
+
})
|
|
458
|
+
.sort((a, b) => b.dateObj.getTime() - a.dateObj.getTime());
|
|
459
|
+
const { hot, warm, cold } = categorize(allEntries, cutoffs);
|
|
460
|
+
const coldByMonth = groupByMonth(cold);
|
|
461
|
+
|
|
462
|
+
const summary = {
|
|
463
|
+
today: todayStr,
|
|
464
|
+
hotCutoff: cutoffs.hotCutoff.toISOString().slice(0, 10),
|
|
465
|
+
warmCutoff: cutoffs.warmCutoff.toISOString().slice(0, 10),
|
|
466
|
+
totals: { all: allEntries.length, hot: hot.length, warm: warm.length, cold: cold.length },
|
|
467
|
+
perFile,
|
|
468
|
+
hotDates: hot.map((e) => e.dateStr),
|
|
469
|
+
warmDates: warm.map((e) => e.dateStr),
|
|
470
|
+
coldDates: cold.map((e) => e.dateStr),
|
|
471
|
+
coldFiles: [...coldByMonth.keys()].sort(),
|
|
472
|
+
};
|
|
378
473
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
474
|
+
if (flags.check) {
|
|
475
|
+
const tooOldInHot = parsed.entries.filter((e) => e.dateObj < cutoffs.hotCutoff);
|
|
476
|
+
if (tooOldInHot.length > 0) {
|
|
477
|
+
logError(
|
|
478
|
+
`[archive-changelog] FAIL: ${tooOldInHot.length} entries in ${CHANGELOG_REL} are older than ${opts.hotDays} days (relative to ${todayStr}).`,
|
|
479
|
+
);
|
|
480
|
+
for (const e of tooOldInHot) logError(` - ${e.dateStr} — ${e.title}`);
|
|
481
|
+
logError('Run the changelog archive script (without --check) to rotate.');
|
|
482
|
+
return 1;
|
|
483
|
+
}
|
|
484
|
+
// The verdict names what it acted on: a zero-entry tier is a DECISION — with the loud path
|
|
485
|
+
// above, zero can only mean nothing unit-shaped is present, never a file that failed to parse.
|
|
486
|
+
log(
|
|
487
|
+
`[archive-changelog] OK — ${CHANGELOG_REL}: ${parsed.entries.length} parsed entries, all within ` +
|
|
488
|
+
`${opts.hotDays} days of ${todayStr}; corpus ${allEntries.length} (HOT ${hot.length} / WARM ${warm.length} / COLD ${cold.length}).`,
|
|
384
489
|
);
|
|
385
|
-
|
|
386
|
-
console.error('Run the changelog archive script (without --check) to rotate.');
|
|
387
|
-
process.exit(1);
|
|
490
|
+
return 0;
|
|
388
491
|
}
|
|
389
|
-
console.log(`[archive-changelog] OK — all changelog.md entries are within ${opts.hotDays} days of ${todayStr}.`);
|
|
390
|
-
process.exit(0);
|
|
391
|
-
}
|
|
392
492
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
493
|
+
if (flags.dryRun) {
|
|
494
|
+
log('[archive-changelog] DRY-RUN — no files will be changed.');
|
|
495
|
+
log(JSON.stringify(summary, null, 2));
|
|
496
|
+
return 0;
|
|
497
|
+
}
|
|
398
498
|
|
|
399
|
-
|
|
499
|
+
// Zero-corpus policy, default mode: with the loud path above, zero entries across EVERY
|
|
500
|
+
// source can only mean genuinely nothing to rotate — a stated no-op, never a gratuitous
|
|
501
|
+
// mkdir + rewrite. Checked against allEntries (not just HOT) so an empty HOT never blocks
|
|
502
|
+
// servicing existing WARM/COLD entries.
|
|
503
|
+
if (allEntries.length === 0) {
|
|
504
|
+
log('[archive-changelog] nothing to rotate — 0 entries across every source.');
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
400
507
|
|
|
401
|
-
|
|
402
|
-
frontmatter: parsed.frontmatter || FRONTMATTER('history', 700, todayStr),
|
|
403
|
-
preamble: parsed.preamble,
|
|
404
|
-
hot,
|
|
405
|
-
footer: parsed.footer,
|
|
406
|
-
hasArchive: warm.length > 0 || cold.length > 0,
|
|
407
|
-
});
|
|
408
|
-
await writeFile(CHANGELOG_PATH, newChangelog, 'utf8');
|
|
508
|
+
mkdirSync(historyDir, { recursive: true });
|
|
409
509
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
510
|
+
const newChangelog = buildChangelog({
|
|
511
|
+
frontmatter: parsed.frontmatter || FRONTMATTER('history', 700, todayStr),
|
|
512
|
+
preamble: parsed.preamble,
|
|
513
|
+
hot,
|
|
514
|
+
footer: parsed.footer,
|
|
515
|
+
hasArchive: warm.length > 0 || cold.length > 0,
|
|
516
|
+
});
|
|
517
|
+
writeFileSync(changelogPath, newChangelog, 'utf8');
|
|
413
518
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
await writeFile(path, buildCold(year, month, entries, todayStr), 'utf8');
|
|
418
|
-
}
|
|
519
|
+
if (warm.length > 0) {
|
|
520
|
+
writeFileSync(recentPath, buildRecent(warm, todayStr), 'utf8');
|
|
521
|
+
}
|
|
419
522
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
523
|
+
for (const [key, entries] of coldByMonth) {
|
|
524
|
+
const [year, month] = key.split('-');
|
|
525
|
+
writeFileSync(resolve(historyDir, `${year}-${month}.md`), buildCold(year, month, entries, todayStr), 'utf8');
|
|
526
|
+
}
|
|
423
527
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
528
|
+
if (warm.length > 0 || cold.length > 0) {
|
|
529
|
+
writeFileSync(indexPath, buildCondensedIndex(warm, coldByMonth, todayStr), 'utf8');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
log('[archive-changelog] migrated:');
|
|
533
|
+
log(` HOT (${CHANGELOG_REL}): ${hot.length}`);
|
|
534
|
+
log(` WARM (${RECENT_REL}): ${warm.length}`);
|
|
535
|
+
for (const key of coldByMonth.keys()) {
|
|
536
|
+
log(` COLD (${HISTORY_REL}/${key}.md): ${coldByMonth.get(key).length}`);
|
|
537
|
+
}
|
|
538
|
+
return 0;
|
|
539
|
+
} catch (err) {
|
|
540
|
+
logError(`[archive-changelog] ${err.message}`);
|
|
541
|
+
return err.exitCode ?? 1;
|
|
429
542
|
}
|
|
430
543
|
};
|
|
431
544
|
|
|
432
545
|
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
433
|
-
if (isDirectRun)
|
|
434
|
-
main().catch((err) => {
|
|
435
|
-
console.error(err);
|
|
436
|
-
process.exit(1);
|
|
437
|
-
});
|
|
438
|
-
}
|
|
546
|
+
if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));
|