@sabaiway/agent-workflow-memory 1.6.0 → 1.8.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 CHANGED
@@ -4,6 +4,57 @@ All notable changes to the memory substrate. Versions are this **package's** npm
4
4
  they are distinct from the **deployment-lineage** stamp written into a project's
5
5
  `docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `1.3.0`).
6
6
 
7
+ ## 1.8.0 — ADR-cascade rotation script + the seeded per-project gate declaration
8
+
9
+ A **feature** release (deployment-lineage head stays `1.3.0`; the new surfaces reach existing
10
+ deployments via stamp-independent ensures, no migration file). The last hand-rolled docs
11
+ rotation — the `decisions.md` ADR cascade — is now a script, and every project gains a
12
+ hand-editable gate declaration:
13
+
14
+ - **`references/scripts/archive-decisions.mjs` (+ test)** — the `archive-changelog.mjs` sibling:
15
+ a chained three-tier cascade (HOT `decisions.md` → WARM `history/decisions-archive.md` → COLD
16
+ `history/decisions-archive-early.md`), caps read from each file's own frontmatter `maxLines`.
17
+ Whole entries move, oldest first; the id multiset and every entry's line count are
18
+ conservation-checked before any write. **Fail-LOUD**: a non-canonical `## AD-0NN — <title>`
19
+ heading, disordered ids, a cross-tier duplicate, or a roll that would not fit COLD's remaining
20
+ headroom all refuse **before any write** (a cap raise is a maintainer decision — the script
21
+ only moves entries). `--check` reports per-tier `lines/cap`; on a project **without**
22
+ `decisions.md` it exits 0 with a **stated skip** — a deliberate divergence from
23
+ `archive-changelog.mjs` (the deployed pre-commit hook must never block a commit over an absent
24
+ ADR substrate).
25
+ - **The pre-commit hook runs it** — `install-git-hooks.mjs` adds `archive-decisions.mjs --check`
26
+ to the installed gate line-up.
27
+ - **`references/templates/gates.json`** — the seeded, user-editable per-project **gate
28
+ declaration** (`{ id, title, cmd }`, strict JSON, an empty list as shipped; `cmd` is ONE bash
29
+ command line). Bootstrap seeds it; upgrade **ensures-if-missing and preserves an existing file
30
+ byte-for-byte**. It declares WHAT to check — the runner lives in the composition root, never
31
+ here.
32
+ - **Stamp-independent ensures (equal-head deployments self-heal)** — the upgrade procedure now
33
+ ensures BOTH seeded `.json` configs **and** the `archive-decisions` script pair
34
+ (copy-if-missing into `scripts/`, never overwriting an existing file); an old hook without the
35
+ decisions line stays consistent-safe until the next hook refresh.
36
+ - **Tests** — 22 cascade tests (fixtures for the chained roll, the COLD-exhaustion refusal, the
37
+ absent-file `--check` skip, determinism, range-token maintenance); bootstrap/ensure coverage
38
+ extended in `standalone-bootstrap.test.mjs`; tarball content re-pinned (40 files, reverse pins
39
+ for every new asset).
40
+
41
+ ## 1.7.0 — Humanize the deploy/version report (memory)
42
+
43
+ A **feature** release (report-prose only — the atomic stamp-WRITE mechanics and the *Stamp = lineage
44
+ head, not package version* gotcha are unchanged; deployment-lineage head stays `1.3.0`, no migration).
45
+
46
+ The substrate's upgrade report no longer surfaces the internal `docs/ai` **structure version**
47
+ (`.memory-version`) on the happy path:
48
+
49
+ - A zero-diff equal-head upgrade says **settings already current — no update needed**; a writeful
50
+ reconcile (a footprint move / config seed) instead reports **what changed** and asks before commit.
51
+ A fresh bootstrap keeps its deploy-success framing. Neither shows the structure number.
52
+ - The number surfaces only at the never-downgrade **STOP** gate and on an **explicit user ask** (a
53
+ read-only answer that writes nothing) — named "the `docs/ai` structure version", never "lineage
54
+ head", with a plain two-axes note (*Version disclosure*). Memory adds **no status mode** (the one
55
+ intended kit↔memory asymmetry).
56
+ - Pinned by a new static contract test (`scripts/skill-report-contract.test.mjs`).
57
+
7
58
  ## 1.6.0 — §2.6 carries the review-loop economics disciplines (memory)
8
59
 
9
60
  A **feature** release. The `agent_rules.md` substrate **§2.6** lens gains the review-loop economics
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-memory
3
3
  description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '1.6.0'
6
+ version: '1.8.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
@@ -48,6 +48,7 @@ What this substrate owns vs what it only points at. The methodology + orchestrat
48
48
  | Plan→Phase→Step vocabulary, lifecycle, `queue.md`, mandatory Cleanup | **methodology** (not this skill) | the empty `workflow:methodology` slot — filled by the composition root |
49
49
  | Orchestration recipes (Solo / Reviewed / Council / Delegated) | **methodology engine** (not this skill) | the empty `workflow:orchestration` slot — filled by the composition root |
50
50
  | Per-project recipe **CONFIG** (which recipe each activity/slot uses) | **memory** seeds an *editable default* | `docs/ai/orchestration.json` (agent-writable via the composition root's `set-recipe` writer, or hand-edited; the recipe **canon** + the slot **vocabulary** live in the engine / composition root, never here) |
51
+ | Per-project **gate declaration** (which verification commands must be green) | **memory** seeds an *editable default* | `docs/ai/gates.json` (hand-editable; an empty list as shipped — the project declares its own commands; the **runner** lives in the composition root, never here) |
51
52
 
52
53
  ---
53
54
 
@@ -96,9 +97,11 @@ bootstrapping over a live system, but the user makes the final call.
96
97
  (`ln -s AGENTS.md CLAUDE.md`). **Leave BOTH pointer slots (`workflow:methodology` +
97
98
  `workflow:orchestration`) exactly as shipped — empty.** Filling them is the composition root's job.
98
99
  6. **Deploy `docs/ai/`.** Create the files + `pages/` from
99
- `${CLAUDE_SKILL_DIR}/references/templates/` (every non-`AGENTS.md` template, including the seeded,
100
- **user-editable** `docs/ai/orchestration.json` config strict JSON, the per-project recipe defaults
101
- the composition root's `procedures` advisor reads). Keep each file's frontmatter.
100
+ `${CLAUDE_SKILL_DIR}/references/templates/` (every non-`AGENTS.md` template, including the two
101
+ seeded, **user-editable** strict-JSON configs: `docs/ai/orchestration.json` — the per-project
102
+ recipe defaults the composition root's `procedures` advisor reads and `docs/ai/gates.json`
103
+ the project's gate declaration, an empty list to fill with its own verification commands,
104
+ consumed by the composition root's gate runner). Keep each `.md` file's frontmatter.
102
105
  7. **Fill templates** per the table below.
103
106
  8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs`
104
107
  (+ `*.test.mjs`) into the project's `scripts/`. **No Node runtime** → skip this + the hook;
@@ -116,7 +119,8 @@ bootstrapping over a live system, but the user makes the final call.
116
119
  lineage, a **separate axis** from this package's npm version (the two may even coincide by
117
120
  accident; see *Stamp = lineage head, not package version*). Use the atomic writer in
118
121
  `scripts/stamp-takeover.mjs` (write-temp + rename).
119
- 11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on filled-vs-TODO, then **ask before
122
+ 11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on filled-vs-TODO its normal deploy-success
123
+ framing, with **no `docs/ai` structure number** (see *Version disclosure*) — then **ask before
120
124
  committing** — never auto-commit. **Exception — delegated mode (below): skip this gate.**
121
125
 
122
126
  > **Delegated mode (invoked by the composition root) — applies to BOTH bootstrap and upgrade.**
@@ -150,7 +154,10 @@ Fill strategy:
150
154
  2. **Never-downgrade gate FIRST, then the stamp-independent hidden-mode reconcile (D14).** Compare the
151
155
  stamp to the **deployment-lineage head** (`LINEAGE_HEAD`, `1.3.0`). **Greater than the head, or
152
156
  unparseable → STOP and report immediately, before ANY write** (never downgrade or guess, and never
153
- touch `.git/info/exclude`). Otherwise (stamp **≤ head**) reconcile the hidden-mode footprint but
157
+ touch `.git/info/exclude`). This STOP is one of the only two places the number is shown (see
158
+ *Version disclosure* below): tell the user **the `docs/ai` structure version** their deployment
159
+ carries versus the one this substrate expects, plus the plain one-line two-axes note — naming it the
160
+ structure version, **never** "lineage head". Otherwise (stamp **≤ head**) reconcile the hidden-mode footprint — but
154
161
  first **infer this deployment's OWN visibility from its git state** (NOT from whether the machine-global
155
162
  excludes list these paths — another repo on the host may have added them): if `AGENTS.md` (or
156
163
  `docs/ai/`) is **tracked / committed** → **VISIBLE** → do nothing (never write `.git/info/exclude`);
@@ -162,16 +169,32 @@ Fill strategy:
162
169
  **untracked AND not ignored** → **AMBIGUOUS** → **ASK** the user before writing. This visibility check
163
170
  runs on **every** in-range upgrade, even at head — it is not gated by the stamp delta, but it is gated
164
171
  **behind** the never-downgrade STOP above. **Also stamp-independent (same gate, before the equal-head
165
- short-circuit): ensure `docs/ai/orchestration.json`** — **create it from
166
- `${CLAUDE_SKILL_DIR}/references/templates/orchestration.json` if missing**, **preserve it byte-for-byte
167
- if it already exists** (a user may have edited it; never clobber it). The shipped template's `_README`
168
- already frames the config as agent-writable (set it with the `set-recipe` writer) and still
169
- hand-editable. (Refreshing that note in place on an existing file is the **composition root's** job on
170
- its own reconcile this substrate is standalone and only seeds-or-preserves; it owns no cross-package
171
- refresh helper.) This is why an equal-head (`1.3.0`) deployment still gains the config seed **without a
172
- lineage-head bump or a migration file** (the stamp-independent-reconcile precedent like the pointer
173
- slots + the hidden-mode footprint). **Then**, if the stamp **equals** the head report "up to date"
174
- (plus any footprint move / config seed just made) and stop.
172
+ short-circuit): ensure BOTH seeded `.json` configs** — for `docs/ai/orchestration.json` AND
173
+ `docs/ai/gates.json`, **create the file from its
174
+ `${CLAUDE_SKILL_DIR}/references/templates/` template if missing**, **preserve it byte-for-byte
175
+ if it already exists** (a user may have edited it; never clobber it). The shipped
176
+ `orchestration.json` template's `_README` already frames that config as agent-writable (set it
177
+ with the `set-recipe` writer) and still hand-editable; `gates.json` is the project's own gate
178
+ declaration (what to verify consumed by the composition root's gate runner). (Refreshing the
179
+ orchestration `_README` note in place on an existing file is the **composition root's** job on
180
+ its own reconcile this substrate is standalone and only seeds-or-preserves; it owns no
181
+ cross-package refresh helper. The gates declaration gets no note-refresh at all — authored
182
+ content, seed-or-preserve only.) This is why an equal-head (`1.3.0`) deployment still gains the
183
+ config seeds **without a lineage-head bump or a migration file** (the
184
+ stamp-independent-reconcile precedent — like the pointer slots + the hidden-mode footprint).
185
+ **Same gate, also stamp-independent: ensure the ADR-cascade enforcement pair** — copy
186
+ `archive-decisions.mjs` + `archive-decisions.test.mjs` from
187
+ `${CLAUDE_SKILL_DIR}/references/scripts/` into the project's `scripts/` **if missing**
188
+ (preserve an existing file byte-for-byte; skip on a No-Node project). The deployed pre-commit
189
+ hook gains its `archive-decisions.mjs --check` line only when the hook is next refreshed via
190
+ `node scripts/install-git-hooks.mjs`; an old hook without the line stays consistent-safe (the
191
+ decisions gate is simply not enforced yet — never a broken hook). **Then**, if the stamp **equals** the head → the substrate is
192
+ current (no structure migration is due), and stop after reporting. Report **in the user's
193
+ conversational language**: if step 2's reconcile just **changed something** (a footprint move /
194
+ config seed), say **what changed** in plain terms and ask before committing; if **nothing changed at
195
+ all**, say their **settings are already current — no update is required**. Either way, show **no**
196
+ structure number, stamp filename, or internal versioning vocabulary on this happy-path exit — the
197
+ number is inert here and belongs to *Version disclosure* (below).
175
198
  3. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` context (entries newer than the stamp).
176
199
  4. Apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those
177
200
  newer than the stamp. Migrations are **idempotent**.
@@ -186,9 +209,37 @@ Fill strategy:
186
209
  gracefully **no-op** on that slot (adding a slot to already-deployed files is the composition
187
210
  root's reconcile, not this substrate's job). On any malformed marker state (single, reversed,
188
211
  nested, or duplicate pair), **no-op with an error** — never edit.
189
- 7. **Re-stamp** `docs/ai/.memory-version` to the lineage head (atomic write). Report changes;
190
- **ask before committing** **except in delegated mode** (see the *Delegated mode* note above),
191
- where the composition root owns the single gate and this step raises none.
212
+ 7. **Re-stamp** `docs/ai/.memory-version` to the lineage head (atomic write — mechanics unchanged).
213
+ Report changes **in plain human terms** (which parts of the deployment are now different);
214
+ **omit the raw structure number**, and do not recite the two-axes note here (it belongs to
215
+ *Version disclosure*). **Ask before committing** — **except in delegated mode** (see the *Delegated
216
+ mode* note above), where the composition root owns the single gate and this step raises none.
217
+
218
+ ---
219
+
220
+ ## Version disclosure — the `docs/ai` structure version, on demand only
221
+
222
+ The deployment carries an internal **`docs/ai` structure version** in `.memory-version` (the
223
+ `LINEAGE_HEAD` — the number `upgrade` compares the stamp against to decide whether a migration is due).
224
+ It is un-actionable in the happy path and reads as smaller than this package's npm version, so the
225
+ happy path **hides** it. This substrate has **no status mode** (unlike the family composition root), so
226
+ the number surfaces in exactly **two** places, and nowhere else:
227
+ 1. the **never-downgrade STOP** (*Mode: upgrade* step 2) — the stamp is ahead of what this substrate
228
+ knows, so the number IS the message;
229
+ 2. when the **user explicitly asks** about versions — a **read-only** answer: read `.memory-version`
230
+ and state it plainly (with the two-axes note). This adds **no mode** and **writes nothing**.
231
+
232
+ When you show it, **name what it versions — "the `docs/ai` structure version"** (render that meaning in
233
+ the user's conversational language) — **never** "lineage head" or any raw internal token. Pair it with
234
+ **one plain-language line** telling the two axes apart, on demand only:
235
+
236
+ > the number your project carries versions its `docs/ai` **structure**; the (usually larger) number on
237
+ > npm is this package's **own version** — the two advance independently, so a bigger package number is
238
+ > **not** a newer deployment.
239
+
240
+ **Never** print this two-axes line on a successful equal-head exit — only at the STOP or on an explicit
241
+ ask. (This is the one intended kit↔memory asymmetry: the kit hosts the number in its `status` mode; the
242
+ memory substrate has none, so it relies on the STOP + the explicit ask — never invent a status mode.)
192
243
 
193
244
  ---
194
245
 
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-memory",
5
5
  "kind": "memory-substrate",
6
- "version": "1.6.0",
6
+ "version": "1.8.0",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -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));
@@ -0,0 +1,365 @@
1
+ import { describe, it, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ HOT_REL,
8
+ WARM_REL,
9
+ COLD_REL,
10
+ parseDecisionsText,
11
+ loadTiers,
12
+ renderTier,
13
+ lineCountOf,
14
+ planRotation,
15
+ updateRangeTokens,
16
+ runCli,
17
+ } from './archive-decisions.mjs';
18
+
19
+ // Hermetic by design: this test ships as deploy payload and runs inside CONSUMER repos via the
20
+ // pre-commit `node --test scripts/*.test.mjs` — it must never read the host repo's docs/ai.
21
+
22
+ const tempDirs = [];
23
+ const makeRoot = () => {
24
+ const dir = mkdtempSync(join(tmpdir(), 'archive-decisions-'));
25
+ tempDirs.push(dir);
26
+ return dir;
27
+ };
28
+ afterEach(() => {
29
+ while (tempDirs.length) rmSync(tempDirs.pop(), { recursive: true, force: true });
30
+ });
31
+
32
+ const fm = (cap) =>
33
+ `---\ntype: reference\nlastUpdated: 2026-01-01\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${cap}\n---\n`;
34
+
35
+ // One canonical entry block: 3 fixed lines + extraLines body lines.
36
+ const entryBlock = (id, extraLines = 2) =>
37
+ [`## AD-${id} — Decision ${id}`, '', '**Date:** 2026-01-01', ...Array.from({ length: extraLines }, (_, i) => `body ${i + 1} of AD-${id}`)].join('\n');
38
+
39
+ const tierText = (cap, preamble, blocks) => `${fm(cap)}\n${preamble}\n\n${blocks.join('\n\n')}\n`;
40
+
41
+ const HOT_PREAMBLE = [
42
+ '# Architecture Decision Records (ADRs)',
43
+ '',
44
+ '> Newest at the bottom.',
45
+ '>',
46
+ '> **Archive:** the stable ADRs **AD-003 … AD-004** now live in [`history/decisions-archive.md`](./history/decisions-archive.md) (the earliest **AD-001 … AD-002** rolled further to the COLD [`history/decisions-archive-early.md`](./history/decisions-archive-early.md)); this file carries the active set (AD-005 onward).',
47
+ ].join('\n');
48
+
49
+ const WARM_PREAMBLE = '# ADR Archive (AD-003 … AD-004)\n\n> WARM tier. The earliest (AD-001 … AD-002) are COLD.';
50
+ const COLD_PREAMBLE = '# ADR Early Archive (AD-001 … AD-002)\n\n> COLD tier.';
51
+
52
+ // Seed a project: HOT with `hotIds`, WARM with `warmIds`, COLD with `coldIds`; caps computed
53
+ // from the measured rendered size + a delta (so fixtures stay robust to formatting arithmetic).
54
+ const seedProject = (root, { hotIds, warmIds, coldIds, hotCapDelta = 100, warmCapDelta = 100, coldCapDelta = 100 }) => {
55
+ mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
56
+ const write = (rel, preamble, ids, capDelta) => {
57
+ const blocks = ids.map((id) => entryBlock(id));
58
+ const probe = tierText(999, preamble, blocks);
59
+ const cap = lineCountOf(probe) + capDelta;
60
+ writeFileSync(join(root, rel), tierText(cap, preamble, blocks));
61
+ return cap;
62
+ };
63
+ return {
64
+ hotCap: write(HOT_REL, HOT_PREAMBLE, hotIds, hotCapDelta),
65
+ warmCap: write(WARM_REL, WARM_PREAMBLE, warmIds, warmCapDelta),
66
+ coldCap: write(COLD_REL, COLD_PREAMBLE, coldIds, coldCapDelta),
67
+ };
68
+ };
69
+
70
+ const run = (argv, root) => {
71
+ const out = [];
72
+ const err = [];
73
+ const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l) });
74
+ return { code, out, err, text: out.join('\n'), errText: err.join('\n') };
75
+ };
76
+
77
+ const idsIn = (root, rel) => parseDecisionsText(readFileSync(join(root, rel), 'utf8'), rel).entries.map((e) => e.id);
78
+
79
+ // ── parsing: strict canonical headings (the Issue-009 lesson) ─────────────────────────
80
+
81
+ describe('parseDecisionsText — fail-loud on non-canonical headings', () => {
82
+ it('parses canonical entries with ids, titles, and per-entry line counts', () => {
83
+ const parsed = parseDecisionsText(tierText(500, HOT_PREAMBLE, [entryBlock('005'), entryBlock('006', 4)]), 'x');
84
+ assert.deepEqual(parsed.entries.map((e) => e.id), ['005', '006']);
85
+ assert.equal(parsed.entries[0].lineCount, 5);
86
+ assert.equal(parsed.entries[1].lineCount, 7);
87
+ assert.equal(parsed.cap, 500);
88
+ });
89
+
90
+ const badHeadings = [
91
+ ['a hyphen instead of the em-dash', '## AD-024 - Title'],
92
+ ['a 2-digit id', '## AD-24 — Title'],
93
+ ['a missing title', '## AD-024 — '],
94
+ ['an unrelated H2', '## Notes'],
95
+ ];
96
+ for (const [name, heading] of badHeadings) {
97
+ it(`rejects ${name} naming file:line — never a silent merge`, () => {
98
+ const text = `${fm(500)}\n# T\n\n${entryBlock('001')}\n\n${heading}\nbody\n`;
99
+ assert.throws(() => parseDecisionsText(text, 'docs/ai/decisions.md'), (e) => {
100
+ assert.equal(e.exitCode, 1);
101
+ assert.match(e.message, /docs\/ai\/decisions\.md:\d+/);
102
+ assert.match(e.message, /non-canonical H2/);
103
+ return true;
104
+ });
105
+ });
106
+ }
107
+
108
+ it('rejects disordered ids (oldest must be at the top)', () => {
109
+ const text = tierText(500, '# T', [entryBlock('007'), entryBlock('005')]);
110
+ assert.throws(() => parseDecisionsText(text, 'x'), /strictly ascending/);
111
+ });
112
+ });
113
+
114
+ describe('loadTiers — cross-tier integrity', () => {
115
+ it('rejects a duplicate id across tiers', () => {
116
+ const root = makeRoot();
117
+ seedProject(root, { hotIds: ['005'], warmIds: ['005'], coldIds: [] });
118
+ assert.throws(() => loadTiers(root, '2026-01-02'), /duplicate id across tiers/);
119
+ });
120
+ });
121
+
122
+ // ── the cascade ───────────────────────────────────────────────────────────────────────
123
+
124
+ describe('rotation — simple HOT→WARM roll', () => {
125
+ it('rolls the OLDEST whole entries until HOT fits; ids + line counts conserved', () => {
126
+ const root = makeRoot();
127
+ seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
128
+ const before = loadTiers(root, '2026-01-02');
129
+ const allBefore = [before.hot, before.warm, before.cold].flatMap((t) => t.entries.map((e) => `${e.id}:${e.lineCount}`)).sort();
130
+
131
+ const { code, text } = run(['--today=2026-01-02'], root);
132
+ assert.equal(code, 0, text);
133
+ assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008'], 'the oldest HOT entry rolled');
134
+ assert.deepEqual(idsIn(root, WARM_REL), ['003', '004', '005'], 'appended to the WARM end');
135
+ assert.deepEqual(idsIn(root, COLD_REL), ['001', '002'], 'COLD untouched');
136
+
137
+ const after = loadTiers(root, '2026-01-02');
138
+ const allAfter = [after.hot, after.warm, after.cold].flatMap((t) => t.entries.map((e) => `${e.id}:${e.lineCount}`)).sort();
139
+ assert.deepEqual(allAfter, allBefore, 'id multiset + per-entry line counts conserved');
140
+ assert.ok(lineCountOf(renderTier(after.hot, after.hot.entries)) <= after.hot.cap, 'HOT now fits its cap');
141
+ });
142
+
143
+ it('a tree already under cap is a no-op (nothing written)', () => {
144
+ const root = makeRoot();
145
+ seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
146
+ const bytesBefore = readFileSync(join(root, HOT_REL), 'utf8');
147
+ const { code, text } = run([], root);
148
+ assert.equal(code, 0);
149
+ assert.match(text, /nothing to rotate/);
150
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), bytesBefore);
151
+ });
152
+ });
153
+
154
+ describe('rotation — the CHAINED roll (WARM near cap rolls WARM→COLD first)', () => {
155
+ it('an incoming HOT entry that would overflow WARM pushes the oldest WARM entry to COLD', () => {
156
+ const root = makeRoot();
157
+ seedProject(root, {
158
+ hotIds: ['005', '006', '007'],
159
+ warmIds: ['003', '004'],
160
+ coldIds: ['001', '002'],
161
+ hotCapDelta: -1, // force one HOT roll
162
+ warmCapDelta: 3, // the incoming 6-line append does NOT fit → chain
163
+ });
164
+ const { code } = run(['--today=2026-01-02'], root);
165
+ assert.equal(code, 0);
166
+ assert.deepEqual(idsIn(root, HOT_REL), ['006', '007']);
167
+ assert.deepEqual(idsIn(root, WARM_REL), ['004', '005'], 'AD-003 chained out, AD-005 appended');
168
+ assert.deepEqual(idsIn(root, COLD_REL), ['001', '002', '003'], 'the chained entry landed in COLD');
169
+ });
170
+ });
171
+
172
+ describe('rotation — COLD exhaustion fails LOUD before ANY write', () => {
173
+ it('a roll that does not fit COLD headroom leaves all three files byte-identical', () => {
174
+ const root = makeRoot();
175
+ seedProject(root, {
176
+ hotIds: ['005', '006', '007'],
177
+ warmIds: ['003', '004'],
178
+ coldIds: ['001', '002'],
179
+ hotCapDelta: -1, // force a HOT roll
180
+ warmCapDelta: 3, // force the chain into COLD
181
+ coldCapDelta: 3, // the chained 6-line entry does NOT fit COLD
182
+ });
183
+ const snapshot = () => [HOT_REL, WARM_REL, COLD_REL].map((rel) => readFileSync(join(root, rel), 'utf8'));
184
+ const before = snapshot();
185
+ const { code, errText } = run(['--today=2026-01-02'], root);
186
+ assert.equal(code, 1);
187
+ assert.match(errText, /refusing BEFORE any write/);
188
+ assert.match(errText, /maintainer\/agent decision/);
189
+ assert.deepEqual(snapshot(), before, 'no file changed on the refused plan');
190
+ });
191
+ });
192
+
193
+ describe('rotation — determinism + dry-run', () => {
194
+ it('the same input yields the same move-set (planRotation is pure)', () => {
195
+ const root = makeRoot();
196
+ seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003'], coldIds: ['001'], hotCapDelta: -1 });
197
+ const tiers = loadTiers(root, '2026-01-02');
198
+ const planA = planRotation(tiers);
199
+ const planB = planRotation(loadTiers(root, '2026-01-02'));
200
+ assert.deepEqual(planA.moves, planB.moves);
201
+ });
202
+
203
+ it('--dry-run prints the move-set and writes nothing', () => {
204
+ const root = makeRoot();
205
+ seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
206
+ const before = readFileSync(join(root, HOT_REL), 'utf8');
207
+ const { code, text } = run(['--dry-run'], root);
208
+ assert.equal(code, 0);
209
+ assert.match(text, /DRY-RUN/);
210
+ assert.match(text, /AD-005/);
211
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before);
212
+ });
213
+ });
214
+
215
+ describe('preamble range tokens', () => {
216
+ it('updates the recognizable range/onward tokens after a rotation', () => {
217
+ const root = makeRoot();
218
+ seedProject(root, { hotIds: ['005', '006', '007'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
219
+ const { code } = run(['--today=2026-01-02'], root);
220
+ assert.equal(code, 0);
221
+ const hotText = readFileSync(join(root, HOT_REL), 'utf8');
222
+ assert.match(hotText, /\*\*AD-003 … AD-005\*\*/, 'WARM range token updated in HOT');
223
+ assert.match(hotText, /\(AD-006 onward\)/, 'active-set token updated');
224
+ const warmText = readFileSync(join(root, WARM_REL), 'utf8');
225
+ assert.match(warmText, /AD-003 … AD-005/, 'WARM file H1 range updated');
226
+ });
227
+
228
+ it('a preamble without tokens is left untouched (consumer wording preserved)', () => {
229
+ const updated = updateRangeTokens('# My own ADR file\n\n> no tokens here', 'hot', {
230
+ hotEntries: [{ id: '010' }],
231
+ warmEntries: [{ id: '001' }, { id: '002' }],
232
+ coldEntries: [],
233
+ });
234
+ assert.equal(updated, '# My own ADR file\n\n> no tokens here');
235
+ });
236
+ });
237
+
238
+ // ── --check + the absent-substrate divergence ─────────────────────────────────────────
239
+
240
+ describe('--check', () => {
241
+ it('all tiers within caps → exit 0 with a per-tier usage report', () => {
242
+ const root = makeRoot();
243
+ seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
244
+ const { code, text } = run(['--check'], root);
245
+ assert.equal(code, 0);
246
+ assert.match(text, /decisions\.md: \d+\/\d+/);
247
+ assert.match(text, /OK — every tier is within its cap/);
248
+ });
249
+
250
+ it('an over-cap HOT → exit 1 naming the rotation recovery', () => {
251
+ const root = makeRoot();
252
+ seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
253
+ const { code, errText } = run(['--check'], root);
254
+ assert.equal(code, 1);
255
+ assert.match(errText, /decisions\.md is over its cap/);
256
+ assert.match(errText, /archive-decisions\.mjs` to rotate/);
257
+ });
258
+
259
+ it('an over-cap COLD → exit 1 naming the maintainer decision (rotation cannot fix it)', () => {
260
+ const root = makeRoot();
261
+ seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001', '002'], coldCapDelta: -1 });
262
+ const { code, errText } = run(['--check'], root);
263
+ assert.equal(code, 1);
264
+ assert.match(errText, /maintainer\/agent decision/);
265
+ });
266
+
267
+ it('ABSENT decisions.md → --check exits 0 with a STATED skip (deliberate divergence from archive-changelog)', () => {
268
+ const root = makeRoot();
269
+ mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
270
+ const { code, text } = run(['--check'], root);
271
+ assert.equal(code, 0);
272
+ assert.match(text, /SKIP — docs\/ai\/decisions\.md not found/);
273
+ });
274
+
275
+ it('an absent decisions.md WITHOUT --check is still a loud non-zero (rotation has nothing to do)', () => {
276
+ const root = makeRoot();
277
+ mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
278
+ const { code, errText } = run([], root);
279
+ assert.equal(code, 1);
280
+ assert.match(errText, /not found/);
281
+ });
282
+ });
283
+
284
+ describe('cap accounting is on RAW file lines (the template-shaped `---` separator case)', () => {
285
+ // A consumer file born from the shipped template joins entries with `\n\n---\n\n`. Normalized
286
+ // rendering drops those separator lines, so a render-based count would false-green a file the
287
+ // docs cap-validator (raw lines) fails. --check and the write trigger must count RAW lines.
288
+ const seedSeparatorShaped = (root, { capDelta }) => {
289
+ mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
290
+ const blocks = [entryBlock('001'), entryBlock('002'), entryBlock('003')];
291
+ const body = blocks.join('\n\n---\n\n'); // the template separator shape
292
+ const probe = `${fm(999)}\n# ADRs\n\n${body}\n`;
293
+ const cap = lineCountOf(probe) + capDelta;
294
+ writeFileSync(join(root, HOT_REL), `${fm(cap)}\n# ADRs\n\n${body}\n`);
295
+ return { cap, rawLines: lineCountOf(probe) };
296
+ };
297
+
298
+ it('--check fails on raw-over-cap even when the normalized render would fit', () => {
299
+ const root = makeRoot();
300
+ // 3 separators × 2 lines = 6 lines the render drops; cap sits 2 under raw → render fits, raw does not.
301
+ seedSeparatorShaped(root, { capDelta: -2 });
302
+ const { code, errText } = run(['--check'], root);
303
+ assert.equal(code, 1, 'raw lines are what the docs cap-validator counts — never false-green');
304
+ assert.match(errText, /decisions\.md is over its cap/);
305
+ });
306
+
307
+ it('rotation performs a NORMALIZE-ONLY rewrite when raw is over cap but the render fits (no moves)', () => {
308
+ const root = makeRoot();
309
+ seedSeparatorShaped(root, { capDelta: -2 });
310
+ const { code, text } = run(['--today=2026-01-02'], root);
311
+ assert.equal(code, 0, text);
312
+ assert.match(text, /normalize-only rewrite/);
313
+ assert.match(text, /HOT→WARM: \(none\)/);
314
+ const after = parseDecisionsText(readFileSync(join(root, HOT_REL), 'utf8'), HOT_REL);
315
+ assert.deepEqual(after.entries.map((e) => e.id), ['001', '002', '003'], 'ids conserved, nothing moved');
316
+ const { code: recheck } = run(['--check'], root);
317
+ assert.equal(recheck, 0, 'the normalized file now fits its cap on raw lines');
318
+ });
319
+
320
+ it('a tier STILL over cap after the planned rewrite refuses BEFORE any write (a normalized rewrite is not a licence)', () => {
321
+ const root = makeRoot();
322
+ // COLD legitimately exceeds its cap (entries, not formatting): no move can fix it and a
323
+ // normalize-only rewrite would still be over budget — the run must refuse with files untouched.
324
+ seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001', '002'], coldCapDelta: -3 });
325
+ const snapshot = () => [HOT_REL, WARM_REL, COLD_REL].map((rel) => readFileSync(join(root, rel), 'utf8'));
326
+ const before = snapshot();
327
+ const { code, errText } = run(['--today=2026-01-02'], root);
328
+ assert.equal(code, 1);
329
+ assert.match(errText, /would still be over its cap after rotation/);
330
+ assert.match(errText, /maintainer\/agent decision/);
331
+ assert.deepEqual(snapshot(), before, 'no file changed on the refused plan');
332
+ });
333
+
334
+ it('a normalize-only rewrite NEVER materializes absent WARM/COLD files (no premature archives)', () => {
335
+ const root = makeRoot();
336
+ seedSeparatorShaped(root, { capDelta: -2 }); // HOT only — no history files exist
337
+ const { code } = run(['--today=2026-01-02'], root);
338
+ assert.equal(code, 0);
339
+ assert.ok(!existsSync(join(root, WARM_REL)), 'an absent WARM with zero entries is not created');
340
+ assert.ok(!existsSync(join(root, COLD_REL)), 'an absent COLD with zero entries is not created');
341
+ });
342
+ });
343
+
344
+ describe('created tiers (a consumer with no history files yet)', () => {
345
+ it('rolling into an absent WARM creates it with frontmatter + preamble', () => {
346
+ const root = makeRoot();
347
+ mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
348
+ const blocks = [entryBlock('001'), entryBlock('002')];
349
+ const probe = tierText(999, '# ADRs', blocks);
350
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
351
+ const { code } = run(['--today=2026-01-02'], root);
352
+ assert.equal(code, 0);
353
+ assert.ok(existsSync(join(root, WARM_REL)), 'WARM created');
354
+ const warm = parseDecisionsText(readFileSync(join(root, WARM_REL), 'utf8'), WARM_REL);
355
+ assert.deepEqual(warm.entries.map((e) => e.id), ['001']);
356
+ assert.equal(warm.cap, 500, 'created WARM carries the default cap');
357
+ assert.match(warm.preamble, /AD-001 … AD-001/, 'created preamble range filled');
358
+ });
359
+
360
+ it('usage errors are loud (exit 2)', () => {
361
+ const { code, errText } = run(['--frobnicate'], makeRoot());
362
+ assert.equal(code, 2);
363
+ assert.match(errText, /Unknown argument/);
364
+ });
365
+ });
@@ -48,6 +48,7 @@ node scripts/check-docs-size.mjs
48
48
  node scripts/check-docs-size.mjs --check-index
49
49
  node scripts/archive-changelog.mjs --check
50
50
  node scripts/archive-issues.mjs --check
51
+ node scripts/archive-decisions.mjs --check
51
52
  node --test scripts/*.test.mjs
52
53
  `;
53
54
 
@@ -83,6 +83,7 @@ Apply these when authoring a plan, reviewing, folding a finding, or editing code
83
83
  - **Per-round emission.** Every review round emits **{round N · finding-origin tally · per-backend verdict}** so the crossover is a computed, visible signal, not a remembered rule.
84
84
  - **Recipe fidelity.** Council runs every backend the recipe names, **every round**; silently dropping a ready backend for quota/convenience is a forbidden downgrade — an unavailable backend is a LOUD, stated degrade, never a quiet drop.
85
85
  - **ExitPlanMode ≠ execute.** A harness "approved — start coding" prompt authorizes the PLAN only; this methodology overrides it. Continue into execution only as a DELIBERATE transition after the plan + cold-start prompt exist, never an implicit slide.
86
+ - **Cost lanes.** Route every step to the **cheapest adequate executor** — L0 deterministic script (the batched gate matrix over `gates.json`, the rotation `--check`s) · L1 cheap subagent (extraction/drafting only; the orchestrator verifies) · L2 subscription bridge · L3 frontier judgment. A step with **no named guardrail does not move down** a lane, and the **red lines never move down** (council review models · real code · ADR/handover/changelog-entry wording · persuasive copy · go/no-go · the approval asks). Own-error repair: salvage recorded state first (L0/L1, batched), never frontier re-derivation.
86
87
 
87
88
  ---
88
89
 
@@ -0,0 +1,4 @@
1
+ {
2
+ "_README": "Per-project gate declaration: the ordered list of verification commands (tests, validators, scanners, docs checks) that must be green before a commit. Run them all in one batch with the family gate runner (the composition root's `gates` command); re-run one with `--only <id>`. Each entry is { id, title, cmd }: `id` = a unique kebab-case handle, `title` = a short human label, `cmd` = ONE bash command line — gates are spawned via bash (brace/glob expansion works; a host without bash gets a loud preflight error, never a silent reinterpretation under another shell). This file declares WHAT to check, never who executes it — the schema has no lane/model/routing fields and rejects unknown keys loudly. Trust posture: the runner executes this project's OWN declared commands with the caller's privileges — a batching convenience over commands the project already runs by hand, not a sandbox. Strict JSON — no comments.",
3
+ "gates": []
4
+ }