@sabaiway/agent-workflow-kit 7.6.0 → 9.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,63 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 9.0.0 — the archiver's cap table ships, and the migration seeds it behind its importer (AD-118)
8
+
9
+ Memory **6.0.0** replaces the changelog archiver's fixed cap literals with a stamp it can honour,
10
+ and this kit ships those bytes. One kit-owned behaviour moved with them, and it is the interesting
11
+ half: a new import by a refreshed archiver reopens a hole the companion seed exists to close.
12
+
13
+ > ### ⚠ BREAKING — inherited from memory 6.0.0
14
+ >
15
+ > The archiver REFUSES once a tier's line count goes past its ceiling, in every mode including
16
+ > `--check`, and META's floor rises 300 → 1500.
17
+ >
18
+ > **When it bites.** The new bytes reach a project's `scripts/` on a FRESH bootstrap, a
19
+ > `migrate-adr-store` refresh, or a hand copy — installing this kit does NOT overwrite an existing
20
+ > deployed `archive-changelog.mjs`. Once they land, a project whose archive has already outgrown a
21
+ > ceiling sees its `changelog-rotation` gate turn red with no edit of its own.
22
+
23
+ - **`references/scripts/archive-caps{,.test}.mjs`** — new, mirrored byte-identical from the memory
24
+ canon; `references/scripts/archive-changelog{,.test}.mjs` refreshed to the 6.0.0 bodies.
25
+ `sync-mirrors --check` green over all three copies.
26
+ - **`tools/migrate-adr-store.mjs` — `COMPANION_SEEDS` gains a per-entry condition.** The list exists
27
+ so a refreshed archiver never crashes on a missing runtime import, and it was flat: every entry
28
+ seeded on every migration. That was correct for what it held — the ONE runtime dependency
29
+ `markdown-blocks.mjs`, which all three archivers import, plus its deploy-payload test riding along
30
+ with it. `archive-caps.mjs` is imported by `archive-changelog.mjs` ALONE, so seeding it
31
+ unconditionally would write a file with no importer during a plain ADR migration — the directional
32
+ "never ADD a basename the consumer lacks" rule broken, and a log line saying something untrue. An
33
+ entry may now NAME the importer it rides: the markdown pair stays unconditional, the `archive-caps`
34
+ pair rides `archive-changelog.mjs`, and two arms pin both cases — importer present, importer absent.
35
+ - **`tools/known-footprint.mjs`** — `KIT_OWN_PATHS` gains both new script paths (30 → 32), so a
36
+ HIDDEN deployment does not leave them visible in `git status`.
37
+ - Tarball sentinels move with the payload on their documented ladders: kit 250 → 252, memory 59 → 61.
38
+
39
+ ## 8.0.0 — the bundled reader gets the scenario floor, and the refresh lane carries it out (AD-117)
40
+
41
+ Memory **5.0.0** gives the spec reader one new refusal — a `## Scenarios` section carrying no
42
+ scenario line — and this kit ships those bytes. Nothing in the kit's own logic changed shape; what
43
+ changed is what the deployed reader says about a document, and therefore what a project's spec gate
44
+ says about its store.
45
+
46
+ > ### ⚠ BREAKING — inherited from memory 5.0.0
47
+ >
48
+ > `spec-check` (both lanes) and the `spec-check` gate row relay the reader's verdict, so a spec that
49
+ > pins NOTHING now turns a deployment red with no edit of its own. The remedy is one line per
50
+ > scenario — `- S<N> <name> :: unbound` while no test pins it. The refusal is the migration signal:
51
+ > a contract that pins nothing is the one shape the layer exists to refuse.
52
+
53
+ - **`references/scripts/spec-schema{,.test}.mjs`** — refreshed to the 5.0.0 bodies; the four mirrored
54
+ copies stay byte-identical, `sync-mirrors --check` green.
55
+ - **`tools/script-priors.mjs`** — the catalog gains the two OUTGOING `4.7.0..4.7.0` reader rows
56
+ (`spec-schema.mjs`, `spec-schema.test.mjs`), read from the released bytes, so an unmodified 4.7.0
57
+ deployment is recognised as unmodified and converges on the next kit touch instead of stranding as
58
+ `custom`. The independent `FROZEN_PRIORS` pin in the test moves with it — 8 catalog rows, none
59
+ equal to the bundled body.
60
+ - **Obligation discharged: KIT-SUBTREE-CHANGED-WITHOUT-A-BUMP.** Slice 4 wrote 19 spec-binding
61
+ markers into this package's subtree and shipped no bump. This release carries them.
62
+
63
+
7
64
  ## 7.6.0 — `spec-check`: the spec store judged against what the session SAYS it changed (AD-115)
8
65
 
9
66
  The feature-spec layer gets its structural checker. The change source is EXPLICIT and never git — a
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '7.6.0'
6
+ version: '9.0.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "7.6.0",
6
+ "version": "9.0.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "7.6.0",
3
+ "version": "9.0.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
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');
@@ -49,6 +49,8 @@ export const KIT_OWN_PATHS = [
49
49
  '/CLAUDE.md',
50
50
  '/docs/ai/',
51
51
  '/scripts/_expect-shim.mjs',
52
+ '/scripts/archive-caps.mjs',
53
+ '/scripts/archive-caps.test.mjs',
52
54
  '/scripts/archive-changelog.mjs',
53
55
  '/scripts/archive-changelog.test.mjs',
54
56
  '/scripts/archive-conservation.test.mjs',
@@ -125,14 +125,29 @@ export const planScriptRefresh = (cwd, deps = {}) => {
125
125
  // archivers to this kit's canon without their runtime dependency would leave every refreshed
126
126
  // script crashing on a missing `./markdown-blocks.mjs` import until a separate upgrade run — so
127
127
  // the dependency rides the SAME apply, atomically, written before its importers.
128
- const COMPANION_SEEDS = ['markdown-blocks.mjs', 'markdown-blocks.test.mjs'];
128
+ // `archive-caps.mjs` joined the list the moment `archive-changelog.mjs` began importing it: a new
129
+ // import by a refreshed archiver reopens this exact hole, so the list moves with the imports.
130
+ //
131
+ // A seed is UNCONDITIONAL only when every refreshable archiver imports it. `markdown-blocks.mjs` is
132
+ // imported by all three (changelog, decisions, issues), so any refresh needs it. `archive-caps.mjs`
133
+ // is imported by `archive-changelog.mjs` ALONE — seeding it on a plain ADR migration that never
134
+ // refreshed the changelog archiver would write a file with no importer, which is the directional
135
+ // "never ADD a basename the consumer lacks" rule broken and a log line that says something untrue.
136
+ const COMPANION_SEEDS = [
137
+ { name: 'markdown-blocks.mjs', requiredBy: null },
138
+ { name: 'markdown-blocks.test.mjs', requiredBy: null },
139
+ { name: 'archive-caps.mjs', requiredBy: 'archive-changelog.mjs' },
140
+ { name: 'archive-caps.test.mjs', requiredBy: 'archive-changelog.mjs' },
141
+ ];
129
142
  export const planCompanionSeeds = (cwd, refresh, deps = {}) => {
130
143
  if (refresh.length === 0) return [];
131
144
  const exists = deps.exists ?? existsSync;
132
145
  const kitScripts = deps.kitScripts ?? KIT_SCRIPTS;
133
146
  const consumerScripts = join(cwd, CONSUMER_SCRIPTS_REL);
147
+ const refreshed = new Set(refresh.map((entry) => entry.name));
134
148
  const out = [];
135
- for (const name of COMPANION_SEEDS) {
149
+ for (const { name, requiredBy } of COMPANION_SEEDS) {
150
+ if (requiredBy !== null && !refreshed.has(requiredBy)) continue;
136
151
  const canon = join(kitScripts, name);
137
152
  const dst = join(consumerScripts, name);
138
153
  if (exists(canon) && !exists(dst)) out.push({ name, canon, dst });
@@ -31,6 +31,8 @@ export const SCRIPT_PRIORS = Object.freeze([
31
31
  prior('check-docs-size.test.mjs', '4.0.0', '4.5.4', '88fbb3d7f097d74771b7c5d9ad99fcd58b274ae33f391e1ff01f4b138b9236cd'),
32
32
  prior('spec-schema.mjs', '4.6.0', '4.6.1', 'f8ee23d81e90fd4225ca4ece288cba41982c4430290bc6d033f5ca18d2d283f4'),
33
33
  prior('spec-schema.test.mjs', '4.6.0', '4.6.1', 'a12d6d3f5d32c6dabdee7e15af7d2ab15a0ced37515d1844fe0951f60cddbc99'),
34
+ prior('spec-schema.mjs', '4.7.0', '4.7.0', '40b5b038d5ec5ed53c327c6d269d22fe5fa2bed99ae711fbf84306ad047be452'),
35
+ prior('spec-schema.test.mjs', '4.7.0', '4.7.0', 'fde896419924223e54cfcabfdb1ef5807df5386fac700ed7c1463b6e7f81501b'),
34
36
  ]);
35
37
 
36
38
  export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');