@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.
@@ -1,25 +1,40 @@
1
1
  #!/usr/bin/env node
2
- // Rotate FIXED issues from docs/ai/known_issues.md → docs/ai/history/issues-resolved.md.
2
+ // Rotate resolved issues from docs/ai/known_issues.md → docs/ai/history/issues-resolved.md.
3
3
  //
4
- // Rule: an issue is archivable when
5
- // - its heading is wrapped in ~~strikethrough~~ AND
6
- // - its body contains `**Status:** FIXED (YYYY.MM.DD)` with a date older than CUTOFF_DAYS.
7
- // Issues marked FIXED without an explicit date are left untouched (conservative agent
8
- // can re-evaluate and archive manually).
4
+ // The file is read through the shared block tokenizer (markdown-blocks.mjs). The section model:
5
+ // H2 and H3 heading tokens bound chunks; an H3 chunk is a SECTION, everything else — the preamble,
6
+ // category H2s (## 🔴 Open / ## 🟢 Resolved) and the trailing `---` + blockquote footer — is a
7
+ // FILE-structural chunk that survives every rotation untouched. A section contains only its own
8
+ // issue. Rewrites are VERBATIM: kept chunks re-emit byte-exact, archived sections land in the
9
+ // archive byte-exact, so kept + archive conserve the input line for line.
10
+ //
11
+ // Archivability (Decision 7): a recognised, line-leading, dated resolution marker decides ALONE —
12
+ // strikethrough on the heading is cosmetic. The marker is the FIRST `**Status:**` / `**Resolved:**`
13
+ // field line (list-item prefix optional, fenced samples ignored):
14
+ // - **Resolved:** <date> … the taught shape (see the template)
15
+ // - **Status:** ✅ FIXED (<date>) … legacy
16
+ // - **Status:** **Resolved** (FIXED <date>, …) …
17
+ // Both separators (YYYY-MM-DD / YYYY.MM.DD) read, with a strict calendar round-trip. Arm C: a
18
+ // resolution claim with NO recognisable date, or a malformed/impossible date, REFUSES loudly with
19
+ // file:line in every mode — never a silent skip. An explicit non-resolved Status (Open, Mitigated)
20
+ // classifies open; a later stray date never overrides it.
9
21
  //
10
22
  // Modes:
11
- // (default) append matching issues to history/issues-resolved.md, remove from known_issues.md
23
+ // (default) append archivable sections to history/issues-resolved.md, remove from known_issues.md
12
24
  // --dry-run print plan, no file changes
13
25
  // --check exit 1 if known_issues.md still has archivable issues
14
26
  //
27
+ // Every mode parses and classifies 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
+ //
15
30
  // CLI:
16
31
  // --cutoff-days=N (default 14)
17
32
  // --today=YYYY-MM-DD (default UTC today)
18
33
 
19
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
20
- import { existsSync, readFileSync } from 'node:fs';
21
- import { dirname, resolve, relative, basename } from 'node:path';
34
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
35
+ import { dirname, resolve, basename } from 'node:path';
22
36
  import { fileURLToPath, pathToFileURL } from 'node:url';
37
+ import { tokenizeMarkdown, fail } from './markdown-blocks.mjs';
23
38
 
24
39
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
40
  const ROOT = resolve(__dirname, '..');
@@ -35,145 +50,366 @@ const readProjectName = () => {
35
50
  };
36
51
  const PROJECT_NAME = readProjectName();
37
52
 
38
- const KNOWN_ISSUES_PATH = resolve(ROOT, 'docs/ai/known_issues.md');
39
- const HISTORY_DIR = resolve(ROOT, 'docs/ai/history');
40
- const RESOLVED_PATH = resolve(HISTORY_DIR, 'issues-resolved.md');
53
+ const KNOWN_REL = 'docs/ai/known_issues.md';
54
+ const HISTORY_REL = 'docs/ai/history';
55
+ const RESOLVED_REL = 'docs/ai/history/issues-resolved.md';
41
56
 
42
57
  const DEFAULT_CUTOFF_DAYS = 14;
43
58
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
44
59
 
45
60
  const ISSUE_HEADING_RE = /^### (.+?)$/;
46
61
  const STRIKETHROUGH_RE = /^~~(.+)~~$/;
47
- const FIXED_WITH_DATE_RE = /\*\*Status:\*\*\s*✅\s*FIXED\s*\((\d{4})\.(\d{2})\.(\d{2})\)/;
62
+ // The canonical issue-heading form, named in code: a column-0 level-3 heading whose title starts
63
+ // `Issue-NNN` (strikethrough optional). ISSUE-SHAPE is deliberately wider — the same prefix at any
64
+ // level or indent — so a mis-levelled or indented issue heading refuses loudly instead of being
65
+ // silently absorbed into the section above it.
66
+ const ISSUE_TITLE_RE = /^~{0,2}Issue-\d/;
67
+ const ISSUE_SHAPED_HEADING_RE = /^\s*#{1,6}[ \t]+~{0,2}Issue-\d/;
68
+ // The strict canonical placement: level 3, column 0, exactly one space. An issue-shaped heading
69
+ // that misses it by ANY whitespace (double space, a tab, an indent) refuses — `### Issue-123`
70
+ // would otherwise slip past the level-3 boundary test and silently declassify to prose.
71
+ const ISSUE_CANONICAL_RE = /^### ~{0,2}Issue-\d/;
72
+
73
+ // The resolution-marker grammar. The FIELD anchors the residual (Decision 7): only a line-leading
74
+ // Status/Resolved field is ever read — a date in prose, or in a Discovered/Update field, is inert.
75
+ const MARKER_FIELD_RE = /^(?:- )?\*\*(Status|Resolved):\*\*\s*(.*)$/;
76
+ // Optional leading emoji and optional ** emphasis around either keyword — `**FIXED** (…)`,
77
+ // `✅ **Resolved** (…)` are resolution claims, not silently-open sections. Case stays exact.
78
+ const RESOLVED_SIGNAL_RE = /^(?:✅\s*)?\*{0,2}(?:FIXED|Resolved)\b/;
79
+ // The explicitly-open vocabulary — ONLY consulted under a struck heading, where the strike itself
80
+ // signals resolution: `~~Issue~~ + Status: Closed/DONE/Wontfix` is an undated resolution claim
81
+ // (Arm C loud), while UNSTRUCK sections keep a free status vocabulary and can never false-red.
82
+ const OPEN_STATUS_RE = /^(?:Open|Mitigated|Accepted)\b/;
83
+ // Loose finds anything date-shaped; strict validates one separator form with 2-digit fields. The
84
+ // FIRST date-shaped token is the marker's date — loose-but-not-strict is a refusal, never a skip.
85
+ const STRICT_MARKER_DATE_RE = /(?<!\d)(\d{4})([.-])(\d{2})\2(\d{2})(?!\d)/;
86
+ const LOOSE_MARKER_DATE_RE = /(?<!\d)\d{4}[./-]\d{1,2}[./-]\d{1,2}(?!\d)/;
87
+
88
+ const FOOTER_QUOTE_RE = /^ {0,3}>/;
89
+ // The canonical closing note the template seeds. The trailing `---` + blockquote run is a FILE
90
+ // footer ONLY when its quote text normalises to exactly this sentence — an issue-owned trailing
91
+ // quote (even one naming the archive path) stays with its section and archives WITH it, never
92
+ // orphaned in the kept file. A reworded note falls back to the section-owned attribution.
93
+ export const CANONICAL_FOOTER_NOTE =
94
+ 'Resolved issues older than the window are rotated to `history/issues-resolved.md` by the issue-archive script.';
95
+ // The exact resolved-example heading the pre-4.0.0 template seeded (legacy-compat, see classify).
96
+ const LEGACY_TEMPLATE_BLANK_HEADING = '### Issue-XXX — {{Title}}';
97
+ const matchable = (line) => line.replace(/\s+$/, '');
98
+ const normalizeQuoteRun = (lines) =>
99
+ lines
100
+ .map((line) => matchable(line))
101
+ .filter((line) => FOOTER_QUOTE_RE.test(line))
102
+ .map((line) => line.replace(/^ {0,3}>\s?/, ''))
103
+ .join(' ')
104
+ .replace(/\s+/g, ' ')
105
+ .trim();
106
+
107
+ const USAGE = 'Usage: archive-issues.mjs [--dry-run|--check] [--cutoff-days=N] [--today=YYYY-MM-DD]';
48
108
 
49
109
  const parseArgs = (argv) => {
50
- const flags = { dryRun: false, check: false };
110
+ const flags = { dryRun: false, check: false, help: false };
51
111
  const opts = { cutoffDays: DEFAULT_CUTOFF_DAYS, today: null };
52
- for (const arg of argv.slice(2)) {
112
+ for (const arg of argv) {
53
113
  if (arg === '--dry-run') flags.dryRun = true;
54
114
  else if (arg === '--check') flags.check = true;
115
+ else if (arg === '--help' || arg === '-h') flags.help = true;
55
116
  else if (arg.startsWith('--cutoff-days=')) opts.cutoffDays = Number(arg.slice('--cutoff-days='.length));
56
117
  else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
57
- else if (arg === '--help' || arg === '-h') {
58
- console.log('Usage: archive-issues.mjs [--dry-run|--check] [--cutoff-days=N] [--today=YYYY-MM-DD]');
59
- process.exit(0);
60
- } else {
61
- console.error(`Unknown argument: ${arg}`);
62
- process.exit(2);
63
- }
118
+ else throw fail(2, `unknown argument: ${arg}\n${USAGE}`);
64
119
  }
65
120
  return { flags, opts };
66
121
  };
67
122
 
68
- export const parseKnownIssues = (text) => {
69
- const fmMatch = text.match(/^(---\n[\s\S]*?\n---\n)/);
70
- const frontmatter = fmMatch ? fmMatch[1] : '';
71
- const body = text.slice(frontmatter.length);
72
- const lines = body.split('\n');
123
+ // Parse { frontmatter, frontLines, sections }. A chunk's `heading` is the trailing-whitespace-free
124
+ // token text (null for the preamble and the footer); `structural` marks chunks that belong to the
125
+ // FILE; `lines` stay byte-exact for re-emission; `start` is the chunk's body-line index; `fenced`
126
+ // holds chunk-relative indexes of fenced lines so classification never reads a fenced sample.
127
+ export const parseKnownIssues = (text, label = KNOWN_REL) => {
128
+ const { frontmatter, frontLines, lines, headings, fencedLines } = tokenizeMarkdown(text, label);
73
129
 
130
+ const boundaries = new Map();
131
+ for (const heading of headings) {
132
+ if (ISSUE_SHAPED_HEADING_RE.test(heading.text) && !ISSUE_CANONICAL_RE.test(heading.text)) {
133
+ throw fail(
134
+ 1,
135
+ `${label}:${frontLines + heading.index + 1}: "${heading.text}" is issue-shaped but not a ` +
136
+ 'canonical issue heading — expected `### Issue-NNN — title` (level 3, column 0, exactly ' +
137
+ 'one space, ~~strikethrough~~ optional). It would previously have been silently glued ' +
138
+ 'into the section above it or declassified to prose; fix the heading, then re-run.',
139
+ );
140
+ }
141
+ if (heading.level === 2 || heading.level === 3) boundaries.set(heading.index, heading);
142
+ }
143
+
144
+ // The trailing footer belongs to the FILE: a terminal run of blank / blockquote lines introduced
145
+ // by a `---` thematic break (outside any fence), whose quote text IS the canonical closing note.
146
+ let footerStart = -1;
147
+ let tail = lines.length - 1;
148
+ while (tail >= 0 && !fencedLines.has(tail) && (matchable(lines[tail]) === '' || FOOTER_QUOTE_RE.test(lines[tail]))) tail -= 1;
149
+ if (
150
+ tail >= 0 &&
151
+ !fencedLines.has(tail) &&
152
+ /^ {0,3}---$/.test(matchable(lines[tail])) &&
153
+ normalizeQuoteRun(lines.slice(tail + 1)) === CANONICAL_FOOTER_NOTE
154
+ ) {
155
+ footerStart = tail;
156
+ }
157
+
158
+ const end = footerStart === -1 ? lines.length : footerStart;
74
159
  const sections = [];
75
- let current = { heading: null, lines: [] };
76
- for (const line of lines) {
77
- if (/^### /.test(line)) {
78
- if (current.heading !== null || current.lines.length > 0) sections.push(current);
79
- current = { heading: line, lines: [line] };
80
- } else {
81
- current.lines.push(line);
160
+ let current = { heading: null, structural: true, start: 0, lines: [] };
161
+ const flush = () => {
162
+ if (current.heading !== null || current.lines.length > 0) sections.push(current);
163
+ };
164
+ for (let i = 0; i < end; i += 1) {
165
+ const boundary = boundaries.get(i);
166
+ if (boundary) {
167
+ flush();
168
+ current = { heading: boundary.text, structural: boundary.level !== 3, start: i, lines: [] };
169
+ }
170
+ current.lines.push(lines[i]);
171
+ }
172
+ flush();
173
+ if (footerStart !== -1) sections.push({ heading: null, structural: true, start: footerStart, lines: lines.slice(footerStart) });
174
+
175
+ for (const section of sections) {
176
+ section.fenced = new Set();
177
+ for (let i = 0; i < section.lines.length; i += 1) {
178
+ if (fencedLines.has(section.start + i)) section.fenced.add(i);
179
+ }
180
+ }
181
+
182
+ verifySectionPartition(sections, lines, label);
183
+
184
+ return { frontmatter, frontLines, sections };
185
+ };
186
+
187
+ // Partition tripwire: the concatenated chunks must reproduce the body EXACTLY — element-wise, in
188
+ // order — or nothing proceeds. Unreachable through a correct chunk loop by construction — it
189
+ // exists so a future edit that drops, duplicates or reorders lines (an equal-cardinality
190
+ // corruption included) refuses BEFORE any write instead of silently losing data.
191
+ export const verifySectionPartition = (sections, bodyLines, label = KNOWN_REL) => {
192
+ const rebuilt = sections.flatMap((s) => s.lines);
193
+ const exact = rebuilt.length === bodyLines.length && rebuilt.every((line, i) => line === bodyLines[i]);
194
+ if (!exact) {
195
+ throw fail(
196
+ 1,
197
+ `${label}: internal section-model error — the ${rebuilt.length}-line section partition does ` +
198
+ `not reproduce the ${bodyLines.length}-line body; refusing before any write.`,
199
+ );
200
+ }
201
+ };
202
+
203
+ const scanMarkerDate = (value) => {
204
+ const loose = LOOSE_MARKER_DATE_RE.exec(value);
205
+ if (!loose) return null;
206
+ const strict = STRICT_MARKER_DATE_RE.exec(loose[0]);
207
+ if (strict && strict[0] === loose[0]) {
208
+ const [, year, , month, day] = strict;
209
+ const date = new Date(`${year}-${month}-${day}T00:00:00Z`);
210
+ // Round-trip, never trust Date: V8 rolls 2026-02-30 into March instead of rejecting it.
211
+ if (!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === `${year}-${month}-${day}`) {
212
+ return { date };
82
213
  }
83
214
  }
84
- if (current.heading !== null || current.lines.length > 0) sections.push(current);
85
- return { frontmatter, sections };
215
+ return { invalid: loose[0] };
86
216
  };
87
217
 
88
218
  export const classifySection = (section, cutoffDate) => {
89
- if (section.heading === null) return { kind: 'preamble' };
219
+ if (section.structural || section.heading === null) return { kind: 'structure' };
90
220
  const headingMatch = ISSUE_HEADING_RE.exec(section.heading);
91
221
  if (!headingMatch) return { kind: 'other' };
92
222
  const title = headingMatch[1];
93
- const stricken = STRIKETHROUGH_RE.exec(title);
94
- if (!stricken) return { kind: 'open' };
223
+ // The pre-4.0.0 template seeded this EXACT example section — the literal heading is a safe
224
+ // identity (no real issue carries `{{Title}}`), so a pristine OR half-substituted legacy blank
225
+ // is inert BEFORE any marker/conflict/date classification and can never red a gate or archive.
226
+ if (section.heading === LEGACY_TEMPLATE_BLANK_HEADING) return { kind: 'template-blank' };
227
+ const stricken = STRIKETHROUGH_RE.test(title);
95
228
 
96
- const blockText = section.lines.join('\n');
97
- const dateMatch = FIXED_WITH_DATE_RE.exec(blockText);
98
- if (!dateMatch) return { kind: 'fixed-undated' };
229
+ let claims = null;
230
+ let nonClaims = null;
231
+ for (let i = 0; i < section.lines.length; i += 1) {
232
+ if (section.fenced?.has(i)) continue;
233
+ const field = MARKER_FIELD_RE.exec(matchable(section.lines[i]));
234
+ if (!field) continue;
235
+ const isClaim = field[1] === 'Resolved' || RESOLVED_SIGNAL_RE.test(field[2]);
236
+ if (isClaim && claims === null) claims = { line: i, raw: matchable(section.lines[i]), value: field[2] };
237
+ else if (!isClaim && nonClaims === null) nonClaims = { line: i, raw: matchable(section.lines[i]), value: field[2] };
238
+ if (claims && nonClaims) break;
239
+ }
99
240
 
100
- const fixedDate = new Date(`${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}T00:00:00Z`);
101
- return fixedDate < cutoffDate ? { kind: 'archivable', fixedDate } : { kind: 'fixed-recent', fixedDate };
241
+ // Contradictory state refuses: Resolved-priority would silently ARCHIVE a genuinely reopened
242
+ // issue, Status-priority would silently skip a resolved one forever loud hides nothing.
243
+ if (claims && nonClaims) {
244
+ return { kind: 'conflict', markerLine: claims.line, marker: claims.raw, openLine: nonClaims.line, openMarker: nonClaims.raw };
245
+ }
246
+ if (claims) {
247
+ const scanned = scanMarkerDate(claims.value);
248
+ if (!scanned) return { kind: 'fixed-undated', markerLine: claims.line, marker: claims.raw };
249
+ if (scanned.invalid) return { kind: 'bad-date', markerLine: claims.line, marker: claims.raw, raw: scanned.invalid };
250
+ return scanned.date < cutoffDate
251
+ ? { kind: 'archivable', fixedDate: scanned.date }
252
+ : { kind: 'fixed-recent', fixedDate: scanned.date };
253
+ }
254
+ // An explicit non-resolution Status decides — strikethrough is cosmetic in BOTH directions, so
255
+ // a reopened-but-still-struck issue never refuses every mode. EXCEPT: under a struck heading an
256
+ // UNRECOGNISED status value (Closed, DONE, Wontfix …) is a resolution claim without a date.
257
+ if (nonClaims) {
258
+ if (stricken && !OPEN_STATUS_RE.test(nonClaims.value)) {
259
+ return { kind: 'fixed-undated', markerLine: nonClaims.line, marker: nonClaims.raw };
260
+ }
261
+ return { kind: ISSUE_TITLE_RE.test(title) ? 'open' : 'other' };
262
+ }
263
+ // A struck heading with NO status/resolved field still claims resolution — undated is loud.
264
+ if (stricken) return { kind: 'fixed-undated', markerLine: 0, marker: section.heading };
265
+ return { kind: ISSUE_TITLE_RE.test(title) ? 'open' : 'other' };
102
266
  };
103
267
 
104
268
  export const buildResolvedFile = (existing, newSections, todayStr) => {
105
269
  const header = existing
106
270
  ? existing
107
271
  : `---\ntype: history\nlastUpdated: ${todayStr}\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: 3500\n---\n\n# Resolved Issues — ${PROJECT_NAME}\n\n> Append-only archive of issues closed > 14 days ago. Sourced from \`../known_issues.md\`.\n\n---\n`;
108
- const newBlocks = newSections.map((s) => s.lines.join('\n').replace(/\n+$/, '')).join('\n\n---\n\n');
109
- if (!newBlocks) return header;
110
- return `${header}\n${newBlocks}\n`;
272
+ if (newSections.length === 0) return header;
273
+ // Sections are appended VERBATIM — every archived CONTENT line lands in the archive byte-exact.
274
+ // Only the trailing blank run (the source's section separator) is normalised to exactly one
275
+ // blank line, the same droppable-decoration accounting the changelog conservation harness uses.
276
+ // Blank trimming is per-ELEMENT (trim() eats a CR), and the separator follows the block's own
277
+ // line-ending flavor — a CRLF corpus gets exact `\r\n\r\n` separators, never mixed EOL runs.
278
+ const blocks = newSections
279
+ .map((s) => {
280
+ const kept = [...s.lines];
281
+ while (kept.length > 0 && kept[kept.length - 1].trim() === '') kept.pop();
282
+ const separator = kept.length > 0 && kept[kept.length - 1].endsWith('\r') ? '\n\r\n' : '\n\n';
283
+ return kept.join('\n') + separator;
284
+ })
285
+ .join('');
286
+ // The header/blocks separator follows the HEADER's own EOL flavor, and the trailing run
287
+ // collapses to ONE terminator of its own flavor — an existing CRLF archive never gains a
288
+ // mixed `\r\n\n` run on append. (An archive whose existing content and new blocks use
289
+ // different flavors stays mixed at that boundary by nature; only runs are guaranteed pure.)
290
+ const headerEndsBlank = /(\r?\n){2}$/.test(header);
291
+ const headerEol = header.endsWith('\r\n') ? '\r\n' : '\n';
292
+ return (header + (headerEndsBlank ? '' : headerEol) + blocks).replace(/(\r?\n)+$/, '$1');
111
293
  };
112
294
 
113
- const main = async () => {
114
- const { flags, opts } = parseArgs(process.argv);
115
- const today = opts.today
116
- ? new Date(`${opts.today}T00:00:00Z`)
117
- : new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
118
- const cutoffDate = new Date(today.getTime() - (opts.cutoffDays - 1) * MS_PER_DAY);
119
- const todayStr = today.toISOString().slice(0, 10);
120
-
121
- const text = await readFile(KNOWN_ISSUES_PATH, 'utf8');
122
- const { frontmatter, sections } = parseKnownIssues(text);
123
-
124
- const classified = sections.map((s) => ({ section: s, ...classifySection(s, cutoffDate) }));
125
- const archivable = classified.filter((c) => c.kind === 'archivable');
126
-
127
- if (flags.check) {
128
- if (archivable.length > 0) {
129
- console.error(`[archive-issues] FAIL: ${archivable.length} archivable issues found in known_issues.md.`);
130
- for (const c of archivable) console.error(` - ${c.section.heading.trim()}`);
131
- console.error('Run the issues archive script (without --check) to rotate.');
132
- process.exit(1);
295
+ export const runCli = (argv, deps = {}) => {
296
+ const { root = ROOT, log = console.log, logError = console.error } = deps;
297
+ try {
298
+ const { flags, opts } = parseArgs(argv);
299
+ if (flags.help) {
300
+ log(USAGE);
301
+ return 0;
133
302
  }
134
- console.log('[archive-issues] OK no FIXED issues older than 14 days in known_issues.md.');
135
- process.exit(0);
136
- }
303
+ const today = opts.today
304
+ ? new Date(`${opts.today}T00:00:00Z`)
305
+ : new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
306
+ const cutoffDate = new Date(today.getTime() - (opts.cutoffDays - 1) * MS_PER_DAY);
307
+ const cutoffStr = cutoffDate.toISOString().slice(0, 10);
308
+ const todayStr = today.toISOString().slice(0, 10);
137
309
 
138
- if (flags.dryRun) {
139
- console.log('[archive-issues] DRY-RUN no files will be changed.');
140
- console.log(` cutoffDate: ${cutoffDate.toISOString().slice(0, 10)}`);
141
- console.log(` total sections: ${sections.length}`);
142
- console.log(` archivable: ${archivable.length}`);
143
- for (const c of archivable) console.log(` - ${c.section.heading.trim()}`);
144
- return;
145
- }
310
+ const knownIssuesPath = resolve(root, KNOWN_REL);
311
+ const historyDir = resolve(root, HISTORY_REL);
312
+ const resolvedPath = resolve(root, RESOLVED_REL);
146
313
 
147
- if (archivable.length === 0) {
148
- console.log('[archive-issues] nothing to archive.');
149
- return;
150
- }
314
+ if (!existsSync(knownIssuesPath)) {
315
+ logError(`[archive-issues] ${KNOWN_REL} not found — nothing to do.`);
316
+ return 1;
317
+ }
318
+
319
+ const { frontmatter, frontLines, sections } = parseKnownIssues(readFileSync(knownIssuesPath, 'utf8'), KNOWN_REL);
320
+ const classified = sections.map((s) => ({ section: s, ...classifySection(s, cutoffDate) }));
321
+
322
+ // Arm C: a resolution claim the parser cannot date — or contradictory open/resolved state —
323
+ // is a refusal in EVERY mode, before any write.
324
+ const loud = classified.filter((c) => c.kind === 'fixed-undated' || c.kind === 'bad-date' || c.kind === 'conflict');
325
+ if (loud.length > 0) {
326
+ for (const c of loud) {
327
+ const line = frontLines + c.section.start + (c.markerLine ?? 0) + 1;
328
+ if (c.kind === 'conflict') {
329
+ const openLine = frontLines + c.section.start + c.openLine + 1;
330
+ logError(
331
+ `[archive-issues] ${KNOWN_REL}:${line}: "${c.marker.trim()}" contradicts the explicit open status ` +
332
+ `"${c.openMarker.trim()}" (line ${openLine}) — an issue is either open or resolved; delete the ` +
333
+ 'stale line, then re-run.',
334
+ );
335
+ } else {
336
+ logError(
337
+ c.kind === 'bad-date'
338
+ ? `[archive-issues] ${KNOWN_REL}:${line}: resolution marker date "${c.raw}" in "${c.marker.trim()}" ` +
339
+ 'is not a valid calendar date in one separator form (YYYY-MM-DD or YYYY.MM.DD) — fix the date, then re-run.'
340
+ : `[archive-issues] ${KNOWN_REL}:${line}: "${(c.marker ?? c.section.heading).trim()}" claims resolution ` +
341
+ 'but carries no recognisable date — add a line-leading `- **Resolved:** YYYY-MM-DD` (or ' +
342
+ '`- **Status:** … FIXED (YYYY-MM-DD)`). Previously this section was silently skipped forever.',
343
+ );
344
+ }
345
+ }
346
+ return 1;
347
+ }
151
348
 
152
- await mkdir(HISTORY_DIR, { recursive: true });
153
- const existing = existsSync(RESOLVED_PATH) ? await readFile(RESOLVED_PATH, 'utf8') : '';
154
- const updatedResolved = buildResolvedFile(existing, archivable.map((c) => c.section), todayStr);
155
- await writeFile(RESOLVED_PATH, updatedResolved, 'utf8');
156
-
157
- const keptSections = classified.filter((c) => c.kind !== 'archivable').map((c) => c.section);
158
- // Rebuild known_issues.md
159
- const rebuilt = [
160
- frontmatter.trim(),
161
- '',
162
- ...keptSections.map((s) => s.lines.join('\n').replace(/\n+$/, '')),
163
- '',
164
- ]
165
- .join('\n')
166
- .replace(/\n{3,}/g, '\n\n')
167
- .trim() + '\n';
168
- await writeFile(KNOWN_ISSUES_PATH, rebuilt, 'utf8');
169
-
170
- console.log(`[archive-issues] archived ${archivable.length} issue(s) to ${relative(ROOT, RESOLVED_PATH)}`);
349
+ const archivable = classified.filter((c) => c.kind === 'archivable');
350
+ const counts = {};
351
+ for (const c of classified) {
352
+ if (c.kind === 'structure') continue;
353
+ counts[c.kind] = (counts[c.kind] ?? 0) + 1;
354
+ }
355
+ const issueSectionCount = classified.filter((c) => c.kind !== 'structure').length;
356
+ const countLine = Object.entries(counts)
357
+ .map(([kind, n]) => `${kind} ${n}`)
358
+ .join(' / ');
359
+
360
+ if (flags.check) {
361
+ if (archivable.length > 0) {
362
+ logError(`[archive-issues] FAIL: ${archivable.length} archivable issues found in ${KNOWN_REL}.`);
363
+ for (const c of archivable) logError(` - ${c.section.heading.trim()}`);
364
+ logError('Run the issues archive script (without --check) to rotate.');
365
+ return 1;
366
+ }
367
+ // The verdict names what it acted on — with the loud path in the parser, a low section
368
+ // count can only mean the sections are genuinely absent, never a file that failed to parse.
369
+ log(
370
+ `[archive-issues] OK — ${KNOWN_REL}: ${issueSectionCount} issue sections (${countLine || 'none'}), ` +
371
+ `0 archivable with a recognised date older than ${cutoffStr} (cutoff ${opts.cutoffDays} days, relative to ${todayStr}).`,
372
+ );
373
+ return 0;
374
+ }
375
+
376
+ if (flags.dryRun) {
377
+ log('[archive-issues] DRY-RUN — no files will be changed.');
378
+ log(` cutoffDate: ${cutoffStr}`);
379
+ log(` total sections: ${sections.length}`);
380
+ log(` issue sections: ${issueSectionCount}${countLine ? ` (${countLine})` : ''}`);
381
+ log(` archivable: ${archivable.length}`);
382
+ for (const c of archivable) log(` - ${c.section.heading.trim()}`);
383
+ return 0;
384
+ }
385
+
386
+ if (archivable.length === 0) {
387
+ log(`[archive-issues] nothing to archive — ${issueSectionCount} issue sections (${countLine || 'none'}).`);
388
+ return 0;
389
+ }
390
+
391
+ mkdirSync(historyDir, { recursive: true });
392
+ const existing = existsSync(resolvedPath) ? readFileSync(resolvedPath, 'utf8') : '';
393
+ const updatedResolved = buildResolvedFile(existing, archivable.map((c) => c.section), todayStr);
394
+ writeFileSync(resolvedPath, updatedResolved, 'utf8');
395
+
396
+ // VERBATIM rebuild: the kept chunks re-emit byte-exact in original order — structural chunks
397
+ // (preamble, category H2s, the footer) survive by construction, nothing is re-flowed.
398
+ const rebuilt =
399
+ frontmatter +
400
+ classified
401
+ .filter((c) => c.kind !== 'archivable')
402
+ .flatMap((c) => c.section.lines)
403
+ .join('\n');
404
+ writeFileSync(knownIssuesPath, rebuilt, 'utf8');
405
+
406
+ log(`[archive-issues] archived ${archivable.length} issue(s) to ${RESOLVED_REL}`);
407
+ return 0;
408
+ } catch (err) {
409
+ logError(`[archive-issues] ${err.message}`);
410
+ return err.exitCode ?? 1;
411
+ }
171
412
  };
172
413
 
173
414
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
174
- if (isDirectRun) {
175
- main().catch((err) => {
176
- console.error(err);
177
- process.exit(1);
178
- });
179
- }
415
+ if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));