@sabaiway/agent-workflow-kit 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env node
2
+ // Three-tier cascade archive for docs/ai/decisions.md (ADRs) — the archive-changelog.mjs sibling.
3
+ //
4
+ // HOT (docs/ai/decisions.md) — the active ADR set (newest at the bottom)
5
+ // WARM (docs/ai/history/decisions-archive.md) — stable/superseded ADRs rotated out of HOT
6
+ // COLD (docs/ai/history/decisions-archive-early.md) — the earliest ADRs, rolled out of WARM
7
+ //
8
+ // Caps are read from each file's OWN frontmatter `maxLines`. The cascade is CHAINED: rolling
9
+ // HOT→WARM when WARM is near its cap first rolls WARM→COLD to make headroom. Whole entries move,
10
+ // oldest (lowest AD id, top of file) first; an entry's lines move verbatim.
11
+ //
12
+ // Modes:
13
+ // (default) rotate, mutate files in place (only when something is over cap)
14
+ // --dry-run print the planned move-set, change nothing
15
+ // --check report per-tier lines/cap; exit 1 if any tier is over its cap
16
+ // --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs)
17
+ //
18
+ // FAIL-LOUD invariants (the Issue-009 lesson — never silently glue an entry to the previous body):
19
+ // • every `## ` heading in every tier MUST parse canonically as `## AD-0NN — <title>` — a
20
+ // malformed heading is exit 1 naming the file + line, never a silent merge;
21
+ // • ADR ids must be strictly ascending within a tier and unique across tiers;
22
+ // • a COLD tier at its cap — or a roll that would not fit COLD's remaining headroom — fails
23
+ // LOUD **before any write** (a cap raise / a new COLD file is a maintainer/agent decision;
24
+ // this script only moves entries);
25
+ // • conservation is self-verified before writing: the multiset of AD ids across all three
26
+ // tiers and every entry's line count are unchanged by the plan.
27
+ //
28
+ // DELIBERATE divergence from the siblings: on a project WITHOUT docs/ai/decisions.md, `--check`
29
+ // reports the absence and exits 0 — the deployed pre-commit hook must never block a commit over
30
+ // an absent ADR substrate. (archive-changelog.mjs reads its source unconditionally and crashes
31
+ // ENOENT on an absent file; this script states the skip instead.)
32
+ //
33
+ // Cap accounting is on the REAL on-disk line count (what the docs cap-validator counts), never
34
+ // on a normalized render — a template-shaped file with `---` separators between entries must not
35
+ // false-green near its cap. A write NORMALIZES formatting (entries joined by one blank line;
36
+ // separators dropped), so when a tier is over cap on raw lines but already fits after
37
+ // normalization, the rotation performs a NORMALIZE-ONLY rewrite (zero entry moves, stated).
38
+ //
39
+ // Dependency-free, Node >= 18. Deployed into a consumer's scripts/ like its siblings.
40
+
41
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
42
+ import { dirname, resolve } from 'node:path';
43
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
+
45
+ const __dirname = dirname(fileURLToPath(import.meta.url));
46
+ const DEFAULT_ROOT = resolve(__dirname, '..');
47
+
48
+ export const HOT_REL = 'docs/ai/decisions.md';
49
+ export const WARM_REL = 'docs/ai/history/decisions-archive.md';
50
+ export const COLD_REL = 'docs/ai/history/decisions-archive-early.md';
51
+
52
+ const DEFAULT_WARM_CAP = 500;
53
+ const DEFAULT_COLD_CAP = 400;
54
+
55
+ export const HEADING_RE = /^## AD-(\d{3}) — (.+)$/;
56
+ const ANY_H2_RE = /^## /;
57
+ const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/;
58
+
59
+ export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
60
+
61
+ // ── parsing (strict; malformed headings are LOUD) ─────────────────────────────────────
62
+
63
+ const stripTrailingSeparators = (blockLines) => {
64
+ const lines = [...blockLines];
65
+ while (lines.length > 0 && (lines[lines.length - 1].trim() === '' || lines[lines.length - 1].trim() === '---')) {
66
+ lines.pop();
67
+ }
68
+ return lines;
69
+ };
70
+
71
+ // Parse one tier's text → { frontmatter, cap, preamble, entries }. Every `## ` line must be a
72
+ // canonical AD heading — anything else in any tier is exit 1 naming file:line (Issue-009).
73
+ export const parseDecisionsText = (text, label) => {
74
+ const fmMatch = text.match(FRONTMATTER_RE);
75
+ const frontmatter = fmMatch ? fmMatch[1] : '';
76
+ const fmLines = frontmatter === '' ? 0 : frontmatter.split('\n').length - 1;
77
+ const rest = text.slice(frontmatter.length);
78
+ const lines = rest.split('\n');
79
+
80
+ const startIdxs = [];
81
+ lines.forEach((line, i) => {
82
+ if (!ANY_H2_RE.test(line)) return;
83
+ if (!HEADING_RE.test(line)) {
84
+ throw fail(
85
+ 1,
86
+ `${label}:${fmLines + i + 1}: non-canonical H2 heading "${line}" — every "## " heading must be \`## AD-0NN — <title>\` (never silently glued to the previous entry; fix the heading, then re-run)`,
87
+ );
88
+ }
89
+ startIdxs.push(i);
90
+ });
91
+
92
+ const preambleEnd = startIdxs.length > 0 ? startIdxs[0] : lines.length;
93
+ const preamble = lines.slice(0, preambleEnd).join('\n').trim();
94
+
95
+ const entries = startIdxs.map((idx, i) => {
96
+ const end = i + 1 < startIdxs.length ? startIdxs[i + 1] : lines.length;
97
+ const blockLines = stripTrailingSeparators(lines.slice(idx, end));
98
+ const m = HEADING_RE.exec(lines[idx]);
99
+ return {
100
+ id: m[1],
101
+ idNum: Number(m[1]),
102
+ title: m[2],
103
+ block: blockLines.join('\n'),
104
+ lineCount: blockLines.length,
105
+ };
106
+ });
107
+
108
+ for (let i = 1; i < entries.length; i += 1) {
109
+ if (entries[i].idNum <= entries[i - 1].idNum) {
110
+ throw fail(
111
+ 1,
112
+ `${label}: AD-${entries[i].id} appears after AD-${entries[i - 1].id} — ids must be strictly ascending within a tier (oldest at the top); refusing to rotate a disordered file`,
113
+ );
114
+ }
115
+ }
116
+
117
+ const capMatch = frontmatter.match(/^maxLines:\s*(\d+)\s*$/m);
118
+ return { frontmatter, cap: capMatch ? Number(capMatch[1]) : null, preamble, entries };
119
+ };
120
+
121
+ // ── tier IO ───────────────────────────────────────────────────────────────────────────
122
+
123
+ const CREATED_FRONTMATTER = (cap, today) =>
124
+ `---\ntype: history\nlastUpdated: ${today}\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${cap}\n---\n`;
125
+
126
+ const CREATED_WARM_PREAMBLE = [
127
+ '# Architecture Decision Records — Archive (AD-000 … AD-000)',
128
+ '',
129
+ '> Stable ADRs rotated out of the active [`decisions.md`](../decisions.md) per the 3-tier archive',
130
+ '> discipline. The earliest entries roll further to the COLD',
131
+ '> [`decisions-archive-early.md`](./decisions-archive-early.md). Cross-links (`[[AD-XXX]]`) resolve',
132
+ '> by id across all three decision files.',
133
+ ].join('\n');
134
+
135
+ const CREATED_COLD_PREAMBLE = [
136
+ '# Architecture Decision Records — Early Archive (AD-000 … AD-000)',
137
+ '',
138
+ '> The earliest foundational ADRs, rolled out of [`decisions-archive.md`](./decisions-archive.md) (WARM)',
139
+ '> into this COLD tier when the WARM archive neared its cap. Cross-links (`[[AD-XXX]]`) still resolve',
140
+ '> by id across all three decision files.',
141
+ ].join('\n');
142
+
143
+ export const loadTiers = (root, today) => {
144
+ const load = (rel, { createdCap, createdPreamble }) => {
145
+ const path = resolve(root, rel);
146
+ if (existsSync(path)) {
147
+ const raw = readFileSync(path, 'utf8');
148
+ const parsed = parseDecisionsText(raw, rel);
149
+ if (parsed.cap === null) throw fail(1, `${rel}: frontmatter carries no maxLines cap — refusing to rotate against an unknown budget`);
150
+ return { rel, path, exists: true, rawLines: lineCountOf(raw), ...parsed };
151
+ }
152
+ return {
153
+ rel,
154
+ path,
155
+ exists: false,
156
+ rawLines: 0,
157
+ frontmatter: CREATED_FRONTMATTER(createdCap, today),
158
+ cap: createdCap,
159
+ preamble: createdPreamble,
160
+ entries: [],
161
+ };
162
+ };
163
+ const hot = load(HOT_REL, { createdCap: 0, createdPreamble: '' }); // HOT is never created here
164
+ const warm = load(WARM_REL, { createdCap: DEFAULT_WARM_CAP, createdPreamble: CREATED_WARM_PREAMBLE });
165
+ const cold = load(COLD_REL, { createdCap: DEFAULT_COLD_CAP, createdPreamble: CREATED_COLD_PREAMBLE });
166
+
167
+ const seen = new Map();
168
+ for (const tier of [hot, warm, cold]) {
169
+ for (const entry of tier.entries) {
170
+ if (seen.has(entry.id)) throw fail(1, `AD-${entry.id} appears in both ${seen.get(entry.id)} and ${tier.rel} — duplicate id across tiers; refusing to rotate`);
171
+ seen.set(entry.id, tier.rel);
172
+ }
173
+ }
174
+ return { hot, warm, cold };
175
+ };
176
+
177
+ // ── rendering ─────────────────────────────────────────────────────────────────────────
178
+
179
+ export const renderTier = (tier, entries) => {
180
+ const blocks = entries.map((entry) => entry.block).join('\n\n');
181
+ const body = [tier.preamble, blocks].filter((part) => part !== '').join('\n\n');
182
+ return `${tier.frontmatter}\n${body}\n`;
183
+ };
184
+
185
+ export const lineCountOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
186
+
187
+ // ── the deterministic move plan (same input → same move-set; nothing written here) ────
188
+
189
+ export const planRotation = ({ hot, warm, cold }) => {
190
+ const hotEntries = [...hot.entries];
191
+ const warmEntries = [...warm.entries];
192
+ const coldEntries = [...cold.entries];
193
+ const moves = { hotToWarm: [], warmToCold: [] };
194
+
195
+ const linesOf = (tier, entries) => lineCountOf(renderTier(tier, entries));
196
+
197
+ const rollWarmToCold = () => {
198
+ if (warmEntries.length === 0) {
199
+ throw fail(1, `${WARM_REL} exceeds its cap with no entries left to roll — its preamble alone is over budget; fix the file by hand`);
200
+ }
201
+ const moved = warmEntries[0];
202
+ const next = [...coldEntries, moved];
203
+ if (linesOf(cold, next) > cold.cap) {
204
+ throw fail(
205
+ 1,
206
+ `refusing BEFORE any write: rolling AD-${moved.id} (${moved.lineCount} lines) into ${COLD_REL} would exceed its cap ` +
207
+ `(${linesOf(cold, coldEntries)}/${cold.cap} now, ${linesOf(cold, next)} after) — the COLD tier is exhausted; ` +
208
+ 'a cap raise or a new COLD file is a maintainer/agent decision, this script only moves entries',
209
+ );
210
+ }
211
+ warmEntries.shift();
212
+ coldEntries.push(moved);
213
+ moves.warmToCold.push(moved.id);
214
+ };
215
+
216
+ const ensureWarmFits = () => {
217
+ while (linesOf(warm, warmEntries) > warm.cap) rollWarmToCold();
218
+ };
219
+
220
+ ensureWarmFits(); // a pre-existing WARM overflow chains down first
221
+ while (linesOf(hot, hotEntries) > hot.cap) {
222
+ if (hotEntries.length <= 1) {
223
+ throw fail(1, `${HOT_REL} exceeds its cap but only one entry remains — the newest entry alone is over budget; trim it or raise the cap (maintainer decision)`);
224
+ }
225
+ const moved = hotEntries.shift();
226
+ warmEntries.push(moved);
227
+ moves.hotToWarm.push(moved.id);
228
+ ensureWarmFits();
229
+ }
230
+
231
+ return { moves, hotEntries, warmEntries, coldEntries };
232
+ };
233
+
234
+ // Conservation self-verify: the multiset of (id → lineCount) across all tiers is unchanged.
235
+ export const verifyConservation = (before, after) => {
236
+ const snapshot = (tiers) =>
237
+ tiers
238
+ .flat()
239
+ .map((entry) => `${entry.id}:${entry.lineCount}`)
240
+ .sort()
241
+ .join('|');
242
+ const beforeKey = snapshot(before);
243
+ const afterKey = snapshot(after);
244
+ if (beforeKey !== afterKey) {
245
+ throw fail(1, `internal conservation violation — the planned move-set would change the ADR set (before ${beforeKey.slice(0, 120)}… vs after ${afterKey.slice(0, 120)}…); refusing to write`);
246
+ }
247
+ };
248
+
249
+ // ── preamble range-token maintenance ──────────────────────────────────────────────────
250
+ // The hand-authored preambles carry range tokens ("AD-014 … AD-023", "(AD-024 onward)",
251
+ // "from **AD-024**"). After a rotation those would silently lie, so the recognizable tokens are
252
+ // updated in place; a preamble without them (a consumer's own wording) is left untouched.
253
+
254
+ const RANGE_TOKEN_RE = /AD-\d{3} … AD-\d{3}/g;
255
+
256
+ const formatRange = (entries) => `AD-${entries[0].id} … AD-${entries[entries.length - 1].id}`;
257
+
258
+ export const updateRangeTokens = (preamble, kind, { hotEntries, warmEntries, coldEntries }) => {
259
+ // Token order per tier file: HOT + WARM preambles mention the WARM range then the COLD range;
260
+ // COLD mentions only its own. This ASSUMES the hand-authored order (WARM before COLD) — the
261
+ // shape all three files here carry. A maintainer who rewrites a preamble with the ranges
262
+ // swapped would get the bounds injected into the wrong slots; if you reorder the ranges,
263
+ // update this sequence too (a preamble WITHOUT the tokens is simply left untouched).
264
+ const sequence = kind === 'cold' ? [coldEntries] : [warmEntries, coldEntries];
265
+ let occurrence = 0;
266
+ let out = preamble.replace(RANGE_TOKEN_RE, (token) => {
267
+ const tierEntries = sequence[Math.min(occurrence, sequence.length - 1)];
268
+ occurrence += 1;
269
+ return tierEntries.length > 0 ? formatRange(tierEntries) : token;
270
+ });
271
+ if (hotEntries.length > 0) {
272
+ out = out.replace(/\(AD-\d{3} onward\)/, `(AD-${hotEntries[0].id} onward)`);
273
+ out = out.replace(/from \*\*AD-\d{3}\*\*/, `from **AD-${hotEntries[0].id}**`);
274
+ }
275
+ return out;
276
+ };
277
+
278
+ const stampLastUpdated = (frontmatter, today) => frontmatter.replace(/^lastUpdated: .*$/m, `lastUpdated: ${today}`);
279
+
280
+ // ── CLI ───────────────────────────────────────────────────────────────────────────────
281
+
282
+ const USAGE = 'Usage: archive-decisions.mjs [--dry-run|--check] [--today=YYYY-MM-DD]';
283
+
284
+ const parseArgs = (argv) => {
285
+ const flags = { dryRun: false, check: false, help: false };
286
+ let today = null;
287
+ for (const arg of argv) {
288
+ if (arg === '--dry-run') flags.dryRun = true;
289
+ else if (arg === '--check') flags.check = true;
290
+ else if (arg === '--help' || arg === '-h') flags.help = true;
291
+ else if (arg.startsWith('--today=')) today = arg.slice('--today='.length);
292
+ else throw fail(2, `Unknown argument: ${arg}\n${USAGE}`);
293
+ }
294
+ return { flags, today };
295
+ };
296
+
297
+ export const runCli = (argv, deps = {}) => {
298
+ const { root = DEFAULT_ROOT, log = console.log, logError = console.error } = deps;
299
+ try {
300
+ const { flags, today: todayOpt } = parseArgs(argv);
301
+ if (flags.help) {
302
+ log(USAGE);
303
+ return 0;
304
+ }
305
+ const today = todayOpt ?? new Date().toISOString().slice(0, 10);
306
+
307
+ // DELIBERATE divergence from archive-changelog.mjs (which crashes ENOENT): an absent ADR
308
+ // substrate is a STATED SKIP on --check — the deployed pre-commit hook must never block a
309
+ // commit over a decisions.md the project simply does not keep.
310
+ if (!existsSync(resolve(root, HOT_REL))) {
311
+ if (flags.check) {
312
+ log(`[archive-decisions] SKIP — ${HOT_REL} not found (this project keeps no ADR file); nothing to check.`);
313
+ return 0;
314
+ }
315
+ logError(`[archive-decisions] ${HOT_REL} not found — nothing to rotate.`);
316
+ return 1;
317
+ }
318
+
319
+ const tiers = loadTiers(root, today);
320
+ const usage = (tier, entries = tier.entries) => `${lineCountOf(renderTier(tier, entries))}/${tier.cap}`;
321
+ // Cap checks count the REAL on-disk lines (what the docs cap-validator counts) — a normalized
322
+ // render undercounts a template-shaped file with `---` separators and would false-green.
323
+ const rawUsage = (tier) => (tier.exists ? tier.rawLines : lineCountOf(renderTier(tier, tier.entries)));
324
+
325
+ if (flags.check) {
326
+ const over = [];
327
+ for (const tier of [tiers.hot, tiers.warm, tiers.cold]) {
328
+ const lines = rawUsage(tier);
329
+ log(`[archive-decisions] ${tier.rel}: ${lines}/${tier.cap}${tier.exists ? '' : ' (absent — would be created on rotation)'}`);
330
+ if (lines > tier.cap) over.push(tier);
331
+ }
332
+ if (over.length > 0) {
333
+ for (const tier of over) {
334
+ const recovery =
335
+ tier.rel === COLD_REL
336
+ ? 'the COLD tier is exhausted — a cap raise or a new COLD file is a maintainer/agent decision'
337
+ : 'run `node scripts/archive-decisions.mjs` to rotate';
338
+ logError(`[archive-decisions] FAIL: ${tier.rel} is over its cap — ${recovery}.`);
339
+ }
340
+ return 1;
341
+ }
342
+ log('[archive-decisions] OK — every tier is within its cap.');
343
+ return 0;
344
+ }
345
+
346
+ const plan = planRotation(tiers);
347
+ // A tier over its cap on RAW lines needs a write even with zero entry moves — normalization
348
+ // (the canonical rendered form) alone brings it back under (the moves loop already ensured
349
+ // the RENDERED result fits; if it did not, moves would be non-empty).
350
+ const normalizeOnly = [tiers.hot, tiers.warm, tiers.cold].filter((tier) => tier.exists && tier.rawLines > tier.cap);
351
+ if (plan.moves.hotToWarm.length === 0 && plan.moves.warmToCold.length === 0 && normalizeOnly.length === 0) {
352
+ log(`[archive-decisions] nothing to rotate — HOT ${usage(tiers.hot)}, WARM ${usage(tiers.warm)}, COLD ${usage(tiers.cold)}.`);
353
+ return 0;
354
+ }
355
+ verifyConservation(
356
+ [tiers.hot.entries, tiers.warm.entries, tiers.cold.entries],
357
+ [plan.hotEntries, plan.warmEntries, plan.coldEntries],
358
+ );
359
+
360
+ const summary = {
361
+ hotToWarm: plan.moves.hotToWarm.map((id) => `AD-${id}`),
362
+ warmToCold: plan.moves.warmToCold.map((id) => `AD-${id}`),
363
+ normalizeOnly: normalizeOnly.map((tier) => tier.rel),
364
+ after: {
365
+ hot: usage(tiers.hot, plan.hotEntries),
366
+ warm: usage(tiers.warm, plan.warmEntries),
367
+ cold: usage(tiers.cold, plan.coldEntries),
368
+ },
369
+ };
370
+
371
+ if (flags.dryRun) {
372
+ log('[archive-decisions] DRY-RUN — no files will be changed.');
373
+ log(JSON.stringify(summary, null, 2));
374
+ return 0;
375
+ }
376
+
377
+ const ranges = { hotEntries: plan.hotEntries, warmEntries: plan.warmEntries, coldEntries: plan.coldEntries };
378
+ const writes = [
379
+ { tier: tiers.hot, entries: plan.hotEntries, kind: 'hot' },
380
+ { tier: tiers.warm, entries: plan.warmEntries, kind: 'warm' },
381
+ { tier: tiers.cold, entries: plan.coldEntries, kind: 'cold' },
382
+ ];
383
+ // A rewrite must never claim success while a tier is STILL over its cap — normalization is
384
+ // not a licence: if the planned rendered result exceeds the budget (a genuinely exhausted
385
+ // COLD is the reachable case — the move loops already bound HOT and WARM), fail LOUD before
386
+ // any write.
387
+ for (const { tier, entries } of writes) {
388
+ const plannedLines = lineCountOf(renderTier(tier, entries));
389
+ if (plannedLines > tier.cap) {
390
+ const recovery =
391
+ tier.rel === COLD_REL
392
+ ? 'the COLD tier is exhausted — a cap raise or a new COLD file is a maintainer/agent decision'
393
+ : 'raise the cap or trim the offending entries (maintainer decision)';
394
+ throw fail(1, `refusing BEFORE any write: ${tier.rel} would still be over its cap after rotation (${plannedLines}/${tier.cap}) — ${recovery}`);
395
+ }
396
+ }
397
+ for (const { tier, entries, kind } of writes) {
398
+ // Never materialize an ABSENT tier that still has nothing to hold — a normalize-only
399
+ // rewrite of HOT must not seed empty WARM/COLD files into a project that never rotated.
400
+ if (!tier.exists && entries.length === 0) continue;
401
+ const updated = {
402
+ ...tier,
403
+ frontmatter: stampLastUpdated(tier.frontmatter, today),
404
+ preamble: updateRangeTokens(tier.preamble, kind, ranges),
405
+ };
406
+ mkdirSync(dirname(tier.path), { recursive: true });
407
+ writeFileSync(tier.path, renderTier(updated, entries), 'utf8');
408
+ }
409
+ log('[archive-decisions] rotated:');
410
+ log(` HOT→WARM: ${summary.hotToWarm.join(', ') || '(none)'}`);
411
+ log(` WARM→COLD: ${summary.warmToCold.join(', ') || '(none)'}`);
412
+ if (summary.normalizeOnly.length > 0 && summary.hotToWarm.length === 0 && summary.warmToCold.length === 0) {
413
+ log(` normalize-only rewrite (over cap on raw lines, no entry moves): ${summary.normalizeOnly.join(', ')}`);
414
+ }
415
+ log(` now: HOT ${summary.after.hot} · WARM ${summary.after.warm} · COLD ${summary.after.cold}`);
416
+ return 0;
417
+ } catch (err) {
418
+ logError(`[archive-decisions] ${err.message}`);
419
+ return err.exitCode ?? 1;
420
+ }
421
+ };
422
+
423
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
424
+ if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));