@sabaiway/agent-workflow-memory 4.7.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,80 @@ 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 `3.0.0`).
6
6
 
7
+ ## 6.0.0 — a rolling archive stamps a cap it can honour, and refuses past a ceiling (AD-118)
8
+
9
+ The changelog archiver wrote each tier's frontmatter `maxLines` as a LITERAL in the builder that
10
+ emitted it: WARM 3500, COLD 1500, META 300. A literal is a promise about a corpus nobody has seen
11
+ yet, so the day a tier outgrew its number the archiver emitted a file the docs gate refuses — its
12
+ own output failing its own gate, and the repair was a hand edit that the next run stamped straight
13
+ back over.
14
+
15
+ > ### ⚠ BREAKING — the archiver now exits non-zero on input it used to accept
16
+ >
17
+ > New module `references/scripts/archive-caps.mjs` exports `capFor({tier, count})` over a frozen
18
+ > floor/ceiling table — COLD 1500/3000, WARM 3500/7000, META 1500/3000. Once a tier's line count
19
+ > goes PAST its ceiling the run REFUSES rather than stamp a cap it cannot honour, and it refuses
20
+ > identically in every mode: the default run, `--dry-run` and `--check` alike. META's floor also
21
+ > RISES 300 → 1500.
22
+ >
23
+ > **When it bites.** These bytes reach a project's `scripts/` only on a FRESH bootstrap, a
24
+ > `migrate-adr-store` refresh, or a hand copy — installing this package does NOT overwrite an
25
+ > existing deployed `archive-changelog.mjs`, and delivery to existing deployments is deliberately
26
+ > out of scope here (a filed row). Once the bytes do land, a project whose archive has already
27
+ > outgrown a ceiling sees its `changelog-rotation` gate turn red with no edit of its own, and an
28
+ > existing `condensed-index.md` is re-stamped on the next run.
29
+
30
+ - **The stamp is now a function of the file.** Below the floor a tier stamps the floor; inside the
31
+ band it stamps its OWN final line count; the ceiling itself is still stamped. `count` is the count
32
+ the docs gate computes (`check-docs-size.mjs` — a trailing newline ends a line, it never opens
33
+ one), restated as `countLines` so the stamp and the judge cannot disagree. Each builder renders
34
+ twice: once with a placeholder to learn its length, once with what `capFor` returns. The stamped
35
+ integer is one line whatever its value, so the first render's count IS the final count and the
36
+ stamp is a fixed point.
37
+ - **Every cap-bearing output is BUILT before the mode branch.** WARM/COLD/META used to be built only
38
+ on the write path, so `--check` and `--dry-run` could never have seen a ceiling breach. A `--check`
39
+ that went green on a corpus the next real run cannot write is the same fail-open the parse already
40
+ refuses to be. Building is pure, so a refusal still leaves the tree byte-unchanged.
41
+ - **The ceiling is 2x the floor, FIXED here, never measured from a corpus.** The refusal rides a
42
+ standing `--check` gate, so a ceiling derived from whatever happened to be on disk the day someone
43
+ wrote the test would brick every commit as soon as the corpus grew past it. Raising one is a
44
+ reviewed edit to the table, never something a run decides for itself.
45
+ - **A sharding tripwire warns at the FLOOR.** META grows O(total archived entries) and never sheds,
46
+ so it reaches its ceiling first; warning while there is still room puts the remedy in front of
47
+ whoever runs the archiver. Both the COLD and META remedies state their sharding as NOT
48
+ IMPLEMENTED and say why — COLD discovery matches `YYYY-MM.md` only, so a hand-split file would
49
+ drop out of the corpus, and META is always regenerated whole. A remedy must never instruct a
50
+ layout the archiver cannot read back.
51
+ - Deliberately out of scope, each a filed row: the HOT changelog's own fallback stamp, the sibling
52
+ `archive-issues` / `archive-decisions` stampers, and per-year META sharding.
53
+
54
+ ## 5.0.0 — the scenario floor: a contract can no longer pin NOTHING (AD-117)
55
+
56
+ The reader enforced a minimum on `## Out of scope` and none on `## Scenarios`. A `kind: spec`
57
+ document could therefore carry an EMPTY scenario section and pass everything — the reader, both
58
+ `spec-check` lanes, the gate row. Measured cost, from writing the layer's first real specs: three
59
+ stub specs would have satisfied every mechanical check of that slice and delivered nothing.
60
+
61
+ > ### ⚠ BREAKING — the reader refuses a document it used to accept
62
+ >
63
+ > `SPEC_SCHEMA.rules` gains ONE frozen rule, **`scenarios-empty`**, in table position between
64
+ > `scenario-path` and `out-of-scope`: a `## Scenarios` section carrying no scenario line is now an
65
+ > error. The rule list is a frozen ordered contract, so a consumer that deep-equals it sees a 34th
66
+ > id, and a deployment's spec gate can turn red with no edit of its own. The remedy is one line per
67
+ > scenario — `- S<N> <name> :: unbound` while no test pins it.
68
+
69
+ - **`*(empty)*` is deliberately NOT an escape here.** Written under `## Scenarios` it still refuses,
70
+ as `scenario-line`, and a fixture now pins that. The asymmetry with `## Out of scope` (where the
71
+ marker IS the decision) is the point: an empty exclusion list is a claim, an empty scenario list is
72
+ an absence, and the grammar already carries the honest form for the absence.
73
+ - **`unbound` accepts on every status** — `draft`, `live` and `retired` alike — so the floor costs a
74
+ spec nothing but the statement that a scenario exists.
75
+ - Measured before shipping: no document in the fixture corpus, the live store or the shipped
76
+ templates carries an empty section, so the floor refuses nothing that already exists.
77
+ - The other 33 rule ids, every existing refusal and the whole `structure` extraction stay
78
+ behaviour-identical. The new arm was red-proofed against the pre-change reader.
79
+
80
+
7
81
  ## 4.7.0 — the reader's verdict gains the additive `structure` extraction (AD-114)
8
82
 
9
83
  The slice-2 checker (`spec-check`, next release) must read a document's structure through the SAME
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: '4.7.0'
6
+ version: '6.0.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
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": "4.7.0",
6
+ "version": "6.0.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": "4.7.0",
3
+ "version": "6.0.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,110 @@
1
+ #!/usr/bin/env node
2
+ // The ONE cap table the rolling changelog archive stamps from.
3
+ //
4
+ // Each tier's frontmatter `maxLines` used to be a LITERAL inside the builder that wrote it: COLD
5
+ // 1500, WARM 3500, META 300. A literal is a promise about a corpus nobody has seen yet, so the day a
6
+ // tier outgrew its number the archiver emitted a file the docs gate refuses — its own output failing
7
+ // its own gate — and the repair was to hand-raise the number in the emitted file until the next run
8
+ // stamped the literal straight back over it.
9
+ //
10
+ // capFor stamps a cap the file HONOURS: the tier FLOOR while the file fits under it, the file's OWN
11
+ // line count once it does not, and a REFUSAL once that count passes the tier CEILING. A growing
12
+ // archive therefore stays green with no hand edit, and an archive that has grown past what its tier
13
+ // was ever meant to hold STOPS the run instead of stamping a number nobody chose.
14
+ //
15
+ // The ceiling is 2x the floor, FIXED here and never measured from a corpus. The refusal rides the
16
+ // standing `--check` gate, so a ceiling derived from whatever happened to be on disk the day someone
17
+ // wrote the test would brick every commit as soon as the corpus grew past it.
18
+ //
19
+ // Dependency-free (one sibling import for the shared failure shape), Node >= 22. No side effects on
20
+ // import.
21
+
22
+ import { fail } from './markdown-blocks.mjs';
23
+
24
+ // Each tier carries its OWN remedy, because the three run out of room for different reasons and one
25
+ // piece of advice is wrong for two of them: COLD is already sharded per month, and WARM is a window
26
+ // whose size is a flag, not a file layout.
27
+ //
28
+ // A remedy must never instruct a layout this archiver cannot READ BACK. COLD files are discovered by
29
+ // `/^\d{4}-\d{2}\.md$/` alone, so a hand-split `2026-03-a.md` would silently drop out of the corpus
30
+ // on the next run — conservation lost by following our own advice. META is always regenerated as a
31
+ // single `condensed-index.md`, so a per-year file would be orphaned the moment it was written. Both
32
+ // therefore name the work as UNIMPLEMENTED rather than telling anyone to do it. Only WARM's remedy
33
+ // is something a run supports today: `--warm-days` is a flag, not a file layout.
34
+ export const CAP_TABLE = Object.freeze({
35
+ // COLD — docs/ai/history/YYYY-MM.md: one CLOSED month of compressed entries. A closed month never
36
+ // grows again, so its stamp is a fact about that month, not a forecast.
37
+ cold: Object.freeze({
38
+ floor: 1500,
39
+ ceiling: 3000,
40
+ remedy: 'compress that month harder — it is already one file per month, and sub-month sharding is NOT implemented (discovery matches YYYY-MM.md only, so a hand-split file would drop out of the corpus)',
41
+ }),
42
+ // WARM — docs/ai/history/recent.md: a rolling window of full-text entries, the widest tier.
43
+ warm: Object.freeze({
44
+ floor: 3500,
45
+ ceiling: 7000,
46
+ remedy: 'shorten the WARM window with --warm-days so the oldest entries move on to COLD',
47
+ }),
48
+ // META — docs/ai/history/condensed-index.md: one line per archived entry, so it grows O(total) and
49
+ // never sheds. Its floor rose 300 -> 1500 because 300 never fit the ~1159-at-2y growth the
50
+ // archiver's own header documents; the ceiling is where per-year sharding stops being optional.
51
+ meta: Object.freeze({
52
+ floor: 1500,
53
+ ceiling: 3000,
54
+ remedy: 'shard it per-year (condensed-index-YYYY.md) — NOT implemented yet: this file is always regenerated whole, so the split needs discovery and generation before anyone splits it by hand',
55
+ }),
56
+ });
57
+
58
+ export const TIERS = Object.freeze(Object.keys(CAP_TABLE));
59
+
60
+ // The docs gate's OWN line count (check-docs-size.mjs): a trailing newline terminates the last line,
61
+ // it does not open a new one. Counting any other way would stamp a cap the gate disagrees with —
62
+ // precisely the failure this module exists to end.
63
+ export const countLines = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
64
+
65
+ // Object.hasOwn, never a bare lookup: `CAP_TABLE['toString']` finds an INHERITED function, which is
66
+ // truthy, and the stamp then reads `undefined` off it and writes `maxLines: undefined`. An unknown
67
+ // tier has to refuse, and "unknown" includes every name Object.prototype happens to carry.
68
+ const bandFor = (tier) => {
69
+ if (!Object.hasOwn(CAP_TABLE, tier)) {
70
+ throw fail(1, `unknown archive tier: ${String(tier)} (known: ${TIERS.join(', ')})`);
71
+ }
72
+ return CAP_TABLE[tier];
73
+ };
74
+
75
+ const assertCount = (tier, count) => {
76
+ if (!Number.isInteger(count) || count < 0) {
77
+ throw fail(1, `${tier}: line count must be a non-negative integer, got ${JSON.stringify(count)}`);
78
+ }
79
+ };
80
+
81
+ // The stamp. `count` is the FINAL line count of the file being written, frontmatter included — the
82
+ // caller renders the whole file once with a placeholder cap, counts it, and renders again with what
83
+ // this returns. The stamped integer occupies one line either way, so the second render has the same
84
+ // count as the first and the value is a fixed point.
85
+ export const capFor = ({ tier, count }) => {
86
+ const band = bandFor(tier);
87
+ assertCount(tier, count);
88
+ if (count > band.ceiling) {
89
+ throw fail(
90
+ 1,
91
+ `${tier} archive is ${count} lines, past its ${band.ceiling}-line ceiling — there is no cap it ` +
92
+ `can both stamp and honour. Remedy: ${band.remedy}. Raising the ceiling is a reviewed edit to ` +
93
+ `archive-caps.mjs, never something a run decides for itself.`,
94
+ );
95
+ }
96
+ return count > band.floor ? count : band.floor;
97
+ };
98
+
99
+ // The sharding tripwire. A tier past its FLOOR still stamps and still passes — it is simply growing
100
+ // on room the floor was not sized for. Warning here puts the remedy in front of whoever runs the
101
+ // archiver while there is still headroom, instead of at the ceiling where the run refuses.
102
+ export const shardingWarning = ({ tier, count }) => {
103
+ const band = bandFor(tier);
104
+ assertCount(tier, count);
105
+ if (count <= band.floor) return null;
106
+ return (
107
+ `${tier} archive is ${count} lines, past its ${band.floor}-line floor (ceiling ${band.ceiling}) — ` +
108
+ `it is stamping its own count. Remedy before the count goes PAST the ceiling and the run refuses: ${band.remedy}.`
109
+ );
110
+ };
@@ -0,0 +1,321 @@
1
+ import { describe, it } from 'node:test';
2
+ import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { expect } from './_expect-shim.mjs';
6
+ import { runCli } from './archive-changelog.mjs';
7
+
8
+ // RED-FIRST, and deliberately a DYNAMIC import: `archive-caps.mjs` does not exist when these arms
9
+ // are first recorded, and a STATIC import of an absent module makes the WHOLE file unloadable — every
10
+ // arm would then be recorded as "unresolvable" rather than as failing, which proves nothing about the
11
+ // behaviour it pins. Imported this way the file still loads and each arm fails on its own missing
12
+ // symbol.
13
+ const caps = await import('./archive-caps.mjs').catch(() => ({}));
14
+
15
+ // The tier table, restated here on purpose. A test that imports the table it is checking agrees with
16
+ // the source by construction and pins nothing; these three rows are the frozen contract, and moving a
17
+ // number in the module must break this file.
18
+ const TABLE = [
19
+ ['cold', 1500, 3000],
20
+ ['warm', 3500, 7000],
21
+ ['meta', 1500, 3000],
22
+ ];
23
+
24
+ // Every arm builds its own corpus under a temp root and passes a fixed `--today`, so nothing here
25
+ // reads the project's own `docs/ai` — these pass in a clean checkout where that tree is absent.
26
+ const TODAY = '2026-06-15';
27
+ const FM = '---\ntype: history\nlastUpdated: 2026-06-15\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: 700\n---\n';
28
+
29
+ const makeRoot = () => {
30
+ const root = mkdtempSync(join(tmpdir(), 'archive-caps-'));
31
+ mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
32
+ return root;
33
+ };
34
+
35
+ // TALL entries carry a files block and a metric, so compression keeps eight lines of them; MINIMAL
36
+ // entries keep three. The difference is how a tier is driven to a chosen size: COLD/WARM need height,
37
+ // META needs COUNT (one line per archived entry) with every month's own COLD file left small.
38
+ const entryBlock = (dateStr, n, tall) =>
39
+ tall
40
+ ? `## ${dateStr} — synthetic ${n}\n\n**Goal:** synthetic session ${n}.\n\n**Files:**\n- \`src/f${n}.mjs\`\n\n**Result:** ${n} tests\n`
41
+ : `## ${dateStr} — synthetic ${n}\n\n**Goal:** synthetic session ${n}.\n`;
42
+
43
+ const seed = (root, dates, tall = true) => {
44
+ const body = dates.map((d, i) => entryBlock(d, i + 1, tall)).join('\n');
45
+ writeFileSync(join(root, 'docs', 'ai', 'changelog.md'), `${FM}\n# Changelog\n\n${body}`, 'utf8');
46
+ };
47
+
48
+ const day = (i) => String((i % 28) + 1).padStart(2, '0');
49
+
50
+ // One month, well behind the WARM cutoff — everything lands in that month's COLD file.
51
+ const oneMonth = (count) => Array.from({ length: count }, (_, i) => `2026.03.${day(i)}`);
52
+
53
+ // Inside the WARM window (older than today-3, newer than today-30 = 2026-05-16).
54
+ const warmWindow = (count) =>
55
+ Array.from({ length: count }, (_, i) => `2026.05.${String(17 + (i % 14)).padStart(2, '0')}`);
56
+
57
+ // Spread across many CLOSED months so META grows by count while no single COLD file gets near its
58
+ // own ceiling — the only shape in which META can be the tier that refuses.
59
+ const manyMonths = (months, perMonth) => {
60
+ const dates = [];
61
+ for (let m = 0; m < months; m += 1) {
62
+ const year = 2019 + Math.floor(m / 12);
63
+ const month = String((m % 12) + 1).padStart(2, '0');
64
+ for (let i = 0; i < perMonth; i += 1) dates.push(`${year}.${month}.${day(i)}`);
65
+ }
66
+ return dates;
67
+ };
68
+
69
+ const run = (root, argv) => {
70
+ const out = [];
71
+ const err = [];
72
+ const code = runCli([...argv, `--today=${TODAY}`], {
73
+ root,
74
+ log: (m) => out.push(String(m)),
75
+ logError: (m) => err.push(String(m)),
76
+ });
77
+ return { code, out: out.join('\n'), err: err.join('\n') };
78
+ };
79
+
80
+ const historyDir = (root) => join(root, 'docs', 'ai', 'history');
81
+ const emitted = (root, name) => readFileSync(join(historyDir(root), name), 'utf8');
82
+
83
+ // A refusal must leave the tree BYTE-unchanged, and "the history dir is still empty" does not say
84
+ // that: it would miss a rewritten changelog.md. Snapshot every file under docs/ai instead.
85
+ // Built with fromCharCode, not an escape literal: the separator only has to be deterministic, and
86
+ // spelling it in code points keeps a stray byte out of a file the NUL guard scans.
87
+ const SEP = String.fromCharCode(10, 64, 64, 10);
88
+ const snapshot = (root) => {
89
+ const base = join(root, 'docs', 'ai');
90
+ const walk = (dir, prefix) =>
91
+ readdirSync(dir, { withFileTypes: true })
92
+ .sort((a, b) => a.name.localeCompare(b.name))
93
+ .flatMap((entry) =>
94
+ entry.isDirectory()
95
+ ? walk(join(dir, entry.name), `${prefix}${entry.name}/`)
96
+ : [`${prefix}${entry.name}:${readFileSync(join(dir, entry.name), 'utf8')}`],
97
+ );
98
+ return walk(base, '').join(SEP);
99
+ };
100
+ const stampOf = (text) => Number(/^maxLines: (\d+)$/m.exec(text)[1]);
101
+ const countOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
102
+
103
+ const refusal = (fn) => {
104
+ try {
105
+ fn();
106
+ return null;
107
+ } catch (err) {
108
+ return err;
109
+ }
110
+ };
111
+
112
+ const withRoot = (body) => {
113
+ const root = makeRoot();
114
+ try {
115
+ body(root);
116
+ } finally {
117
+ rmSync(root, { recursive: true, force: true });
118
+ }
119
+ };
120
+
121
+ describe('capFor — the frozen tier table', () => {
122
+ for (const [tier, floor, ceiling] of TABLE) {
123
+ it(`capFor: ${tier} stamps the floor below it, its own count inside the band, and the ceiling itself`, () => {
124
+ expect(caps.capFor({ tier, count: 0 })).toBe(floor);
125
+ expect(caps.capFor({ tier, count: floor - 1 })).toBe(floor);
126
+ expect(caps.capFor({ tier, count: floor })).toBe(floor);
127
+ expect(caps.capFor({ tier, count: floor + 1 })).toBe(floor + 1);
128
+ expect(caps.capFor({ tier, count: ceiling - 1 })).toBe(ceiling - 1);
129
+ // The boundary ACCEPTS. A cap equal to the count is a cap the file honours, so the ceiling is
130
+ // the LAST count that can be stamped, never the first that refuses — and stamping the count
131
+ // (not the ceiling) here is what separates this from a `Math.min(count, ceiling)` that would
132
+ // pass the boundary arm while getting the whole band wrong.
133
+ expect(caps.capFor({ tier, count: ceiling })).toBe(ceiling);
134
+ });
135
+
136
+ it(`capFor: ${tier} refuses one line past its ceiling`, () => {
137
+ const err = refusal(() => caps.capFor({ tier, count: ceiling + 1 }));
138
+ expect(err).not.toBeNull();
139
+ expect(err.message).toContain(`${ceiling}-line ceiling`);
140
+ expect(err.exitCode).toBe(1);
141
+ });
142
+ }
143
+
144
+ it('capFor: an unknown tier and a nonsense count both refuse rather than stamp a default', () => {
145
+ expect(refusal(() => caps.capFor({ tier: 'lukewarm', count: 10 })).message).toContain('unknown archive tier');
146
+ expect(refusal(() => caps.capFor({ tier: 'cold', count: -1 })).message).toContain('non-negative integer');
147
+ expect(refusal(() => caps.capFor({ tier: 'cold', count: 1.5 })).message).toContain('non-negative integer');
148
+ });
149
+
150
+ it('countLines: counts the docs gate its own way — a trailing newline ends a line, it never opens one', () => {
151
+ expect(caps.countLines('a\nb\n')).toBe(2);
152
+ expect(caps.countLines('a\nb')).toBe(2);
153
+ expect(caps.countLines('a\n')).toBe(1);
154
+ expect(caps.countLines('a\n\n')).toBe(2);
155
+ });
156
+
157
+ it('shardingWarning: silent under the floor, and past it names the tier, the count and the remedy', () => {
158
+ expect(caps.shardingWarning({ tier: 'meta', count: 1500 })).toBeNull();
159
+ const warning = caps.shardingWarning({ tier: 'meta', count: 1501 });
160
+ expect(warning).toContain('1501');
161
+ expect(warning).toContain('1500-line floor');
162
+ expect(warning).toContain('condensed-index-YYYY.md');
163
+ });
164
+ });
165
+
166
+ describe('the archiver stamps a cap it can honour', () => {
167
+ it('cold: a band-sized month stamps its own count and round-trips byte-identically', () => {
168
+ withRoot((root) => {
169
+ seed(root, oneMonth(160));
170
+ expect(run(root, []).code).toBe(0);
171
+
172
+ const first = emitted(root, '2026-03.md');
173
+ const count = countOf(first);
174
+ // Strictly inside the band: below the floor the tier would stamp the floor and this arm would
175
+ // pin nothing; at or above the ceiling it would refuse.
176
+ expect(count).toBeGreaterThan(1500);
177
+ expect(count).toBeLessThan(3001);
178
+ expect(stampOf(first)).toBe(count);
179
+
180
+ // The byte fixed point, under the new stamp: run two re-reads what run one wrote — including
181
+ // the cap it stamped — and must reproduce it exactly.
182
+ expect(run(root, []).code).toBe(0);
183
+ expect(emitted(root, '2026-03.md')).toBe(first);
184
+ });
185
+ });
186
+
187
+ it('warm: a band-sized window stamps its own count and round-trips byte-identically', () => {
188
+ withRoot((root) => {
189
+ seed(root, warmWindow(400));
190
+ expect(run(root, []).code).toBe(0);
191
+
192
+ const first = emitted(root, 'recent.md');
193
+ const count = countOf(first);
194
+ expect(count).toBeGreaterThan(3500);
195
+ expect(count).toBeLessThan(7001);
196
+ expect(stampOf(first)).toBe(count);
197
+
198
+ expect(run(root, []).code).toBe(0);
199
+ expect(emitted(root, 'recent.md')).toBe(first);
200
+ });
201
+ });
202
+
203
+ it('meta: a band-sized corpus stamps its own count and round-trips byte-identically', () => {
204
+ withRoot((root) => {
205
+ seed(root, manyMonths(32, 50), false);
206
+ expect(run(root, []).code).toBe(0);
207
+
208
+ const first = emitted(root, 'condensed-index.md');
209
+ const count = countOf(first);
210
+ expect(count).toBeGreaterThan(1500);
211
+ expect(count).toBeLessThan(3001);
212
+ expect(stampOf(first)).toBe(count);
213
+
214
+ expect(run(root, []).code).toBe(0);
215
+ expect(emitted(root, 'condensed-index.md')).toBe(first);
216
+ });
217
+ });
218
+
219
+ it('a tier under its floor still stamps the floor — the cap never shrinks to the corpus', () => {
220
+ withRoot((root) => {
221
+ seed(root, oneMonth(4));
222
+ expect(run(root, []).code).toBe(0);
223
+ const cold = emitted(root, '2026-03.md');
224
+ expect(countOf(cold)).toBeLessThan(1500);
225
+ expect(stampOf(cold)).toBe(1500);
226
+ });
227
+ });
228
+ });
229
+
230
+ describe('the archiver refuses past a ceiling, identically in every mode', () => {
231
+ // The refusal has to fire in --check and --dry-run as well as the default run, or the standing
232
+ // `--check` gate would go green on a corpus the next real run cannot write. That is only true if
233
+ // every output is BUILT before the mode branch — which is what these three arms pin.
234
+ const modes = [[], ['--dry-run'], ['--check']];
235
+
236
+ // Each mode gets its OWN fixture: sharing one root lets an earlier mode's write hide inside a
237
+ // later mode's assertion. And the tree is compared WHOLE before and after, so a rewritten
238
+ // changelog.md is caught as well as a written archive.
239
+ const refusesInEveryMode = (dates, tall, ceiling) => {
240
+ for (const argv of modes) {
241
+ withRoot((root) => {
242
+ seed(root, dates, tall);
243
+ const before = snapshot(root);
244
+ const result = run(root, argv);
245
+ expect(result.code).toBe(1);
246
+ expect(result.err).toContain(`${ceiling}-line ceiling`);
247
+ expect(snapshot(root)).toBe(before);
248
+ });
249
+ }
250
+ };
251
+
252
+ it('cold: a month past the ceiling refuses in all three modes', () => {
253
+ refusesInEveryMode(oneMonth(400), true, 3000);
254
+ });
255
+
256
+ it('warm: a window past the ceiling refuses in all three modes', () => {
257
+ refusesInEveryMode(warmWindow(700), true, 7000);
258
+ });
259
+
260
+ it('meta: a corpus past the ceiling refuses in all three modes', () => {
261
+ refusesInEveryMode(manyMonths(62, 50), false, 3000);
262
+ });
263
+
264
+ // Two arms, deliberately separate: this one pins that a refusal is ACTIONABLE at all — it names
265
+ // the tier, the measured size and a remedy clause, rather than a bare number the reader has to
266
+ // interpret.
267
+ it('the refusal names the tier and the remedy, not just the number', () => {
268
+ withRoot((root) => {
269
+ seed(root, oneMonth(400));
270
+ const cold = run(root, ['--check']);
271
+ expect(cold.err).toContain('cold archive is');
272
+ expect(cold.err).toContain('-line ceiling');
273
+ expect(cold.err).toContain('Remedy:');
274
+ });
275
+ });
276
+
277
+ // And this one pins that the remedy is RIGHT for the tier it is printed for. One piece of advice
278
+ // would be wrong for two of the three: COLD is already one file per month, and WARM's size is a
279
+ // flag rather than a layout. A remedy must also never name a layout the archiver cannot read back.
280
+ it('the refusal names the tier and a remedy that fits THAT tier', () => {
281
+ withRoot((root) => {
282
+ seed(root, oneMonth(400));
283
+ const cold = run(root, ['--check']);
284
+ expect(cold.err).toContain('already one file per month');
285
+ expect(cold.err).toContain('NOT implemented');
286
+ });
287
+ withRoot((root) => {
288
+ seed(root, warmWindow(700));
289
+ const warm = run(root, ['--check']);
290
+ expect(warm.err).toContain('warm archive is');
291
+ expect(warm.err).toContain('--warm-days');
292
+ });
293
+ withRoot((root) => {
294
+ seed(root, manyMonths(62, 50), false);
295
+ const meta = run(root, ['--check']);
296
+ expect(meta.err).toContain('meta archive is');
297
+ expect(meta.err).toContain('condensed-index-YYYY.md');
298
+ });
299
+ });
300
+ });
301
+
302
+ describe('the sharding tripwire', () => {
303
+ it('meta past its floor warns while still passing, so the remedy lands before the ceiling', () => {
304
+ withRoot((root) => {
305
+ seed(root, manyMonths(32, 50), false);
306
+ const result = run(root, []);
307
+ expect(result.code).toBe(0);
308
+ expect(result.err).toContain('past its 1500-line floor');
309
+ expect(result.err).toContain('condensed-index-YYYY.md');
310
+ });
311
+ });
312
+
313
+ it('meta under its floor says nothing at all', () => {
314
+ withRoot((root) => {
315
+ seed(root, oneMonth(4));
316
+ const result = run(root, []);
317
+ expect(result.code).toBe(0);
318
+ expect(result.err).not.toMatch(/floor/);
319
+ });
320
+ });
321
+ });
@@ -13,19 +13,25 @@
13
13
  // does not parse as an entry refuses with file:line — it is never glued into the previous entry,
14
14
  // never duplicated into the footer, never normalised into a different calendar date.
15
15
  //
16
- // NOTE (multi-year scaling): condensed-index.md grows O(total archived entries),
17
- // so on a multi-year horizon it approaches its cap (~1159 lines over 2y in a stress
18
- // test). When it nears the cap, shard it per-year (condensed-index-YYYY.md) or switch
19
- // to an append-only cap. Stress-test rotation via the exported pure functions against
20
- // a /tmp copy seeded with a synthetic multi-year dataset (include burst periods).
16
+ // NOTE (multi-year scaling): condensed-index.md grows O(total archived entries) and never sheds, so
17
+ // on a multi-year horizon it is the tier that runs out of room first (~1159 lines over 2y in a
18
+ // stress test). Its floor is 1500 and its ceiling 3000 (archive-caps.mjs): past the FLOOR every run
19
+ // prints a sharding warning, and once the count goes PAST the ceiling the run refuses outright
20
+ // rather than stamp a cap it cannot honour the ceiling itself is still stamped. The remedy is per-year sharding (condensed-index-YYYY.md) a queued row, never
21
+ // something a run decides for itself. Stress-test rotation via the exported pure functions against
22
+ // a temp copy seeded with a synthetic multi-year dataset (include burst periods).
21
23
  //
22
24
  // Modes:
23
25
  // (default) run rotation, mutate files in place
24
26
  // --dry-run print planned distribution, do not change files
25
27
  // --check exit 1 if changelog.md still holds entries that should be archived
26
28
  //
27
- // Every mode parses every source BEFORE any write, so a refusal fires identically for the
28
- // default run, --dry-run and --check, and nothing is written on a refused input.
29
+ // Every mode parses every source AND builds every tier-STAMPED output (WARM, COLD, META) BEFORE any
30
+ // write, so a refusal an unparsable source, or a tier that has outgrown its ceiling — fires
31
+ // identically for the default run, --dry-run and --check, and nothing is written on a refused input.
32
+ // The HOT changelog is deliberately NOT in that set: buildChangelog passes the file's OWN
33
+ // frontmatter through and reaches a literal only when the file carries none. That fallback is
34
+ // unguarded, and it is a filed queue row rather than an oversight.
29
35
  //
30
36
  // CLI overrides:
31
37
  // --hot-days=N (default 3)
@@ -36,6 +42,7 @@ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, realpa
36
42
  import { dirname, resolve, basename } from 'node:path';
37
43
  import { fileURLToPath } from 'node:url';
38
44
  import { tokenizeMarkdown, findParagraphBreak, fail } from './markdown-blocks.mjs';
45
+ import { capFor, countLines, shardingWarning } from './archive-caps.mjs';
39
46
 
40
47
  const __filename = fileURLToPath(import.meta.url);
41
48
  const __dirname = dirname(__filename);
@@ -347,22 +354,25 @@ export const buildChangelog = ({ frontmatter, preamble, hot, footer, hasArchive
347
354
  return parts.filter((p) => p !== null && p !== undefined).join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
348
355
  };
349
356
 
357
+ // Each tier renders TWICE and stamps what the second render can honour. The cap is one line of
358
+ // frontmatter whatever integer it holds, so the first render's line count IS the final file's line
359
+ // count — which makes the stamp a fixed point rather than a promise about a corpus nobody has seen.
360
+ // capFor refuses outright past the tier's ceiling; see archive-caps.mjs.
361
+ const stamped = (tier, render) => render(capFor({ tier, count: countLines(render(0)) }));
362
+
350
363
  export const buildRecent = (entries, todayStr) => {
351
- const frontmatter = FRONTMATTER('history', 3500, todayStr);
352
364
  const preamble = `# Changelog WARM Archive — ${PROJECT_NAME}\n\n> Entries aged **7–30 days** from today. Newer → [\`../changelog.md\`](../changelog.md). Older → [\`condensed-index.md\`](./condensed-index.md) plus per-month \`YYYY-MM.md\` files.`;
353
365
  const body = renderEntries(entries);
354
- return `${frontmatter}\n${preamble}\n\n---\n\n${body}\n`;
366
+ return stamped('warm', (cap) => `${FRONTMATTER('history', cap, todayStr)}\n${preamble}\n\n---\n\n${body}\n`);
355
367
  };
356
368
 
357
369
  export const buildCold = (year, month, entries, todayStr) => {
358
- const frontmatter = FRONTMATTER('history', 1500, todayStr);
359
370
  const preamble = `# Changelog COLD Archive — ${year}-${month}\n\n> Compressed entries from ${year}-${month} (older than 30 days). Cross-month one-liners → [\`condensed-index.md\`](./condensed-index.md). Full commit history: \`git log --since=${year}-${month}-01 --until=${year}-${month}-31\`.`;
360
371
  const compressed = entries.map(compressEntry).join('\n\n---\n\n');
361
- return `${frontmatter}\n${preamble}\n\n---\n\n${compressed}\n`;
372
+ return stamped('cold', (cap) => `${FRONTMATTER('history', cap, todayStr)}\n${preamble}\n\n---\n\n${compressed}\n`);
362
373
  };
363
374
 
364
375
  export const buildCondensedIndex = (warmEntries, coldByMonth, todayStr) => {
365
- const frontmatter = FRONTMATTER('history', 300, todayStr);
366
376
  const intro = `# Condensed Index — ${PROJECT_NAME} Changelog\n\n> One-line TL;DR for every archived entry. Each line links to the file holding the full text.`;
367
377
 
368
378
  const lines = [];
@@ -378,7 +388,7 @@ export const buildCondensedIndex = (warmEntries, coldByMonth, todayStr) => {
378
388
  for (const e of coldByMonth.get(key)) lines.push(summarizeEntry(e, `${year}-${month}.md`));
379
389
  lines.push('');
380
390
  }
381
- return `${frontmatter}\n${intro}\n\n${lines.join('\n').trim()}\n`;
391
+ return stamped('meta', (cap) => `${FRONTMATTER('history', cap, todayStr)}\n${intro}\n\n${lines.join('\n').trim()}\n`);
382
392
  };
383
393
 
384
394
  export const groupByMonth = (entries) => {
@@ -471,6 +481,27 @@ export const runCli = (argv, deps = {}) => {
471
481
  coldFiles: [...coldByMonth.keys()].sort(),
472
482
  };
473
483
 
484
+ // EVERY cap-bearing output is BUILT here, before the mode branch, and capFor refuses a tier that
485
+ // has outgrown its ceiling. That refusal has to fire identically in all three modes: a `--check`
486
+ // that went green on a corpus the next real run cannot write would be exactly the fail-open the
487
+ // parse above already refuses to be. Building is pure — nothing reaches disk until the writes
488
+ // below, so a refusal still leaves the tree untouched.
489
+ const archives = [];
490
+ if (warm.length > 0) archives.push({ path: recentPath, text: buildRecent(warm, todayStr) });
491
+ for (const [key, entries] of coldByMonth) {
492
+ const [year, month] = key.split('-');
493
+ archives.push({ path: resolve(historyDir, `${year}-${month}.md`), text: buildCold(year, month, entries, todayStr) });
494
+ }
495
+ if (warm.length > 0 || cold.length > 0) {
496
+ const index = buildCondensedIndex(warm, coldByMonth, todayStr);
497
+ // The sharding tripwire. META grows O(total) and never sheds, so it is the tier that reaches
498
+ // its ceiling first; warning at the floor puts the remedy in front of whoever runs the
499
+ // archiver while there is still room, instead of at the ceiling where the run refuses.
500
+ const warning = shardingWarning({ tier: 'meta', count: countLines(index) });
501
+ if (warning) logError(`[archive-changelog] WARNING: ${warning}`);
502
+ archives.push({ path: indexPath, text: index });
503
+ }
504
+
474
505
  if (flags.check) {
475
506
  const tooOldInHot = parsed.entries.filter((e) => e.dateObj < cutoffs.hotCutoff);
476
507
  if (tooOldInHot.length > 0) {
@@ -516,18 +547,7 @@ export const runCli = (argv, deps = {}) => {
516
547
  });
517
548
  writeFileSync(changelogPath, newChangelog, 'utf8');
518
549
 
519
- if (warm.length > 0) {
520
- writeFileSync(recentPath, buildRecent(warm, todayStr), 'utf8');
521
- }
522
-
523
- for (const [key, entries] of coldByMonth) {
524
- const [year, month] = key.split('-');
525
- writeFileSync(resolve(historyDir, `${year}-${month}.md`), buildCold(year, month, entries, todayStr), 'utf8');
526
- }
527
-
528
- if (warm.length > 0 || cold.length > 0) {
529
- writeFileSync(indexPath, buildCondensedIndex(warm, coldByMonth, todayStr), 'utf8');
530
- }
550
+ for (const archive of archives) writeFileSync(archive.path, archive.text, 'utf8');
531
551
 
532
552
  log('[archive-changelog] migrated:');
533
553
  log(` HOT (${CHANGELOG_REL}): ${hot.length}`);
@@ -158,12 +158,19 @@ describe('buildChangelog', () => {
158
158
  });
159
159
 
160
160
  describe('buildRecent', () => {
161
- it('emits frontmatter with maxLines 3500 for WARM archive', () => {
161
+ it('emits frontmatter at the WARM floor for a corpus that fits under it', () => {
162
162
  const result = buildRecent([makeEntry('2026.05.10', 'warm')], '2026-05-24');
163
163
  expect(result).toMatch(/maxLines: 3500/);
164
164
  expect(result).toMatch(/Changelog WARM Archive/);
165
165
  expect(result).toMatch(/## 2026\.05\.10/);
166
166
  });
167
+
168
+ // 3500/1500/1500 are tier FLOORS reached through capFor (archive-caps.mjs), not literals a builder
169
+ // still carries — this pins all three onto that path; band/ceiling/fixed point: archive-caps.test.mjs.
170
+ it('every builder stamps its own tier floor on a corpus that fits under it', () => {
171
+ expect(buildCold('2026', '03', [makeEntry('2026.03.10', 'c')], '2026-05-24')).toMatch(/maxLines: 1500/);
172
+ expect(buildCondensedIndex([makeEntry('2026.05.10', 'w')], new Map(), '2026-05-24')).toMatch(/maxLines: 1500/);
173
+ });
167
174
  });
168
175
 
169
176
  describe('buildCold', () => {
@@ -38,7 +38,7 @@ export const SPEC_SCHEMA = Object.freeze({
38
38
  'frontmatter', 'frontmatter-key', 'substrate-key', 'type', 'kind', 'maxlines', 'status', 'revision',
39
39
  'root-owns', 'slug', 'kind-path', 'root-uplink', 'title', 'section-missing', 'section-order',
40
40
  'section-forbidden', 'fence', 'children-link', 'children-duplicate', 'fan-out', 'scenario-line',
41
- 'scenario-number', 'scenario-marker', 'scenario-path', 'out-of-scope', 'module-line', 'module-empty',
41
+ 'scenario-number', 'scenario-marker', 'scenario-path', 'scenarios-empty', 'out-of-scope', 'module-line', 'module-empty',
42
42
  'module-traversal', 'module-absolute', 'module-backslash', 'module-glob', 'module-mix', 'parts',
43
43
  ]),
44
44
  });
@@ -238,6 +238,10 @@ const checkScenarios = (parsed, slug, status, errors, warnings) => {
238
238
  }
239
239
  scenarios.push(scenario);
240
240
  }
241
+ if (scenarios.length === 0) {
242
+ errors.push({ rule: 'scenarios-empty', message: `at least one scenario line (\`${SPEC_SCHEMA.unboundMarker}\` while no test pins it)` });
243
+ return;
244
+ }
241
245
  const gap = scenarios.findIndex((scenario, i) => scenario.n !== i + 1);
242
246
  if (gap !== -1) errors.push({ rule: 'scenario-number', message: `scenario ${gap + 1} is numbered S${scenarios[gap].n} — N runs contiguously from 1` });
243
247
  for (const scenario of scenarios) {
@@ -131,6 +131,14 @@ describe('readSpecDocument — refuse, exactly one rule per defect', () => {
131
131
  refuses(specDoc({ scenarios: ['- S1 a :: /abs/login.test.mjs :: spec:login/S1'] }), 'login.md', 'scenario-path');
132
132
  });
133
133
 
134
+ it('scenarios-empty / no empty-marker escape', () => {
135
+ refuses(specDoc({ scenarios: [] }), 'login.md', 'scenarios-empty');
136
+ refuses(specDoc({ scenarios: ['*(empty)*'] }), 'login.md', 'scenario-line');
137
+ for (const status of SPEC_SCHEMA.statuses) {
138
+ expect(readSpecDocument(specDoc({ scenarios: ['- S1 x :: unbound'], fields: { status } }), 'login.md').errors).toEqual([]);
139
+ }
140
+ });
141
+
134
142
  it('out-of-scope / module-empty / module-* path refusals / module-mix / parts', () => {
135
143
  refuses(specDoc({ outOfScope: '' }), 'login.md', 'out-of-scope');
136
144
  refuses(specDoc({ outOfScope: 'nothing excluded' }), 'login.md', 'out-of-scope');