@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.
- package/CHANGELOG.md +53 -0
- package/README.md +2 -0
- package/SKILL.md +39 -7
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/changelog-skeleton.md +23 -0
- package/references/agents/gate-triage.md +23 -0
- package/references/agents/mechanical-sweep.md +20 -0
- package/references/contracts.md +1 -0
- package/references/scripts/archive-decisions.mjs +424 -0
- package/references/scripts/archive-decisions.test.mjs +365 -0
- package/references/scripts/install-git-hooks.mjs +1 -0
- package/references/templates/agent_rules.md +2 -1
- package/references/templates/gates.json +4 -0
- package/tools/cheap-agents.mjs +239 -0
- package/tools/commands.mjs +29 -6
- package/tools/known-footprint.mjs +3 -0
- package/tools/procedures.mjs +19 -0
- package/tools/run-gates.mjs +299 -0
|
@@ -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));
|