@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 +51 -0
- package/SKILL.md +70 -19
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/archive-decisions.mjs +424 -0
- package/references/scripts/archive-decisions.test.mjs +365 -0
- package/references/scripts/install-git-hooks.mjs +1 -0
- package/references/templates/agent_rules.md +1 -0
- package/references/templates/gates.json +4 -0
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
|
+
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
|
|
100
|
-
**user-editable** `docs/ai/orchestration.json`
|
|
101
|
-
the composition root's `procedures` advisor reads
|
|
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
|
|
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`).
|
|
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
|
|
166
|
-
|
|
167
|
-
if
|
|
168
|
-
already
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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).
|
|
190
|
-
|
|
191
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-memory",
|
|
3
|
-
"version": "1.
|
|
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
|
+
});
|
|
@@ -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
|
+
}
|