@sabaiway/agent-workflow-memory 1.12.0 → 2.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.
- package/CHANGELOG.md +52 -1
- package/README.md +2 -2
- package/SKILL.md +35 -21
- package/bin/install.mjs +32 -0
- package/capability.json +1 -1
- package/migrations/README.md +1 -1
- package/migrations/legacy-stamp-takeover.md +3 -3
- package/package.json +1 -1
- package/references/scripts/archive-decisions.mjs +705 -320
- package/references/scripts/archive-decisions.test.mjs +652 -312
- package/references/scripts/check-docs-size.mjs +35 -5
- package/references/scripts/check-docs-size.test.mjs +55 -0
- package/references/templates/adr/log.md +25 -0
- package/references/templates/adr-record.md +72 -0
- package/references/templates/decisions.md +7 -18
- package/scripts/stamp-takeover.mjs +2 -2
|
@@ -1,95 +1,87 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// One-file-per-ADR store for docs/ai/decisions.md (ADRs) — the durable replacement for the retired
|
|
3
|
+
// 3-tier cascade (HOT → WARM archive → a single COLD monolith whose cap was raised release after
|
|
4
|
+
// release). Every ADR beyond the HOT window becomes its OWN immutable record so no artifact is ever
|
|
5
|
+
// O(n) and no cap is ever raised again.
|
|
3
6
|
//
|
|
4
|
-
// HOT
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// HOT (docs/ai/decisions.md) — the active ADR window (newest at the bottom), self-bounding
|
|
8
|
+
// under its own frontmatter maxLines.
|
|
9
|
+
// STORE (docs/ai/adr/AD-NNN-slug.md) — one immutable MADR record per archived ADR (frontmatter +
|
|
10
|
+
// the verbatim `## AD-NNN — title` block). Retrieval is O(1):
|
|
11
|
+
// by id → the deterministic filename glob `AD-NNN-*.md`; by
|
|
12
|
+
// topic → grep the flat tree; by lifecycle → the two-way
|
|
13
|
+
// supersedes/supersededBy frontmatter + the [[AD-NNN]] chain.
|
|
14
|
+
// NAV (docs/ai/adr/log.md) — the ONE generated navigator: the currently-governing heads
|
|
15
|
+
// (accepted ∧ not-superseded, computed by supersession
|
|
16
|
+
// inference across the whole corpus) + a recent window. It
|
|
17
|
+
// plateaus at O(governing), never O(cumulative). Not a ledger.
|
|
11
18
|
//
|
|
12
19
|
// Modes:
|
|
13
|
-
// (default)
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// --check
|
|
17
|
-
//
|
|
20
|
+
// (default) rotate: explode the oldest HOT entries beyond the cap into adr/ records, then
|
|
21
|
+
// regenerate the navigator + docs/ai/index.md (item (h)). Monoliths present → a
|
|
22
|
+
// LOUD legacy-guard refusal ("run --migrate first"); it never half-explodes.
|
|
23
|
+
// --check verify HOT cap + adr/ store integrity + the legacy guard + navigator freshness;
|
|
24
|
+
// exit 1 on any breach. A STATED skip (exit 0) only when NO ADR substrate exists
|
|
25
|
+
// (neither decisions.md NOR docs/ai/adr/).
|
|
26
|
+
// --migrate one-time retirement of the 3-tier monoliths → per-file adr/ records. Dry-run by
|
|
27
|
+
// default (prints the file set + id diff + conservation proof, writes nothing).
|
|
28
|
+
// --migrate --apply writes a durable pre-delete snapshot, writes the records, rewrites the retained
|
|
29
|
+
// HOT preamble, and only THEN removes the monoliths — gated on conservation AND
|
|
30
|
+
// the snapshot. Re-run skips byte-identical records (crash-resumable).
|
|
31
|
+
// --write-navigator regenerate docs/ai/adr/log.md AND re-trigger the index regen (the authoring /
|
|
32
|
+
// supersession write-side; the --write-index analog).
|
|
33
|
+
// --dry-run print the planned rotation move-set, change nothing.
|
|
34
|
+
// --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs).
|
|
18
35
|
//
|
|
19
36
|
// FAIL-LOUD invariants (the Issue-009 lesson — never silently glue an entry to the previous body):
|
|
20
|
-
// • every `## ` heading
|
|
21
|
-
//
|
|
22
|
-
// • ADR ids
|
|
23
|
-
// •
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// •
|
|
27
|
-
// tiers and every entry's line count are unchanged by the plan.
|
|
28
|
-
//
|
|
29
|
-
// DELIBERATE divergence from the siblings: on a project WITHOUT docs/ai/decisions.md, `--check`
|
|
30
|
-
// reports the absence and exits 0 — the deployed pre-commit hook must never block a commit over
|
|
31
|
-
// an absent ADR substrate. (archive-changelog.mjs reads its source unconditionally and crashes
|
|
32
|
-
// ENOENT on an absent file; this script states the skip instead.)
|
|
37
|
+
// • every `## ` heading MUST parse canonically as `## AD-NNN — <title>` (AD-\d{3,}) — a malformed
|
|
38
|
+
// heading is exit 1 naming file:line, never a silent merge;
|
|
39
|
+
// • ADR ids are strictly ascending (NUMERIC, never lexical) within a tier and unique across HOT ∪ adr/;
|
|
40
|
+
// • migration is CONSERVATION-checked before any destructive write: the full multiset
|
|
41
|
+
// {id → sha256(verbatim block)} across the OLD monoliths equals {retained-HOT ∪ written records};
|
|
42
|
+
// a drop / renumber / edited-block / stray adr record fails exit 1 before any remove or overwrite;
|
|
43
|
+
// • a legacy monolith still on disk fails LOUD on default/--check (it is a half-migrated tree).
|
|
33
44
|
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// separators dropped), so when a tier is over cap on raw lines but already fits after
|
|
38
|
-
// normalization, the rotation performs a NORMALIZE-ONLY rewrite (zero entry moves, stated).
|
|
45
|
+
// docs/ai here is git-ignored, so the monoliths were NEVER committed (no VCS recovery) — every
|
|
46
|
+
// destructive --migrate --apply writes a durable snapshot to the git dir (uncommittable, the
|
|
47
|
+
// review-receipts precedent) BEFORE any delete, with a stated out-of-tree fallback off git.
|
|
39
48
|
//
|
|
40
49
|
// Dependency-free, Node >= 18. Deployed into a consumer's scripts/ like its siblings.
|
|
41
50
|
|
|
42
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
43
|
-
import { dirname, resolve } from 'node:path';
|
|
51
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
52
|
+
import { dirname, resolve, join } from 'node:path';
|
|
44
53
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
45
54
|
import { spawnSync } from 'node:child_process';
|
|
55
|
+
import { createHash } from 'node:crypto';
|
|
56
|
+
import { tmpdir } from 'node:os';
|
|
46
57
|
|
|
47
58
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
48
59
|
const DEFAULT_ROOT = resolve(__dirname, '..');
|
|
49
60
|
|
|
50
|
-
// (h) — after a rotation write, docs/ai/index.md would silently go stale (a moved ADR, or just the
|
|
51
|
-
// bumped lastUpdated), tripping the SEPARATE `--check-index` gate mid-release-matrix. So the
|
|
52
|
-
// rotation regenerates the index by REUSING the ONE generator in the sibling check-docs-size.mjs
|
|
53
|
-
// (spawned with --write-index --root=<root>). The subprocess bridges that script's ASYNC generator,
|
|
54
|
-
// so this runCli stays SYNCHRONOUS (spawnSync) — the existing sync callers/tests never ripple. It
|
|
55
|
-
// DEGRADES LOUDLY to an instruct (never a silent failure) when the sibling is absent or the
|
|
56
|
-
// regeneration fails; the --check-index gate still catches a stale index. Injectable for tests.
|
|
57
|
-
const CHECK_DOCS_SIBLING = resolve(__dirname, 'check-docs-size.mjs');
|
|
58
|
-
const INDEX_INSTRUCT = 'run `node scripts/check-docs-size.mjs --write-index` to refresh docs/ai/index.md';
|
|
59
|
-
// Exported + its filesystem edges injectable (deps) so BOTH degrade branches are unit-testable.
|
|
60
|
-
export const defaultRegenerateIndex = (root, today, deps = {}) => {
|
|
61
|
-
const exists = deps.existsSync ?? existsSync;
|
|
62
|
-
const spawn = deps.spawnSync ?? spawnSync;
|
|
63
|
-
const sibling = deps.sibling ?? CHECK_DOCS_SIBLING;
|
|
64
|
-
if (!exists(sibling)) {
|
|
65
|
-
return { ok: false, detail: `the index generator is not beside this script — ${INDEX_INSTRUCT}` };
|
|
66
|
-
}
|
|
67
|
-
// `--report` ISOLATES the index-WRITE outcome from the docs-cap-CHECK outcome: check-docs-size
|
|
68
|
-
// --write-index still WRITES the index (and still exits 2 on an empty write / rejects a genuine
|
|
69
|
-
// throw), but --report suppresses its exit-1 on an unrelated over-cap co-located doc — otherwise a
|
|
70
|
-
// benign over-cap sibling would read as an index-regeneration FAILURE (a cry-wolf on this very
|
|
71
|
-
// loud-degrade channel).
|
|
72
|
-
const r = spawn(process.execPath, [sibling, '--write-index', '--report', `--root=${root}`, `--today=${today}`], { encoding: 'utf8' });
|
|
73
|
-
if (r.error || r.status !== 0) {
|
|
74
|
-
return { ok: false, detail: `index regeneration failed (${(r.error && r.error.message) || `exit ${r.status}`}) — ${INDEX_INSTRUCT}` };
|
|
75
|
-
}
|
|
76
|
-
return { ok: true, detail: (r.stdout || '').trim() };
|
|
77
|
-
};
|
|
78
|
-
|
|
79
61
|
export const HOT_REL = 'docs/ai/decisions.md';
|
|
62
|
+
// The retired monolith tiers — still READ during migration (their entries explode into records) and
|
|
63
|
+
// still DETECTED by the legacy guard; never written by the new scheme.
|
|
80
64
|
export const WARM_REL = 'docs/ai/history/decisions-archive.md';
|
|
81
65
|
export const COLD_REL = 'docs/ai/history/decisions-archive-early.md';
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
66
|
+
export const ADR_DIR_REL = 'docs/ai/adr';
|
|
67
|
+
export const NAV_REL = 'docs/ai/adr/log.md';
|
|
68
|
+
|
|
69
|
+
// A per-record cap (generous vs the largest real ADR ~74 lines; a body over it is a genuine smell) and
|
|
70
|
+
// the navigator cap (bounded — exceeding it is the future plateau-shard trigger, Decision 8).
|
|
71
|
+
export const RECORD_CAP = 400;
|
|
72
|
+
export const NAV_MAXLINES = 200;
|
|
73
|
+
// How many most-recent ids the navigator's recent window carries (status-annotated, incl. supersessions).
|
|
74
|
+
const NAV_RECENT_WINDOW = 15;
|
|
75
|
+
|
|
76
|
+
// AD-\d{3,}: 3-digit ids stay valid, AD-1000+ parse; ordering is always NUMERIC (never lexical).
|
|
77
|
+
export const HEADING_RE = /^## AD-(\d{3,}) — (.+)$/;
|
|
87
78
|
const ANY_H2_RE = /^## /;
|
|
88
79
|
const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/;
|
|
80
|
+
const RECORD_FILE_RE = /^AD-(\d{3,})-.*\.md$/;
|
|
89
81
|
|
|
90
82
|
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
91
83
|
|
|
92
|
-
// ── parsing (strict; malformed headings are LOUD)
|
|
84
|
+
// ── parsing (strict; malformed headings are LOUD) + lifecycle extraction ───────────────
|
|
93
85
|
|
|
94
86
|
const stripTrailingSeparators = (blockLines) => {
|
|
95
87
|
const lines = [...blockLines];
|
|
@@ -99,8 +91,40 @@ const stripTrailingSeparators = (blockLines) => {
|
|
|
99
91
|
return lines;
|
|
100
92
|
};
|
|
101
93
|
|
|
94
|
+
// Lifecycle from the real corpus body forms (Decision 9), the block preserved VERBATIM:
|
|
95
|
+
// • status = the leading word of `**Status:**` (lowercased); the marker may be on its own line OR
|
|
96
|
+
// same-line after `**Date:** … · **Status:** …`, and its prose may wrap. A MISSING status line →
|
|
97
|
+
// `accepted` (the in-force default; 6 of 9 active HOT ADRs carry no status line). An unknown
|
|
98
|
+
// leading word is kept verbatim (never coerced) — governance keys on the exact 'accepted' token.
|
|
99
|
+
// • date = the `**Date:**` value up to a ` · ` separator or end of line (null when absent).
|
|
100
|
+
// • supersedes ← every `Supersedes [[AD-NNN]]`.
|
|
101
|
+
// • supersededBy ← every `Superseded by [[AD-NNN]]` / `Amended by [[AD-NNN]]`.
|
|
102
|
+
const extractLifecycle = (block) => {
|
|
103
|
+
const statusMatch = block.match(/\*\*Status:\*\*\s*([^\n]*)/);
|
|
104
|
+
let status = 'accepted';
|
|
105
|
+
if (statusMatch) {
|
|
106
|
+
const word = (statusMatch[1].trim().match(/^[A-Za-z]+/) || [''])[0].toLowerCase();
|
|
107
|
+
if (word) status = word;
|
|
108
|
+
}
|
|
109
|
+
const dateMatch = block.match(/\*\*Date:\*\*\s*([^\n·]*)/);
|
|
110
|
+
const date = dateMatch ? dateMatch[1].trim() || null : null;
|
|
111
|
+
const idsFrom = (re) => {
|
|
112
|
+
const out = [];
|
|
113
|
+
let m;
|
|
114
|
+
const g = new RegExp(re.source, 'g');
|
|
115
|
+
while ((m = g.exec(block)) !== null) out.push(m[1]);
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
const supersedes = idsFrom(/Supersedes \[\[AD-(\d{3,})\]\]/);
|
|
119
|
+
const supersededBy = [
|
|
120
|
+
...idsFrom(/Superseded by \[\[AD-(\d{3,})\]\]/),
|
|
121
|
+
...idsFrom(/Amended by \[\[AD-(\d{3,})\]\]/),
|
|
122
|
+
];
|
|
123
|
+
return { status, date, supersedes, supersededBy };
|
|
124
|
+
};
|
|
125
|
+
|
|
102
126
|
// Parse one tier's text → { frontmatter, cap, preamble, entries }. Every `## ` line must be a
|
|
103
|
-
// canonical AD heading — anything else
|
|
127
|
+
// canonical AD heading — anything else is exit 1 naming file:line (Issue-009).
|
|
104
128
|
export const parseDecisionsText = (text, label) => {
|
|
105
129
|
const fmMatch = text.match(FRONTMATTER_RE);
|
|
106
130
|
const frontmatter = fmMatch ? fmMatch[1] : '';
|
|
@@ -114,7 +138,7 @@ export const parseDecisionsText = (text, label) => {
|
|
|
114
138
|
if (!HEADING_RE.test(line)) {
|
|
115
139
|
throw fail(
|
|
116
140
|
1,
|
|
117
|
-
`${label}:${fmLines + i + 1}: non-canonical H2 heading "${line}" — every "## " heading must be \`## AD-
|
|
141
|
+
`${label}:${fmLines + i + 1}: non-canonical H2 heading "${line}" — every "## " heading must be \`## AD-NNN — <title>\` (AD-\\d{3,}; never silently glued to the previous entry; fix the heading, then re-run)`,
|
|
118
142
|
);
|
|
119
143
|
}
|
|
120
144
|
startIdxs.push(i);
|
|
@@ -127,12 +151,14 @@ export const parseDecisionsText = (text, label) => {
|
|
|
127
151
|
const end = i + 1 < startIdxs.length ? startIdxs[i + 1] : lines.length;
|
|
128
152
|
const blockLines = stripTrailingSeparators(lines.slice(idx, end));
|
|
129
153
|
const m = HEADING_RE.exec(lines[idx]);
|
|
154
|
+
const block = blockLines.join('\n');
|
|
130
155
|
return {
|
|
131
156
|
id: m[1],
|
|
132
157
|
idNum: Number(m[1]),
|
|
133
158
|
title: m[2],
|
|
134
|
-
block
|
|
159
|
+
block,
|
|
135
160
|
lineCount: blockLines.length,
|
|
161
|
+
...extractLifecycle(block),
|
|
136
162
|
};
|
|
137
163
|
});
|
|
138
164
|
|
|
@@ -140,7 +166,7 @@ export const parseDecisionsText = (text, label) => {
|
|
|
140
166
|
if (entries[i].idNum <= entries[i - 1].idNum) {
|
|
141
167
|
throw fail(
|
|
142
168
|
1,
|
|
143
|
-
`${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`,
|
|
169
|
+
`${label}: AD-${entries[i].id} appears after AD-${entries[i - 1].id} — ids must be strictly ascending (numeric) within a tier (oldest at the top); refusing to rotate a disordered file`,
|
|
144
170
|
);
|
|
145
171
|
}
|
|
146
172
|
}
|
|
@@ -149,175 +175,390 @@ export const parseDecisionsText = (text, label) => {
|
|
|
149
175
|
return { frontmatter, cap: capMatch ? Number(capMatch[1]) : null, preamble, entries };
|
|
150
176
|
};
|
|
151
177
|
|
|
152
|
-
// ──
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
178
|
+
// ── slug + record rendering (frontmatter built INLINE, Decision 1 — no runtime template read) ──
|
|
179
|
+
|
|
180
|
+
const SLUG_MAX = 60;
|
|
181
|
+
|
|
182
|
+
export const slugify = (title) => {
|
|
183
|
+
const base = title
|
|
184
|
+
.toLowerCase()
|
|
185
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
186
|
+
.replace(/^-+|-+$/g, '');
|
|
187
|
+
if (base.length <= SLUG_MAX) return base || 'record';
|
|
188
|
+
const cut = base.slice(0, SLUG_MAX);
|
|
189
|
+
const lastDash = cut.lastIndexOf('-');
|
|
190
|
+
return (lastDash > 0 ? cut.slice(0, lastDash) : cut) || 'record';
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const recordFileName = (id, slug) => `AD-${id}-${slug}.md`;
|
|
194
|
+
|
|
195
|
+
const yamlIdList = (ids) => (ids.length > 0 ? `[${ids.map((id) => `AD-${id}`).join(', ')}]` : '[]');
|
|
196
|
+
|
|
197
|
+
// The record's 6-field frontmatter + the 4 lifecycle keys (Decision 1). Ends with `---\n` like the
|
|
198
|
+
// sibling CREATED_FRONTMATTER pattern; the block follows after one blank line.
|
|
199
|
+
export const buildRecordFrontmatter = (entry, today) =>
|
|
200
|
+
[
|
|
201
|
+
'---',
|
|
202
|
+
'type: adr',
|
|
203
|
+
`lastUpdated: ${today}`,
|
|
204
|
+
'scope: permanent',
|
|
205
|
+
'staleAfter: never',
|
|
206
|
+
'owner: none',
|
|
207
|
+
`maxLines: ${RECORD_CAP}`,
|
|
208
|
+
`status: ${entry.status}`,
|
|
209
|
+
`date: ${entry.date ?? 'unknown'}`,
|
|
210
|
+
`supersedes: ${yamlIdList(entry.supersedes)}`,
|
|
211
|
+
`supersededBy: ${yamlIdList(entry.supersededBy)}`,
|
|
212
|
+
'---',
|
|
213
|
+
'',
|
|
214
|
+
].join('\n');
|
|
215
|
+
|
|
216
|
+
export const renderRecord = (record) => `${record.frontmatter}\n${record.block}\n`;
|
|
217
|
+
|
|
218
|
+
// explode entries → immutable records (id, slug, filename, frontmatter, verbatim block).
|
|
219
|
+
export const explode = (entries, today) =>
|
|
220
|
+
entries.map((entry) => {
|
|
221
|
+
const slug = slugify(entry.title);
|
|
183
222
|
return {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
frontmatter:
|
|
189
|
-
|
|
190
|
-
preamble: createdPreamble,
|
|
191
|
-
entries: [],
|
|
223
|
+
id: entry.id,
|
|
224
|
+
idNum: entry.idNum,
|
|
225
|
+
slug,
|
|
226
|
+
fileName: recordFileName(entry.id, slug),
|
|
227
|
+
frontmatter: buildRecordFrontmatter(entry, today),
|
|
228
|
+
block: entry.block,
|
|
192
229
|
};
|
|
193
|
-
};
|
|
194
|
-
const hot = load(HOT_REL, { createdCap: 0, createdPreamble: '' }); // HOT is never created here
|
|
195
|
-
const warm = load(WARM_REL, { createdCap: DEFAULT_WARM_CAP, createdPreamble: CREATED_WARM_PREAMBLE });
|
|
196
|
-
const cold = load(COLD_REL, { createdCap: DEFAULT_COLD_CAP, createdPreamble: CREATED_COLD_PREAMBLE });
|
|
230
|
+
});
|
|
197
231
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
232
|
+
// ── conservation (fail-loud, partition-preserving, extra-aware — Decision 4) ────────────
|
|
233
|
+
|
|
234
|
+
export const blockHash = (block) => createHash('sha256').update(block, 'utf8').digest('hex');
|
|
235
|
+
|
|
236
|
+
const multiset = (items) => {
|
|
237
|
+
const map = new Map();
|
|
238
|
+
for (const { id, block } of items) {
|
|
239
|
+
if (map.has(id)) throw fail(1, `conservation: AD-${id} appears twice on the same side — a duplicate id (no double-count allowed)`);
|
|
240
|
+
map.set(id, blockHash(block));
|
|
204
241
|
}
|
|
205
|
-
return
|
|
242
|
+
return map;
|
|
206
243
|
};
|
|
207
244
|
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
245
|
+
// OLD = every block across the migrated tiers; NEW = retained-HOT ∪ written record bodies. Every OLD
|
|
246
|
+
// id must appear exactly once on the NEW side with a byte-identical block; a NEW id absent from OLD is
|
|
247
|
+
// a stray (crashed-run remnant / invented history) → refuse. No loss, no double-count, no edit.
|
|
248
|
+
export const verifyConservation = (oldItems, newItems) => {
|
|
249
|
+
const before = multiset(oldItems);
|
|
250
|
+
const after = multiset(newItems);
|
|
251
|
+
for (const [id, hash] of before) {
|
|
252
|
+
if (!after.has(id)) throw fail(1, `conservation violation: AD-${id} is present in the OLD tiers but absent from the migrated store — refusing to write (an ADR would be lost)`);
|
|
253
|
+
if (after.get(id) !== hash) throw fail(1, `conservation violation: AD-${id}'s block changed during migration (hash mismatch) — the block must move verbatim; refusing to write`);
|
|
254
|
+
}
|
|
255
|
+
for (const id of after.keys()) {
|
|
256
|
+
if (!before.has(id)) throw fail(1, `conservation violation: AD-${id} is present in the migrated store but NOT in the OLD tiers — a stray/invented record; refuse or quarantine it, then re-run`);
|
|
257
|
+
}
|
|
214
258
|
};
|
|
215
259
|
|
|
216
|
-
|
|
260
|
+
// ── tier / store IO ─────────────────────────────────────────────────────────────────────
|
|
217
261
|
|
|
218
|
-
|
|
262
|
+
export const lineCountOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
219
263
|
|
|
220
|
-
export const
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
264
|
+
export const loadHot = (root) => {
|
|
265
|
+
const path = resolve(root, HOT_REL);
|
|
266
|
+
const raw = readFileSync(path, 'utf8');
|
|
267
|
+
const parsed = parseDecisionsText(raw, HOT_REL);
|
|
268
|
+
if (parsed.cap === null) {
|
|
269
|
+
throw fail(1, `${HOT_REL}: frontmatter carries no maxLines cap — refusing to operate against an unknown budget (add a maxLines field to the frontmatter)`);
|
|
270
|
+
}
|
|
271
|
+
return { rel: HOT_REL, path, raw, rawLines: lineCountOf(raw), ...parsed };
|
|
272
|
+
};
|
|
225
273
|
|
|
226
|
-
|
|
274
|
+
const loadMonolith = (root, rel) => {
|
|
275
|
+
const path = resolve(root, rel);
|
|
276
|
+
if (!existsSync(path)) return { rel, path, exists: false, entries: [] };
|
|
277
|
+
const raw = readFileSync(path, 'utf8');
|
|
278
|
+
const parsed = parseDecisionsText(raw, rel);
|
|
279
|
+
return { rel, path, exists: true, raw, ...parsed };
|
|
280
|
+
};
|
|
227
281
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
282
|
+
export const monolithsPresent = (root) =>
|
|
283
|
+
[WARM_REL, COLD_REL].filter((rel) => existsSync(resolve(root, rel)));
|
|
284
|
+
|
|
285
|
+
export const NAV_BASENAME = 'log.md';
|
|
286
|
+
|
|
287
|
+
// Read the immutable adr/ store → one entry per AD-NNN-slug.md record (the navigator log.md
|
|
288
|
+
// excluded). Any UNEXPECTED markdown file in the tree is a LOUD integrity failure — never silently
|
|
289
|
+
// dropped from the store (the collapse would otherwise hide it from both navigator and index). The
|
|
290
|
+
// heading id must match the filename id.
|
|
291
|
+
export const loadAdrStore = (root) => {
|
|
292
|
+
const dir = resolve(root, ADR_DIR_REL);
|
|
293
|
+
if (!existsSync(dir)) return [];
|
|
294
|
+
const records = [];
|
|
295
|
+
const seenIds = new Map(); // the ROOT store invariant: exactly one file per id (never a silent dup)
|
|
296
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
297
|
+
const name = entry.name;
|
|
298
|
+
if (entry.isDirectory()) {
|
|
299
|
+
throw fail(1, `${ADR_DIR_REL}/${name}/: the adr/ store is a FLAT directory — a nested subdirectory would hide records from the navigator + integrity checks; remove it, then re-run`);
|
|
231
300
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
`(${linesOf(cold, coldEntries)}/${cold.cap} now, ${linesOf(cold, next)} after) — the COLD tier is exhausted; ` +
|
|
239
|
-
'a cap raise or a new COLD file is a maintainer/agent decision, this script only moves entries',
|
|
240
|
-
);
|
|
301
|
+
if (name === NAV_BASENAME) continue; // the navigator, not a record
|
|
302
|
+
if (!RECORD_FILE_RE.test(name)) {
|
|
303
|
+
if (name.endsWith('.md')) {
|
|
304
|
+
throw fail(1, `${ADR_DIR_REL}/${name}: unexpected markdown file in the adr/ store — only the navigator ${NAV_BASENAME} and AD-NNN-slug.md records belong here; rename/remove it, then re-run (never silently hidden)`);
|
|
305
|
+
}
|
|
306
|
+
continue; // a non-markdown stray is ignored by the ADR store (and by the cap-validator)
|
|
241
307
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
308
|
+
const rel = `${ADR_DIR_REL}/${name}`;
|
|
309
|
+
const parsed = parseDecisionsText(readFileSync(resolve(dir, name), 'utf8'), rel);
|
|
310
|
+
if (parsed.entries.length !== 1) {
|
|
311
|
+
throw fail(1, `${rel}: an adr/ record must hold exactly one ADR block (found ${parsed.entries.length}); refusing to treat it as a record`);
|
|
312
|
+
}
|
|
313
|
+
const record = parsed.entries[0];
|
|
314
|
+
const fnId = name.match(RECORD_FILE_RE)[1];
|
|
315
|
+
if (fnId !== record.id) {
|
|
316
|
+
throw fail(1, `${rel}: filename id AD-${fnId} does not match the heading id AD-${record.id} — a corrupt/renamed record; refusing to trust it`);
|
|
317
|
+
}
|
|
318
|
+
// Fail LOUD at the SOURCE on a duplicate id (two files for one AD-NNN) so no caller ever operates
|
|
319
|
+
// on — or silently dedups away — a corrupt store (rotate/migrate/navigator/check all read here).
|
|
320
|
+
if (seenIds.has(record.id)) {
|
|
321
|
+
throw fail(1, `${ADR_DIR_REL}/: two records for AD-${record.id} (${seenIds.get(record.id)} and ${name}) — the store must hold exactly one file per id; remove the stale one, then re-run`);
|
|
322
|
+
}
|
|
323
|
+
seenIds.set(record.id, name);
|
|
324
|
+
records.push({ ...record, fileName: name, rel });
|
|
325
|
+
}
|
|
326
|
+
return records;
|
|
327
|
+
};
|
|
246
328
|
|
|
247
|
-
|
|
248
|
-
while (linesOf(warm, warmEntries) > warm.cap) rollWarmToCold();
|
|
249
|
-
};
|
|
329
|
+
// ── HOT rendering + preamble rewrite (Decision 15) ──────────────────────────────────────
|
|
250
330
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
331
|
+
export const renderTier = (frontmatter, preamble, entries) => {
|
|
332
|
+
const blocks = entries.map((entry) => entry.block).join('\n\n');
|
|
333
|
+
const body = [preamble, blocks].filter((part) => part !== '').join('\n\n');
|
|
334
|
+
return `${frontmatter}\n${body}\n`;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const NEW_ARCHIVE_LINE = (oldestId) =>
|
|
338
|
+
`> **Archive:** older ADRs are stored one file per record under [\`adr/\`](./adr/) — see the active-set ` +
|
|
339
|
+
`navigator [\`adr/log.md\`](./adr/log.md). This file carries the active window (AD-${oldestId} onward).`;
|
|
340
|
+
|
|
341
|
+
// Repoint the retained HOT preamble at the navigator and DROP the dead monolith links/"rolled to
|
|
342
|
+
// COLD" prose (Decision 15 — not just a range re-stamp). Any line that is the `**Archive:**` marker
|
|
343
|
+
// OR names a retired monolith is dropped; the FIRST such line is replaced by the navigator pointer.
|
|
344
|
+
// A preamble that never mentioned the archive is left untouched (a consumer's own wording preserved).
|
|
345
|
+
// Assumes the archive prose is one logical blockquote line (the shipped shape); a hand-wrapped
|
|
346
|
+
// continuation line carrying NEITHER marker is left in place (a stated, benign residual).
|
|
347
|
+
export const rewriteHotPreamble = (preamble, oldestId) => {
|
|
348
|
+
const isArchiveLine = (line) => /decisions-archive/.test(line) || /\*\*Archive:\*\*/.test(line);
|
|
349
|
+
const out = [];
|
|
350
|
+
let replaced = false;
|
|
351
|
+
for (const line of preamble.split('\n')) {
|
|
352
|
+
if (isArchiveLine(line)) {
|
|
353
|
+
if (!replaced) {
|
|
354
|
+
out.push(NEW_ARCHIVE_LINE(oldestId));
|
|
355
|
+
replaced = true;
|
|
356
|
+
}
|
|
357
|
+
continue;
|
|
255
358
|
}
|
|
256
|
-
|
|
257
|
-
warmEntries.push(moved);
|
|
258
|
-
moves.hotToWarm.push(moved.id);
|
|
259
|
-
ensureWarmFits();
|
|
359
|
+
out.push(line);
|
|
260
360
|
}
|
|
361
|
+
return out.join('\n');
|
|
362
|
+
};
|
|
261
363
|
|
|
262
|
-
|
|
364
|
+
const stampLastUpdated = (frontmatter, today) => frontmatter.replace(/^lastUpdated: .*$/m, `lastUpdated: ${today}`);
|
|
365
|
+
|
|
366
|
+
// ── the deterministic HOT-bounding plan (oldest-out until the render fits its cap) ──────
|
|
367
|
+
|
|
368
|
+
export const boundHot = (candidates, frontmatter, preamble, cap) => {
|
|
369
|
+
const retained = [...candidates].sort((a, b) => a.idNum - b.idNum);
|
|
370
|
+
const toExplode = [];
|
|
371
|
+
while (retained.length > 1 && lineCountOf(renderTier(frontmatter, preamble, retained)) > cap) {
|
|
372
|
+
toExplode.unshift(retained.shift());
|
|
373
|
+
}
|
|
374
|
+
return { retained, toExplode };
|
|
263
375
|
};
|
|
264
376
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
377
|
+
// ── navigator generation (governance COMPUTED by supersession inference — Decision 8) ───
|
|
378
|
+
|
|
379
|
+
// The set of ids superseded BY ANOTHER ADR: an entry's own `Superseded by / Amended by [[X]]`
|
|
380
|
+
// declaration retires that entry, and an IN-FORCE (accepted) entry's `Supersedes [[Y]]` retires Y
|
|
381
|
+
// — so a new superseding ADR needs NO predecessor-file mutation. A Supersedes edge from a NON-accepted
|
|
382
|
+
// entry (proposed/rejected/superseded) is NOT effective and never retires its target.
|
|
383
|
+
export const IN_FORCE_STATUS = 'accepted';
|
|
384
|
+
|
|
385
|
+
export const computeSupersededSet = (entries) => {
|
|
386
|
+
const superseded = new Set();
|
|
387
|
+
for (const entry of entries) {
|
|
388
|
+
if (entry.supersededBy.length > 0) superseded.add(entry.id);
|
|
389
|
+
if (entry.status === IN_FORCE_STATUS) {
|
|
390
|
+
for (const id of entry.supersedes) superseded.add(id);
|
|
391
|
+
}
|
|
277
392
|
}
|
|
393
|
+
return superseded;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// Governing heads = in-force (accepted) AND not superseded by another. A non-accepted status
|
|
397
|
+
// (proposed/rejected/superseded/amended/deprecated) is never a governing head.
|
|
398
|
+
export const computeGoverningIds = (entries) => {
|
|
399
|
+
const superseded = computeSupersededSet(entries);
|
|
400
|
+
return new Set(entries.filter((e) => e.status === IN_FORCE_STATUS && !superseded.has(e.id)).map((e) => e.id));
|
|
278
401
|
};
|
|
279
402
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
403
|
+
const NAV_FRONTMATTER = (today) =>
|
|
404
|
+
`---\ntype: reference\nlastUpdated: ${today}\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${NAV_MAXLINES}\n---\n`;
|
|
405
|
+
|
|
406
|
+
// Build docs/ai/adr/log.md from the WHOLE corpus (HOT entries ∪ adr/ records). Governing heads
|
|
407
|
+
// (accepted ∧ not-superseded) sorted newest-first; superseded ADRs drop OUT (still reachable by
|
|
408
|
+
// filename/grep/chain) so the navigator plateaus at O(governing). A recent window follows, status-
|
|
409
|
+
// annotated, so recent supersessions stay visible. On-demand (type: reference), never a full ledger.
|
|
410
|
+
export const buildNavigator = (corpus, today) => {
|
|
411
|
+
const entries = [...corpus].sort((a, b) => a.idNum - b.idNum);
|
|
412
|
+
const superseded = computeSupersededSet(entries);
|
|
413
|
+
const governingIds = computeGoverningIds(entries);
|
|
414
|
+
const governing = entries.filter((entry) => governingIds.has(entry.id));
|
|
415
|
+
const govLines = [...governing]
|
|
416
|
+
.reverse()
|
|
417
|
+
.map((entry) => {
|
|
418
|
+
const where = entry.fileName ? `[\`${entry.fileName}\`](./${entry.fileName})` : `[\`../decisions.md\`](../decisions.md)`;
|
|
419
|
+
return `| AD-${entry.id} | ${entry.title.replace(/\|/g, '\\|')} | ${where} |`;
|
|
420
|
+
});
|
|
421
|
+
const recent = [...entries].slice(-NAV_RECENT_WINDOW).reverse().map((entry) => {
|
|
422
|
+
const state = governingIds.has(entry.id) ? 'governing' : superseded.has(entry.id) ? 'superseded' : entry.status;
|
|
423
|
+
const where = entry.fileName ? `adr/${entry.fileName}` : 'decisions.md (HOT)';
|
|
424
|
+
return `- **AD-${entry.id}** — ${state} — \`${where}\``;
|
|
301
425
|
});
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
426
|
+
const oldestId = entries.length > 0 ? entries[0].id : '000';
|
|
427
|
+
const newestId = entries.length > 0 ? entries[entries.length - 1].id : '000';
|
|
428
|
+
const body = [
|
|
429
|
+
`# ADR Navigator — active set (AD-${oldestId} … AD-${newestId})`,
|
|
430
|
+
'',
|
|
431
|
+
'> **Auto-generated** (`archive-decisions.mjs --write-navigator`). The governing heads only —',
|
|
432
|
+
'> an ADR superseded/amended by another drops OUT (still reachable by filename, grep, or the',
|
|
433
|
+
`> \`[[AD-NNN]]\` chain). The HOT window lives in [\`../decisions.md\`](../decisions.md); every`,
|
|
434
|
+
'> archived record is one file in this directory. This is a navigator, never a full ledger.',
|
|
435
|
+
'',
|
|
436
|
+
`## Governing (${governing.length}) — accepted & not superseded`,
|
|
437
|
+
'',
|
|
438
|
+
'| ADR | Title | Record |',
|
|
439
|
+
'|-----|-------|--------|',
|
|
440
|
+
...govLines,
|
|
441
|
+
'',
|
|
442
|
+
`## Recent (${Math.min(NAV_RECENT_WINDOW, entries.length)})`,
|
|
443
|
+
'',
|
|
444
|
+
...recent,
|
|
445
|
+
].join('\n');
|
|
446
|
+
return `${NAV_FRONTMATTER(today)}\n${body}\n`;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// ── snapshot (durable, pre-delete — Decision 5) ─────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
const SNAPSHOT_PREFIX = 'agent-workflow-adr-migration-snapshot';
|
|
452
|
+
|
|
453
|
+
const resolveGitDir = (root, spawn) => {
|
|
454
|
+
const r = spawn('git', ['rev-parse', '--absolute-git-dir'], { cwd: root, encoding: 'utf8' });
|
|
455
|
+
return r && r.status === 0 && r.stdout ? r.stdout.trim() : null;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
// Snapshot the docs the migration is about to destroy (decisions.md + both monoliths) into the git
|
|
459
|
+
// dir (uncommittable — never in the work tree, never stageable). Off git → a stated tmpdir fallback;
|
|
460
|
+
// fails LOUD if neither base is writable. Returns { dir, base } for the caller to report.
|
|
461
|
+
export const writeSnapshot = (root, files, deps = {}) => {
|
|
462
|
+
const spawn = deps.spawnSync ?? spawnSync;
|
|
463
|
+
const stamp = deps.stamp ?? `${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
464
|
+
const fallbackBase = deps.snapshotFallbackBase ?? tmpdir();
|
|
465
|
+
const gitDir = resolveGitDir(root, spawn);
|
|
466
|
+
const bases = gitDir ? [gitDir, fallbackBase] : [fallbackBase];
|
|
467
|
+
let lastErr = null;
|
|
468
|
+
for (const base of bases) {
|
|
469
|
+
const dir = resolve(base, `${SNAPSHOT_PREFIX}-${stamp}`);
|
|
470
|
+
try {
|
|
471
|
+
mkdirSync(dir, { recursive: true });
|
|
472
|
+
for (const { rel, content } of files) {
|
|
473
|
+
writeFileSync(resolve(dir, rel.replace(/\//g, '__')), content, 'utf8');
|
|
474
|
+
}
|
|
475
|
+
return { dir, base, viaGitDir: base === gitDir };
|
|
476
|
+
} catch (err) {
|
|
477
|
+
lastErr = err;
|
|
478
|
+
}
|
|
305
479
|
}
|
|
306
|
-
|
|
480
|
+
throw fail(1, `refusing to migrate: no writable snapshot location (git dir + fallback both failed: ${lastErr && lastErr.message}) — a durable pre-delete snapshot is mandatory`);
|
|
307
481
|
};
|
|
308
482
|
|
|
309
|
-
|
|
483
|
+
// ── index regeneration (item (h) — reuse the ONE generator in check-docs-size.mjs) ──────
|
|
484
|
+
|
|
485
|
+
const CHECK_DOCS_SIBLING = resolve(__dirname, 'check-docs-size.mjs');
|
|
486
|
+
const INDEX_INSTRUCT = 'run `node scripts/check-docs-size.mjs --write-index` to refresh docs/ai/index.md';
|
|
487
|
+
|
|
488
|
+
export const defaultRegenerateIndex = (root, today, deps = {}) => {
|
|
489
|
+
const exists = deps.existsSync ?? existsSync;
|
|
490
|
+
const spawn = deps.spawnSync ?? spawnSync;
|
|
491
|
+
const sibling = deps.sibling ?? CHECK_DOCS_SIBLING;
|
|
492
|
+
if (!exists(sibling)) {
|
|
493
|
+
return { ok: false, detail: `the index generator is not beside this script — ${INDEX_INSTRUCT}` };
|
|
494
|
+
}
|
|
495
|
+
const r = spawn(process.execPath, [sibling, '--write-index', '--report', `--root=${root}`, `--today=${today}`], { encoding: 'utf8' });
|
|
496
|
+
if (r.error || r.status !== 0) {
|
|
497
|
+
return { ok: false, detail: `index regeneration failed (${(r.error && r.error.message) || `exit ${r.status}`}) — ${INDEX_INSTRUCT}` };
|
|
498
|
+
}
|
|
499
|
+
return { ok: true, detail: (r.stdout || '').trim() };
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// ── shared store-write helpers ──────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
const writeRecords = (root, records) => {
|
|
505
|
+
const dir = resolve(root, ADR_DIR_REL);
|
|
506
|
+
mkdirSync(dir, { recursive: true });
|
|
507
|
+
// Index the existing filenames by id ONCE (O(store), not O(records × store)) so a stale same-id
|
|
508
|
+
// file under a DIVERGENT slug (a retitled or prior-run remnant) can be pruned — exactly one file
|
|
509
|
+
// per id survives, the canonical slug wins.
|
|
510
|
+
const existingById = new Map();
|
|
511
|
+
for (const name of readdirSync(dir)) {
|
|
512
|
+
const m = name.match(RECORD_FILE_RE);
|
|
513
|
+
if (!m) continue;
|
|
514
|
+
const arr = existingById.get(m[1]);
|
|
515
|
+
if (arr) arr.push(name);
|
|
516
|
+
else existingById.set(m[1], [name]);
|
|
517
|
+
}
|
|
518
|
+
let written = 0;
|
|
519
|
+
let skipped = 0;
|
|
520
|
+
for (const record of records) {
|
|
521
|
+
for (const name of existingById.get(record.id) ?? []) {
|
|
522
|
+
if (name !== record.fileName) rmSync(resolve(dir, name));
|
|
523
|
+
}
|
|
524
|
+
const path = resolve(dir, record.fileName);
|
|
525
|
+
const body = renderRecord(record);
|
|
526
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === body) {
|
|
527
|
+
skipped += 1; // crash-resumable: a byte-identical record is left as is
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
writeFileSync(path, body, 'utf8');
|
|
531
|
+
written += 1;
|
|
532
|
+
}
|
|
533
|
+
return { written, skipped };
|
|
534
|
+
};
|
|
310
535
|
|
|
311
|
-
|
|
536
|
+
const writeNavigatorFile = (root, corpus, today) => {
|
|
537
|
+
const dir = resolve(root, ADR_DIR_REL);
|
|
538
|
+
mkdirSync(dir, { recursive: true });
|
|
539
|
+
writeFileSync(resolve(root, NAV_REL), buildNavigator(corpus, today), 'utf8');
|
|
540
|
+
};
|
|
312
541
|
|
|
313
|
-
const
|
|
542
|
+
const writeHot = (root, hot, retained, today) => {
|
|
543
|
+
const oldestId = retained.length > 0 ? retained[0].id : hot.entries.length > 0 ? hot.entries[0].id : '000';
|
|
544
|
+
const preamble = rewriteHotPreamble(hot.preamble, oldestId);
|
|
545
|
+
const frontmatter = stampLastUpdated(hot.frontmatter, today);
|
|
546
|
+
writeFileSync(hot.path, renderTier(frontmatter, preamble, retained), 'utf8');
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
const USAGE = 'Usage: archive-decisions.mjs [--check|--migrate [--apply]|--write-navigator|--dry-run] [--today=YYYY-MM-DD]';
|
|
314
552
|
|
|
315
553
|
const parseArgs = (argv) => {
|
|
316
|
-
const flags = {
|
|
554
|
+
const flags = { check: false, migrate: false, apply: false, writeNavigator: false, dryRun: false, help: false };
|
|
317
555
|
let today = null;
|
|
318
556
|
for (const arg of argv) {
|
|
319
|
-
if (arg === '--
|
|
320
|
-
else if (arg === '--
|
|
557
|
+
if (arg === '--check') flags.check = true;
|
|
558
|
+
else if (arg === '--migrate') flags.migrate = true;
|
|
559
|
+
else if (arg === '--apply') flags.apply = true;
|
|
560
|
+
else if (arg === '--write-navigator') flags.writeNavigator = true;
|
|
561
|
+
else if (arg === '--dry-run') flags.dryRun = true;
|
|
321
562
|
else if (arg === '--help' || arg === '-h') flags.help = true;
|
|
322
563
|
else if (arg.startsWith('--today=')) today = arg.slice('--today='.length);
|
|
323
564
|
else throw fail(2, `Unknown argument: ${arg}\n${USAGE}`);
|
|
@@ -325,8 +566,259 @@ const parseArgs = (argv) => {
|
|
|
325
566
|
return { flags, today };
|
|
326
567
|
};
|
|
327
568
|
|
|
569
|
+
// Store integrity: HOT ∪ adr/ ids are unique (an ADR lives in exactly one place) AND partitioned —
|
|
570
|
+
// every archived record holds a strictly-OLDER (smaller) id than every HOT entry. NUMERIC throughout.
|
|
571
|
+
const assertStoreIntegrity = (hotEntries, adrEntries) => {
|
|
572
|
+
const seen = new Map();
|
|
573
|
+
for (const e of [...adrEntries, ...hotEntries]) {
|
|
574
|
+
const where = e.fileName ? `adr/${e.fileName}` : HOT_REL;
|
|
575
|
+
if (seen.has(e.id)) {
|
|
576
|
+
throw fail(1, `duplicate ADR id AD-${e.id} across the store (${seen.get(e.id)} and ${where}) — an ADR must live in exactly one place; refusing`);
|
|
577
|
+
}
|
|
578
|
+
seen.set(e.id, where);
|
|
579
|
+
}
|
|
580
|
+
// The adr/ store is the past, the HOT window is the present; an interleaved id is corruption.
|
|
581
|
+
if (adrEntries.length > 0 && hotEntries.length > 0) {
|
|
582
|
+
const newestAdr = adrEntries.reduce((a, b) => (b.idNum > a.idNum ? b : a));
|
|
583
|
+
const oldestHot = hotEntries.reduce((a, b) => (b.idNum < a.idNum ? b : a));
|
|
584
|
+
if (newestAdr.idNum >= oldestHot.idNum) {
|
|
585
|
+
throw fail(1, `store partition violated: archived AD-${newestAdr.id} (adr/${newestAdr.fileName}) is not older than active AD-${oldestHot.id} (${HOT_REL}) — the adr/ store must hold strictly-older ids than the HOT window; refusing`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const runMigrate = (root, flags, today, deps, log, logError) => {
|
|
591
|
+
const present = monolithsPresent(root);
|
|
592
|
+
const hasHot = existsSync(resolve(root, HOT_REL));
|
|
593
|
+
if (present.length === 0) {
|
|
594
|
+
if (existsSync(resolve(root, ADR_DIR_REL))) {
|
|
595
|
+
log('[archive-decisions] already migrated — no legacy monolith present; nothing to do.');
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
log('[archive-decisions] nothing to migrate — no legacy decisions-archive monolith found.');
|
|
599
|
+
return 0;
|
|
600
|
+
}
|
|
601
|
+
if (!hasHot) throw fail(1, `${HOT_REL} not found but a legacy monolith is present — a broken tree; restore ${HOT_REL} before migrating.`);
|
|
602
|
+
|
|
603
|
+
const hot = loadHot(root);
|
|
604
|
+
const warm = loadMonolith(root, WARM_REL);
|
|
605
|
+
const cold = loadMonolith(root, COLD_REL);
|
|
606
|
+
const existingStore = loadAdrStore(root);
|
|
607
|
+
|
|
608
|
+
// Build the FULL ADR corpus as the UNION of every CURRENT source (HOT ∪ monoliths ∪ adr/ store),
|
|
609
|
+
// keyed by id — the crash-safe / idempotent core (internal-sweep major). The destructive apply
|
|
610
|
+
// trims HOT and deletes the monoliths NON-atomically, so on a resume a source may be gone while
|
|
611
|
+
// its already-written adr record survives; deriving the corpus from the union (not just the
|
|
612
|
+
// surviving monoliths + trimmed HOT) means a re-run reconstructs the SAME corpus and repartitions
|
|
613
|
+
// to the SAME target — it never accuses its own correct records of being "invented history".
|
|
614
|
+
// An id in two sources MUST carry a byte-identical block (else a corrupt / hand-edited resume
|
|
615
|
+
// artifact → FAIL before any write, never silently overwritten).
|
|
616
|
+
const corpusById = new Map();
|
|
617
|
+
const addEntry = (entry, where) => {
|
|
618
|
+
const seen = corpusById.get(entry.id);
|
|
619
|
+
if (seen) {
|
|
620
|
+
if (seen.block !== entry.block) {
|
|
621
|
+
throw fail(1, `AD-${entry.id} appears in ${seen.where} and ${where} with DIFFERENT bodies — a corrupt or hand-edited store/resume artifact; resolve it by hand, refusing to migrate`);
|
|
622
|
+
}
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
corpusById.set(entry.id, { ...entry, where });
|
|
626
|
+
};
|
|
627
|
+
for (const entry of [...warm.entries, ...cold.entries]) addEntry(entry, 'a legacy monolith');
|
|
628
|
+
for (const entry of hot.entries) addEntry(entry, HOT_REL);
|
|
629
|
+
for (const entry of existingStore) addEntry(entry, entry.rel);
|
|
630
|
+
const allEntries = [...corpusById.values()].sort((a, b) => a.idNum - b.idNum);
|
|
631
|
+
|
|
632
|
+
// Retain the bounded CURRENT HOT window (never repopulated from archived entries); everything else
|
|
633
|
+
// in the corpus becomes an adr/ record. The preamble is rewritten first so the cap arithmetic
|
|
634
|
+
// reflects the post-migration (shorter) preamble.
|
|
635
|
+
const provisionalOldest = hot.entries.length > 0 ? hot.entries[0].id : allEntries.length > 0 ? allEntries[0].id : '000';
|
|
636
|
+
const rewrittenPreamble = rewriteHotPreamble(hot.preamble, provisionalOldest);
|
|
637
|
+
const { retained } = boundHot(hot.entries, hot.frontmatter, rewrittenPreamble, hot.cap ?? Infinity);
|
|
638
|
+
const retainedIds = new Set(retained.map((e) => e.id));
|
|
639
|
+
const toExplode = allEntries.filter((e) => !retainedIds.has(e.id));
|
|
640
|
+
const records = explode(toExplode, today);
|
|
641
|
+
|
|
642
|
+
// Conservation: a PURE repartition of the corpus — retained-HOT ∪ written records == the corpus,
|
|
643
|
+
// nothing lost, added, double-counted, or edited.
|
|
644
|
+
const oldItems = allEntries.map((e) => ({ id: e.id, block: e.block }));
|
|
645
|
+
const newItems = [
|
|
646
|
+
...retained.map((e) => ({ id: e.id, block: e.block })),
|
|
647
|
+
...records.map((r) => ({ id: r.id, block: r.block })),
|
|
648
|
+
];
|
|
649
|
+
verifyConservation(oldItems, newItems);
|
|
650
|
+
// Integrity over the FULL final store — existing records ∪ freshly-exploded — not just the new
|
|
651
|
+
// writes: a pre-existing adr/ record whose id also stays RETAINED in HOT would otherwise leave the
|
|
652
|
+
// ADR in two places (codex R4). Same full-store pattern as runRotate.
|
|
653
|
+
const finalStoreById = new Map(existingStore.map((e) => [e.id, { id: e.id, idNum: e.idNum, fileName: e.fileName }]));
|
|
654
|
+
for (const r of records) finalStoreById.set(r.id, { id: r.id, idNum: r.idNum, fileName: r.fileName });
|
|
655
|
+
assertStoreIntegrity(retained, [...finalStoreById.values()]);
|
|
656
|
+
|
|
657
|
+
const summary = {
|
|
658
|
+
records: records.map((r) => r.fileName),
|
|
659
|
+
retainedHot: retained.map((e) => `AD-${e.id}`),
|
|
660
|
+
monolithsRetired: present,
|
|
661
|
+
conservation: `${oldItems.length} corpus blocks → ${retained.length} retained-HOT + ${records.length} records (conserved)`,
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
if (!flags.apply) {
|
|
665
|
+
log('[archive-decisions] --migrate DRY-RUN — no files will be changed.');
|
|
666
|
+
log(JSON.stringify(summary, null, 2));
|
|
667
|
+
return 0;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const snapshotFiles = [
|
|
671
|
+
{ rel: HOT_REL, content: hot.raw },
|
|
672
|
+
...(warm.exists ? [{ rel: WARM_REL, content: warm.raw }] : []),
|
|
673
|
+
...(cold.exists ? [{ rel: COLD_REL, content: cold.raw }] : []),
|
|
674
|
+
];
|
|
675
|
+
const snapshot = writeSnapshot(root, snapshotFiles, deps);
|
|
676
|
+
|
|
677
|
+
writeRecords(root, records);
|
|
678
|
+
const corpus = [...retained, ...loadAdrStore(root)];
|
|
679
|
+
writeNavigatorFile(root, corpus, today);
|
|
680
|
+
writeHot(root, hot, retained, today);
|
|
681
|
+
// Only NOW — conservation passed AND the snapshot exists — remove the monoliths.
|
|
682
|
+
for (const rel of present) rmSync(resolve(root, rel));
|
|
683
|
+
|
|
684
|
+
const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
|
|
685
|
+
log('[archive-decisions] migrated the 3-tier cascade → one-file-per-ADR store:');
|
|
686
|
+
log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'})`);
|
|
687
|
+
log(` records written: ${records.length} under ${ADR_DIR_REL}/`);
|
|
688
|
+
log(` retained HOT: ${summary.retainedHot.join(', ') || '(none)'}`);
|
|
689
|
+
log(` retired monoliths: ${present.join(', ')}`);
|
|
690
|
+
log(` navigator: ${NAV_REL}`);
|
|
691
|
+
if (regen.ok) log(' regenerated docs/ai/index.md');
|
|
692
|
+
else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
|
|
693
|
+
return 0;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const runWriteNavigator = (root, today, deps, log, logError) => {
|
|
697
|
+
if (!existsSync(resolve(root, HOT_REL)) && !existsSync(resolve(root, ADR_DIR_REL))) {
|
|
698
|
+
log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to write.`);
|
|
699
|
+
return 0;
|
|
700
|
+
}
|
|
701
|
+
const present = monolithsPresent(root);
|
|
702
|
+
if (present.length > 0) throw fail(1, `${present.join(', ')} present — a half-migrated tree; run \`--migrate\` first before regenerating the navigator.`);
|
|
703
|
+
const hotEntries = existsSync(resolve(root, HOT_REL)) ? loadHot(root).entries : [];
|
|
704
|
+
const adrEntries = loadAdrStore(root);
|
|
705
|
+
assertStoreIntegrity(hotEntries, adrEntries); // never emit a duplicate-row / corrupt navigator
|
|
706
|
+
const corpus = [...hotEntries, ...adrEntries];
|
|
707
|
+
writeNavigatorFile(root, corpus, today);
|
|
708
|
+
const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
|
|
709
|
+
log(`[archive-decisions] wrote ${NAV_REL} (${corpus.length} ADRs in the corpus).`);
|
|
710
|
+
if (regen.ok) log(' regenerated docs/ai/index.md');
|
|
711
|
+
else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
|
|
712
|
+
return 0;
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
// Reuse the on-disk navigator's OWN lastUpdated for the freshness diff so a mere day-rollover (no
|
|
716
|
+
// corpus change) is never flagged — only genuine drift makes it stale (the --check-index precedent).
|
|
717
|
+
const navLastUpdated = (text) => {
|
|
718
|
+
const m = text.match(/^lastUpdated:\s*(.*)$/m);
|
|
719
|
+
return m ? m[1].trim() : 'unknown';
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const runCheck = (root, today, log, logError) => {
|
|
723
|
+
const hasHot = existsSync(resolve(root, HOT_REL));
|
|
724
|
+
const hasStore = existsSync(resolve(root, ADR_DIR_REL));
|
|
725
|
+
// The legacy guard runs BEFORE the substrate-absent skip: a lone monolith (HOT and adr/ both
|
|
726
|
+
// absent) is a half-migrated / half-deleted tree, never a clean "no substrate" skip.
|
|
727
|
+
const present = monolithsPresent(root);
|
|
728
|
+
if (present.length > 0) {
|
|
729
|
+
logError(`[archive-decisions] FAIL: legacy monolith present (${present.join(', ')}) — the ADR store is half-migrated; run \`node scripts/archive-decisions.mjs --migrate --apply\` first.`);
|
|
730
|
+
return 1;
|
|
731
|
+
}
|
|
732
|
+
if (!hasHot && !hasStore) {
|
|
733
|
+
log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to check.`);
|
|
734
|
+
return 0;
|
|
735
|
+
}
|
|
736
|
+
const hot = hasHot ? loadHot(root) : null;
|
|
737
|
+
const adrEntries = loadAdrStore(root);
|
|
738
|
+
const hotEntries = hot ? hot.entries : [];
|
|
739
|
+
assertStoreIntegrity(hotEntries, adrEntries);
|
|
740
|
+
|
|
741
|
+
const problems = [];
|
|
742
|
+
if (hot) {
|
|
743
|
+
log(`[archive-decisions] ${HOT_REL}: ${hot.rawLines}/${hot.cap}`);
|
|
744
|
+
if (hot.cap !== null && hot.rawLines > hot.cap) problems.push(`${HOT_REL} is over its cap (${hot.rawLines}/${hot.cap}) — run \`node scripts/archive-decisions.mjs\` to explode the oldest entries`);
|
|
745
|
+
}
|
|
746
|
+
log(`[archive-decisions] ${ADR_DIR_REL}: ${adrEntries.length} record(s)`);
|
|
747
|
+
|
|
748
|
+
// Navigator freshness: regenerate in memory and diff against on-disk (the folded --check that never
|
|
749
|
+
// false-blocks — --write-navigator is the deterministic fix).
|
|
750
|
+
const navPath = resolve(root, NAV_REL);
|
|
751
|
+
const corpus = [...hotEntries, ...adrEntries];
|
|
752
|
+
const expected = buildNavigator(corpus, existsSync(navPath) ? navLastUpdated(readFileSync(navPath, 'utf8')) : today);
|
|
753
|
+
const onDisk = existsSync(navPath) ? readFileSync(navPath, 'utf8') : null;
|
|
754
|
+
if (onDisk !== expected) {
|
|
755
|
+
problems.push(`${NAV_REL} is stale (out of sync with the ADR corpus) — run \`node scripts/archive-decisions.mjs --write-navigator\` and commit it`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (problems.length > 0) {
|
|
759
|
+
for (const p of problems) logError(`[archive-decisions] FAIL: ${p}.`);
|
|
760
|
+
return 1;
|
|
761
|
+
}
|
|
762
|
+
log('[archive-decisions] OK — HOT within cap, store integrity intact, navigator fresh.');
|
|
763
|
+
return 0;
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const runRotate = (root, flags, today, deps, log, logError) => {
|
|
767
|
+
const present = monolithsPresent(root);
|
|
768
|
+
if (present.length > 0) {
|
|
769
|
+
logError(`[archive-decisions] FAIL: legacy monolith present (${present.join(', ')}) — the ADR store is half-migrated; run \`node scripts/archive-decisions.mjs --migrate --apply\` first (a default rotate never half-explodes).`);
|
|
770
|
+
return 1;
|
|
771
|
+
}
|
|
772
|
+
const hot = loadHot(root);
|
|
773
|
+
const existingStore = loadAdrStore(root);
|
|
774
|
+
const { retained, toExplode } = boundHot(hot.entries, hot.frontmatter, hot.preamble, hot.cap ?? Infinity);
|
|
775
|
+
if (toExplode.length === 0 && hot.rawLines <= (hot.cap ?? Infinity)) {
|
|
776
|
+
assertStoreIntegrity(hot.entries, existingStore); // a no-op still refuses a corrupt store (dup / partition)
|
|
777
|
+
log(`[archive-decisions] nothing to rotate — HOT ${hot.rawLines}/${hot.cap}, ${existingStore.length} archived record(s).`);
|
|
778
|
+
return 0;
|
|
779
|
+
}
|
|
780
|
+
const rewrittenPreamble = rewriteHotPreamble(hot.preamble, retained.length > 0 ? retained[0].id : '000');
|
|
781
|
+
if (hot.cap !== null && lineCountOf(renderTier(hot.frontmatter, rewrittenPreamble, retained)) > hot.cap) {
|
|
782
|
+
throw fail(1, `${HOT_REL} is over its cap but cannot be reduced (the newest entry alone exceeds ${hot.cap} lines) — trim it or raise the cap (a maintainer decision; this script only moves entries).`);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const records = explode(toExplode, today);
|
|
786
|
+
// Crash-resume (fold-induced, codex R2): a record from a prior crashed rotate may already be on
|
|
787
|
+
// disk. A byte-identical one is done (deduped, not a duplicate-id error); a divergent-body one is
|
|
788
|
+
// corrupt → FAIL. The FINAL store = existing ∪ freshly-exploded, deduped by id.
|
|
789
|
+
const existingById = new Map(existingStore.map((e) => [e.id, e]));
|
|
790
|
+
for (const rec of records) {
|
|
791
|
+
const ex = existingById.get(rec.id);
|
|
792
|
+
if (ex && ex.block !== rec.block) {
|
|
793
|
+
throw fail(1, `${ex.rel}: the existing adr/ record for AD-${rec.id} diverges from the freshly-exploded block — a corrupt or hand-edited crash-resume artifact; resolve it by hand, refusing to overwrite`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const finalStoreById = new Map(existingStore.map((e) => [e.id, { id: e.id, idNum: e.idNum, fileName: e.fileName }]));
|
|
797
|
+
for (const rec of records) finalStoreById.set(rec.id, { id: rec.id, idNum: rec.idNum, fileName: rec.fileName });
|
|
798
|
+
assertStoreIntegrity(retained, [...finalStoreById.values()]);
|
|
799
|
+
|
|
800
|
+
const summary = { explode: records.map((r) => r.fileName), retainedHot: retained.map((e) => `AD-${e.id}`) };
|
|
801
|
+
if (flags.dryRun) {
|
|
802
|
+
log('[archive-decisions] DRY-RUN — no files will be changed.');
|
|
803
|
+
log(JSON.stringify(summary, null, 2));
|
|
804
|
+
return 0;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
writeRecords(root, records);
|
|
808
|
+
const corpus = [...retained, ...loadAdrStore(root)];
|
|
809
|
+
writeNavigatorFile(root, corpus, today);
|
|
810
|
+
writeHot(root, hot, retained, today);
|
|
811
|
+
const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
|
|
812
|
+
log('[archive-decisions] rotated:');
|
|
813
|
+
log(` exploded to adr/: ${summary.explode.join(', ') || '(none)'}`);
|
|
814
|
+
log(` retained HOT: ${summary.retainedHot.join(', ')}`);
|
|
815
|
+
if (regen.ok) log(' regenerated docs/ai/index.md');
|
|
816
|
+
else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
|
|
817
|
+
return 0;
|
|
818
|
+
};
|
|
819
|
+
|
|
328
820
|
export const runCli = (argv, deps = {}) => {
|
|
329
|
-
const { root = DEFAULT_ROOT, log = console.log, logError = console.error
|
|
821
|
+
const { root = DEFAULT_ROOT, log = console.log, logError = console.error } = deps;
|
|
330
822
|
try {
|
|
331
823
|
const { flags, today: todayOpt } = parseArgs(argv);
|
|
332
824
|
if (flags.help) {
|
|
@@ -335,122 +827,15 @@ export const runCli = (argv, deps = {}) => {
|
|
|
335
827
|
}
|
|
336
828
|
const today = todayOpt ?? new Date().toISOString().slice(0, 10);
|
|
337
829
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
830
|
+
if (flags.migrate) return runMigrate(root, flags, today, deps, log, logError);
|
|
831
|
+
if (flags.writeNavigator) return runWriteNavigator(root, today, deps, log, logError);
|
|
832
|
+
if (flags.check) return runCheck(root, today, log, logError);
|
|
833
|
+
|
|
341
834
|
if (!existsSync(resolve(root, HOT_REL))) {
|
|
342
|
-
if (flags.check) {
|
|
343
|
-
log(`[archive-decisions] SKIP — ${HOT_REL} not found (this project keeps no ADR file); nothing to check.`);
|
|
344
|
-
return 0;
|
|
345
|
-
}
|
|
346
835
|
logError(`[archive-decisions] ${HOT_REL} not found — nothing to rotate.`);
|
|
347
836
|
return 1;
|
|
348
837
|
}
|
|
349
|
-
|
|
350
|
-
const tiers = loadTiers(root, today);
|
|
351
|
-
const usage = (tier, entries = tier.entries) => `${lineCountOf(renderTier(tier, entries))}/${tier.cap}`;
|
|
352
|
-
// Cap checks count the REAL on-disk lines (what the docs cap-validator counts) — a normalized
|
|
353
|
-
// render undercounts a template-shaped file with `---` separators and would false-green.
|
|
354
|
-
const rawUsage = (tier) => (tier.exists ? tier.rawLines : lineCountOf(renderTier(tier, tier.entries)));
|
|
355
|
-
|
|
356
|
-
if (flags.check) {
|
|
357
|
-
const over = [];
|
|
358
|
-
for (const tier of [tiers.hot, tiers.warm, tiers.cold]) {
|
|
359
|
-
const lines = rawUsage(tier);
|
|
360
|
-
log(`[archive-decisions] ${tier.rel}: ${lines}/${tier.cap}${tier.exists ? '' : ' (absent — would be created on rotation)'}`);
|
|
361
|
-
if (lines > tier.cap) over.push(tier);
|
|
362
|
-
}
|
|
363
|
-
if (over.length > 0) {
|
|
364
|
-
for (const tier of over) {
|
|
365
|
-
const recovery =
|
|
366
|
-
tier.rel === COLD_REL
|
|
367
|
-
? 'the COLD tier is exhausted — a cap raise or a new COLD file is a maintainer/agent decision'
|
|
368
|
-
: 'run `node scripts/archive-decisions.mjs` to rotate';
|
|
369
|
-
logError(`[archive-decisions] FAIL: ${tier.rel} is over its cap — ${recovery}.`);
|
|
370
|
-
}
|
|
371
|
-
return 1;
|
|
372
|
-
}
|
|
373
|
-
log('[archive-decisions] OK — every tier is within its cap.');
|
|
374
|
-
return 0;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const plan = planRotation(tiers);
|
|
378
|
-
// A tier over its cap on RAW lines needs a write even with zero entry moves — normalization
|
|
379
|
-
// (the canonical rendered form) alone brings it back under (the moves loop already ensured
|
|
380
|
-
// the RENDERED result fits; if it did not, moves would be non-empty).
|
|
381
|
-
const normalizeOnly = [tiers.hot, tiers.warm, tiers.cold].filter((tier) => tier.exists && tier.rawLines > tier.cap);
|
|
382
|
-
if (plan.moves.hotToWarm.length === 0 && plan.moves.warmToCold.length === 0 && normalizeOnly.length === 0) {
|
|
383
|
-
log(`[archive-decisions] nothing to rotate — HOT ${usage(tiers.hot)}, WARM ${usage(tiers.warm)}, COLD ${usage(tiers.cold)}.`);
|
|
384
|
-
return 0;
|
|
385
|
-
}
|
|
386
|
-
verifyConservation(
|
|
387
|
-
[tiers.hot.entries, tiers.warm.entries, tiers.cold.entries],
|
|
388
|
-
[plan.hotEntries, plan.warmEntries, plan.coldEntries],
|
|
389
|
-
);
|
|
390
|
-
|
|
391
|
-
const summary = {
|
|
392
|
-
hotToWarm: plan.moves.hotToWarm.map((id) => `AD-${id}`),
|
|
393
|
-
warmToCold: plan.moves.warmToCold.map((id) => `AD-${id}`),
|
|
394
|
-
normalizeOnly: normalizeOnly.map((tier) => tier.rel),
|
|
395
|
-
after: {
|
|
396
|
-
hot: usage(tiers.hot, plan.hotEntries),
|
|
397
|
-
warm: usage(tiers.warm, plan.warmEntries),
|
|
398
|
-
cold: usage(tiers.cold, plan.coldEntries),
|
|
399
|
-
},
|
|
400
|
-
};
|
|
401
|
-
|
|
402
|
-
if (flags.dryRun) {
|
|
403
|
-
log('[archive-decisions] DRY-RUN — no files will be changed.');
|
|
404
|
-
log(JSON.stringify(summary, null, 2));
|
|
405
|
-
return 0;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const ranges = { hotEntries: plan.hotEntries, warmEntries: plan.warmEntries, coldEntries: plan.coldEntries };
|
|
409
|
-
const writes = [
|
|
410
|
-
{ tier: tiers.hot, entries: plan.hotEntries, kind: 'hot' },
|
|
411
|
-
{ tier: tiers.warm, entries: plan.warmEntries, kind: 'warm' },
|
|
412
|
-
{ tier: tiers.cold, entries: plan.coldEntries, kind: 'cold' },
|
|
413
|
-
];
|
|
414
|
-
// A rewrite must never claim success while a tier is STILL over its cap — normalization is
|
|
415
|
-
// not a licence: if the planned rendered result exceeds the budget (a genuinely exhausted
|
|
416
|
-
// COLD is the reachable case — the move loops already bound HOT and WARM), fail LOUD before
|
|
417
|
-
// any write.
|
|
418
|
-
for (const { tier, entries } of writes) {
|
|
419
|
-
const plannedLines = lineCountOf(renderTier(tier, entries));
|
|
420
|
-
if (plannedLines > tier.cap) {
|
|
421
|
-
const recovery =
|
|
422
|
-
tier.rel === COLD_REL
|
|
423
|
-
? 'the COLD tier is exhausted — a cap raise or a new COLD file is a maintainer/agent decision'
|
|
424
|
-
: 'raise the cap or trim the offending entries (maintainer decision)';
|
|
425
|
-
throw fail(1, `refusing BEFORE any write: ${tier.rel} would still be over its cap after rotation (${plannedLines}/${tier.cap}) — ${recovery}`);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
for (const { tier, entries, kind } of writes) {
|
|
429
|
-
// Never materialize an ABSENT tier that still has nothing to hold — a normalize-only
|
|
430
|
-
// rewrite of HOT must not seed empty WARM/COLD files into a project that never rotated.
|
|
431
|
-
if (!tier.exists && entries.length === 0) continue;
|
|
432
|
-
const updated = {
|
|
433
|
-
...tier,
|
|
434
|
-
frontmatter: stampLastUpdated(tier.frontmatter, today),
|
|
435
|
-
preamble: updateRangeTokens(tier.preamble, kind, ranges),
|
|
436
|
-
};
|
|
437
|
-
mkdirSync(dirname(tier.path), { recursive: true });
|
|
438
|
-
writeFileSync(tier.path, renderTier(updated, entries), 'utf8');
|
|
439
|
-
}
|
|
440
|
-
// (h) — the write loop completed (a full successful rotation OR a normalize-only rewrite): the
|
|
441
|
-
// docs index is now stale, so regenerate it here (never on --check / --dry-run / the
|
|
442
|
-
// nothing-to-rotate no-op / a pre-write refusal — those return before this point).
|
|
443
|
-
const regen = regenerateIndex(root, today);
|
|
444
|
-
log('[archive-decisions] rotated:');
|
|
445
|
-
log(` HOT→WARM: ${summary.hotToWarm.join(', ') || '(none)'}`);
|
|
446
|
-
log(` WARM→COLD: ${summary.warmToCold.join(', ') || '(none)'}`);
|
|
447
|
-
if (summary.normalizeOnly.length > 0 && summary.hotToWarm.length === 0 && summary.warmToCold.length === 0) {
|
|
448
|
-
log(` normalize-only rewrite (over cap on raw lines, no entry moves): ${summary.normalizeOnly.join(', ')}`);
|
|
449
|
-
}
|
|
450
|
-
log(` now: HOT ${summary.after.hot} · WARM ${summary.after.warm} · COLD ${summary.after.cold}`);
|
|
451
|
-
if (regen.ok) log(' regenerated docs/ai/index.md (the rotation kept the index fresh)');
|
|
452
|
-
else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
|
|
453
|
-
return 0;
|
|
838
|
+
return runRotate(root, flags, today, deps, log, logError);
|
|
454
839
|
} catch (err) {
|
|
455
840
|
logError(`[archive-decisions] ${err.message}`);
|
|
456
841
|
return err.exitCode ?? 1;
|