@sabaiway/agent-workflow-memory 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 7)
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 { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
26
- import { existsSync, readFileSync } from 'node:fs';
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 CHANGELOG_PATH = resolve(ROOT, 'docs/ai/changelog.md');
46
- const HISTORY_DIR = resolve(ROOT, 'docs/ai/history');
47
- const RECENT_PATH = resolve(HISTORY_DIR, 'recent.md');
48
- const INDEX_PATH = resolve(HISTORY_DIR, 'condensed-index.md');
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
- const ENTRY_HEADING_RE = /^## (\d{4})\.(\d{2})\.(\d{2})(?: [—–] (.*))?$/;
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.slice(2)) {
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 if (arg === '--help' || arg === '-h') {
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
- const lines = entry.block.split('\n');
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 extractFirstParagraph = (text) => {
203
- const paragraphs = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
204
- for (const para of paragraphs) {
205
- if (para.startsWith('#')) continue;
206
- if (/^(\*\*Goal|\*\*Problem|\*\*Context|\*\*Why|\*\*Session)/i.test(para)) return para;
207
- }
208
- return paragraphs.find((p) => !p.startsWith('#')) ?? '';
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 summary = extractFirstParagraph(body);
229
- const files = extractFileBullets(body);
230
- const metric = extractMetric(body);
231
-
232
- return [heading, '', summary, files, metric].filter(Boolean).join('\n\n').trim();
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
- return `- **${entry.dateStr}** ${titleSnippet} [${sourceLink}](./${sourceLink})`;
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 main = async () => {
318
- const { flags, opts } = parseArgs(process.argv);
319
- const cutoffs = computeCutoffs(opts.today, opts.hotDays, opts.warmDays);
320
- const todayStr = cutoffs.today.toISOString().slice(0, 10);
321
-
322
- const changelogText = await readFile(CHANGELOG_PATH, 'utf8');
323
- const parsed = parseChangelogText(changelogText);
324
-
325
- // Pull in legacy archive file if present (one-time inhalation).
326
- const legacyArchivePath = resolve(ROOT, 'docs/ai/changelog-archive.md');
327
- let legacyEntries = [];
328
- if (existsSync(legacyArchivePath)) {
329
- const legacyText = await readFile(legacyArchivePath, 'utf8');
330
- legacyEntries = parseChangelogText(legacyText).entries;
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
- // Read existing archive files so rotation is idempotent and does not drop entries
334
- // already in WARM/COLD when only HOT changed.
335
- let warmExistingEntries = [];
336
- if (existsSync(RECENT_PATH)) {
337
- const recentText = await readFile(RECENT_PATH, 'utf8');
338
- warmExistingEntries = parseChangelogText(recentText).entries;
339
- }
340
- let coldExistingEntries = [];
341
- if (existsSync(HISTORY_DIR)) {
342
- const archiveEntries = await readdir(HISTORY_DIR);
343
- for (const name of archiveEntries) {
344
- if (!/^\d{4}-\d{2}\.md$/.test(name)) continue;
345
- const text = await readFile(resolve(HISTORY_DIR, name), 'utf8');
346
- coldExistingEntries.push(...parseChangelogText(text).entries);
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
- // Dedupe by (date + title) — favour the freshest occurrence by file source order.
351
- const seen = new Set();
352
- const allEntries = [
353
- ...parsed.entries,
354
- ...legacyEntries,
355
- ...warmExistingEntries,
356
- ...coldExistingEntries,
357
- ]
358
- .filter((e) => {
359
- const key = `${e.dateStr}|${e.title}`;
360
- if (seen.has(key)) return false;
361
- seen.add(key);
362
- return true;
363
- })
364
- .sort((a, b) => b.dateObj.getTime() - a.dateObj.getTime());
365
- const { hot, warm, cold } = categorize(allEntries, cutoffs);
366
- const coldByMonth = groupByMonth(cold);
367
-
368
- const summary = {
369
- today: todayStr,
370
- hotCutoff: cutoffs.hotCutoff.toISOString().slice(0, 10),
371
- warmCutoff: cutoffs.warmCutoff.toISOString().slice(0, 10),
372
- totals: { all: allEntries.length, hot: hot.length, warm: warm.length, cold: cold.length },
373
- hotDates: hot.map((e) => e.dateStr),
374
- warmDates: warm.map((e) => e.dateStr),
375
- coldDates: cold.map((e) => e.dateStr),
376
- coldFiles: [...coldByMonth.keys()].sort(),
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
- if (flags.check) {
380
- const tooOldInHot = parsed.entries.filter((e) => e.dateObj < cutoffs.hotCutoff);
381
- if (tooOldInHot.length > 0) {
382
- console.error(
383
- `[archive-changelog] FAIL: ${tooOldInHot.length} entries in changelog.md are older than ${opts.hotDays} days (relative to ${todayStr}).`,
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
- for (const e of tooOldInHot) console.error(` - ${e.dateStr} — ${e.title}`);
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
- if (flags.dryRun) {
394
- console.log('[archive-changelog] DRY-RUN — no files will be changed.');
395
- console.log(JSON.stringify(summary, null, 2));
396
- return;
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
- await mkdir(HISTORY_DIR, { recursive: true });
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
- const newChangelog = buildChangelog({
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
- if (warm.length > 0) {
411
- await writeFile(RECENT_PATH, buildRecent(warm, todayStr), 'utf8');
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
- for (const [key, entries] of coldByMonth) {
415
- const [year, month] = key.split('-');
416
- const path = resolve(HISTORY_DIR, `${year}-${month}.md`);
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
- if (warm.length > 0 || cold.length > 0) {
421
- await writeFile(INDEX_PATH, buildCondensedIndex(warm, coldByMonth, todayStr), 'utf8');
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
- console.log('[archive-changelog] migrated:');
425
- console.log(` HOT (${relative(ROOT, CHANGELOG_PATH)}): ${hot.length}`);
426
- console.log(` WARM (${relative(ROOT, RECENT_PATH)}): ${warm.length}`);
427
- for (const key of coldByMonth.keys()) {
428
- console.log(` COLD (history/${key}.md): ${coldByMonth.get(key).length}`);
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));