@sabaiway/agent-workflow-memory 5.0.0 → 7.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,104 @@ 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
+ ## 7.0.0 — a symlinked docs file gets named instead of skipped (AD-119)
8
+
9
+ The docs cap-validator discovered files through `readdir(dir, { withFileTypes: true })` and kept an
10
+ entry only when `entry.isFile()`. For a symlink that predicate is FALSE — and so is `isDirectory()`
11
+ — so a symlinked `*.md` under `docs/ai` fell through BOTH arms of the walk and left it entirely.
12
+ Probed: a temp tree holding a 14-line `real.md` under `maxLines: 3`, a `linked.md` pointing at it and
13
+ an `escaped.md` pointing outside `docs/ai` reported `1 files inspected — 1 error(s)`. No cap check,
14
+ no missing-frontmatter error, no staleness check, no navigator row — and not counted in the file
15
+ total either, which is what made it a lie rather than a gap.
16
+
17
+ > ### ⚠ BREAKING — a docs tree that was green can now go RED
18
+ >
19
+ > A symlink under `docs/ai` named `*.md`, or resolving to a directory, is now a NAMED error row and
20
+ > is never read.
21
+ >
22
+ > `--check-index` additionally runs the navigator's containment guard BEFORE it reads. On a chain
23
+ > the walk can TRAVERSE, that guard rejects a symlink at the project root, `docs`, `docs/ai` or
24
+ > `docs/ai/index.md` and exits 2 — where the mode used to compare and pass. It is the same guard
25
+ > `--write-index` and `--ensure-index` already applied. (A chain the walk cannot traverse at all —
26
+ > a dangling or unreadable `docs/ai` — still ends the run as it always did, before any guard; only
27
+ > `--ensure-index` promises one named line there. That is unchanged by this release.)
28
+ > Reading through a link matters because `buildIndex` drops the navigator's own row, so a symlinked
29
+ > `index.md` whose target held the current bytes compared EQUAL and reported it fresh (measured:
30
+ > exit 0 before, exit 2 after).
31
+ >
32
+ > **When it bites.** Only a deployment that actually has such a symlink — and only once these bytes
33
+ > reach its `scripts/`, which happens on a fresh bootstrap, a `migrate-adr-store` refresh, a
34
+ > prior-matched `specs` ensure, or a hand copy; installing this package does not overwrite an
35
+ > existing deployed `check-docs-size.mjs`. Such a project sees `docs-caps` turn red with no edit of
36
+ > its own. For a symlinked docs FILE, `docs-index` reds until the navigator is regenerated with that
37
+ > link's row. For a symlink on the navigator's own chain the regeneration is itself REFUSED, so
38
+ > `--write-index` cannot clear it — only replacing the link can.
39
+ >
40
+ > **Remedy.** Replace it with a real file or a real directory, whichever it stands for, or move it out
41
+ > of `docs/ai`. The gate names the path and which case it is.
42
+
43
+ **What is deliberately preserved.** A symlinked NON-`.md` regular file is still skipped, so the
44
+ pinned `orchestration.json` skip is unchanged; a tree with no symlink under `docs/ai` writes a
45
+ byte-identical navigator; and an over-cap REAL `adr/` record still collapses into the aggregate row,
46
+ because the collapse guard keys on the refusal and never on `errors.length`. Each is pinned by its
47
+ own arm.
48
+
49
+ **Only `ENOENT` and `ENOTDIR` mean "nothing is there".** The name decides first — a link named `*.md`
50
+ is refused with no `stat` at all — and only a differently named one is stat'ed, to learn whether it
51
+ stands where a directory would and would hide a whole subtree. Every other code (`EACCES`, `EIO`,
52
+ `ELOOP` from a symlink cycle) yields a named refusal carrying the code, because treating an
53
+ unclassifiable link as a skip is how the subtree behind it escapes all over again.
54
+
55
+ `walkMarkdownFiles` keeps its exact historical one-argument signature and contract: the `*.md` files
56
+ a run may READ. A refused symlink is not one of them — it is a row, not a file.
57
+
58
+ ## 6.0.0 — a rolling archive stamps a cap it can honour, and refuses past a ceiling (AD-118)
59
+
60
+ The changelog archiver wrote each tier's frontmatter `maxLines` as a LITERAL in the builder that
61
+ emitted it: WARM 3500, COLD 1500, META 300. A literal is a promise about a corpus nobody has seen
62
+ yet, so the day a tier outgrew its number the archiver emitted a file the docs gate refuses — its
63
+ own output failing its own gate, and the repair was a hand edit that the next run stamped straight
64
+ back over.
65
+
66
+ > ### ⚠ BREAKING — the archiver now exits non-zero on input it used to accept
67
+ >
68
+ > New module `references/scripts/archive-caps.mjs` exports `capFor({tier, count})` over a frozen
69
+ > floor/ceiling table — COLD 1500/3000, WARM 3500/7000, META 1500/3000. Once a tier's line count
70
+ > goes PAST its ceiling the run REFUSES rather than stamp a cap it cannot honour, and it refuses
71
+ > identically in every mode: the default run, `--dry-run` and `--check` alike. META's floor also
72
+ > RISES 300 → 1500.
73
+ >
74
+ > **When it bites.** These bytes reach a project's `scripts/` only on a FRESH bootstrap, a
75
+ > `migrate-adr-store` refresh, or a hand copy — installing this package does NOT overwrite an
76
+ > existing deployed `archive-changelog.mjs`, and delivery to existing deployments is deliberately
77
+ > out of scope here (a filed row). Once the bytes do land, a project whose archive has already
78
+ > outgrown a ceiling sees its `changelog-rotation` gate turn red with no edit of its own, and an
79
+ > existing `condensed-index.md` is re-stamped on the next run.
80
+
81
+ - **The stamp is now a function of the file.** Below the floor a tier stamps the floor; inside the
82
+ band it stamps its OWN final line count; the ceiling itself is still stamped. `count` is the count
83
+ the docs gate computes (`check-docs-size.mjs` — a trailing newline ends a line, it never opens
84
+ one), restated as `countLines` so the stamp and the judge cannot disagree. Each builder renders
85
+ twice: once with a placeholder to learn its length, once with what `capFor` returns. The stamped
86
+ integer is one line whatever its value, so the first render's count IS the final count and the
87
+ stamp is a fixed point.
88
+ - **Every cap-bearing output is BUILT before the mode branch.** WARM/COLD/META used to be built only
89
+ on the write path, so `--check` and `--dry-run` could never have seen a ceiling breach. A `--check`
90
+ that went green on a corpus the next real run cannot write is the same fail-open the parse already
91
+ refuses to be. Building is pure, so a refusal still leaves the tree byte-unchanged.
92
+ - **The ceiling is 2x the floor, FIXED here, never measured from a corpus.** The refusal rides a
93
+ standing `--check` gate, so a ceiling derived from whatever happened to be on disk the day someone
94
+ wrote the test would brick every commit as soon as the corpus grew past it. Raising one is a
95
+ reviewed edit to the table, never something a run decides for itself.
96
+ - **A sharding tripwire warns at the FLOOR.** META grows O(total archived entries) and never sheds,
97
+ so it reaches its ceiling first; warning while there is still room puts the remedy in front of
98
+ whoever runs the archiver. Both the COLD and META remedies state their sharding as NOT
99
+ IMPLEMENTED and say why — COLD discovery matches `YYYY-MM.md` only, so a hand-split file would
100
+ drop out of the corpus, and META is always regenerated whole. A remedy must never instruct a
101
+ layout the archiver cannot read back.
102
+ - Deliberately out of scope, each a filed row: the HOT changelog's own fallback stamp, the sibling
103
+ `archive-issues` / `archive-decisions` stampers, and per-year META sharding.
104
+
7
105
  ## 5.0.0 — the scenario floor: a contract can no longer pin NOTHING (AD-117)
8
106
 
9
107
  The reader enforced a minimum on `## Out of scope` and none on `## Scenarios`. A `kind: spec`
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: '5.0.0'
6
+ version: '7.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": "5.0.0",
6
+ "version": "7.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": "5.0.0",
3
+ "version": "7.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', () => {
@@ -1,17 +1,62 @@
1
1
  // check-docs-size-cli.test.mjs — runCli branch pins the subprocess smokes cannot reach
2
2
  // in-process (Phase-5 coverage fill; the main spec file is parity-frozen, so these ride a
3
- // colocated file): the unknown-argument refusal and the pre-write symlink refusal on the index path.
3
+ // colocated file): the unknown-argument refusal, the pre-write symlink refusal on the index path,
4
+ // and the symlink refusals the WALK itself emits.
4
5
  import { describe, it } from 'node:test';
5
6
  import assert from 'node:assert/strict';
6
- import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
7
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
7
8
  import { tmpdir } from 'node:os';
8
9
  import { join } from 'node:path';
9
10
  import { runCli } from './check-docs-size.mjs';
10
11
 
11
- const cli = async (argv) => {
12
- const { code, stdout, stderr } = await runCli(argv);
13
- return { code, stdout, stderr };
12
+ const cli = (argv) => runCli(argv);
13
+ const DOC = (name, maxLines = 10) =>
14
+ `---\ntype: state\nlastUpdated: 2026-07-18\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: ${maxLines}\n---\n\n# ${name}\n`;
15
+
16
+ // One temp root per arm, always torn down: docs/ai holds one real `a.md`, and `build` adds
17
+ // whatever the arm needs before the CLI runs.
18
+ const withRoot = async (build, run) => {
19
+ const root = mkdtempSync(join(tmpdir(), 'cds-cli-'));
20
+ const docs = join(root, 'docs', 'ai');
21
+ try {
22
+ mkdirSync(docs, { recursive: true });
23
+ // A fixed package.json name, so the navigator's own bytes are deterministic across temp roots.
24
+ writeFileSync(join(root, 'package.json'), '{ "name": "probe-project" }\n');
25
+ writeFileSync(join(docs, 'a.md'), DOC('a'));
26
+ build({ root, docs });
27
+ await run({ root, docs });
28
+ } finally {
29
+ rmSync(root, { recursive: true, force: true });
30
+ }
14
31
  };
32
+ const indexOf = (root) => readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
33
+
34
+ // The exact navigator a symlink-free probe tree writes. A characterization literal: the symlink
35
+ // refusal must leave every byte of it — header, preamble, table, footer — untouched.
36
+ const EXPECTED_INDEX = `---
37
+ type: reference
38
+ lastUpdated: 2026-07-18
39
+ scope: permanent
40
+ staleAfter: 30d
41
+ owner: none
42
+ maxLines: 80
43
+ ---
44
+
45
+ # Memory Map — probe-project \`docs/ai/\`
46
+
47
+ > **Auto-generated** — edit the source files' frontmatter, not this file. Regenerate after changes.
48
+ > Layered context architecture:
49
+ > **Always-loaded** — root \`AGENTS.md\` + this index.
50
+ > **On-demand** — read a specific \`docs/ai/\` file when its "Read When" applies.
51
+ > **Hierarchical** — subdirectory \`AGENTS.md\` files load when working in that folder.
52
+ > **Archive** — \`history/recent.md\` (WARM) + \`history/condensed-index.md\` + per-month files.
53
+
54
+ ## Files
55
+
56
+ | File | Type | Lines/Max | Updated | Stale after |
57
+ |------|------|-----------|---------|-------------|
58
+ | [\`a.md\`](./a.md) | state | 10/10 | 2026-07-18 | never |
59
+ `;
15
60
 
16
61
  describe('check-docs-size runCli — refusal branches', () => {
17
62
  it('an unknown argument exits 2 naming it', async () => {
@@ -20,23 +65,216 @@ describe('check-docs-size runCli — refusal branches', () => {
20
65
  assert.match(stderr, /Unknown argument: --bogus/);
21
66
  });
22
67
 
23
- it('--write-index refuses a symlinked index path BEFORE writing, naming the path', async () => {
24
- const root = mkdtempSync(join(tmpdir(), 'cds-cli-'));
25
- try {
26
- mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
27
- writeFileSync(
28
- join(root, 'docs', 'ai', 'a.md'),
29
- '---\ntype: state\nlastUpdated: 2026-07-18\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# a\n',
68
+ it('every mode refuses a symlinked navigator whose target holds the CURRENT bytes', async () => {
69
+ await withRoot(
70
+ () => {},
71
+ async ({ root, docs }) => {
72
+ // Write the real navigator, move its bytes to a target OUTSIDE docs/ai, and point index.md
73
+ // at them. Freshness now MATCHES, so an implementation that reads through the link and only
74
+ // complains when the comparison differs would pass: the refusal has to fire by NAME, before
75
+ // the read. A `/dev/null` target would have proved nothing but a mismatch.
76
+ const indexPath = join(docs, 'index.md');
77
+ const target = join(root, 'elsewhere-index.md');
78
+ await cli(['--write-index', `--root=${root}`, '--today=2026-07-18']);
79
+ writeFileSync(target, readFileSync(indexPath, 'utf8'));
80
+ rmSync(indexPath);
81
+ symlinkSync(target, indexPath);
82
+ const linked = new RegExp(`${indexPath} is a symlink`);
83
+ const c = await cli(['--check-index', `--root=${root}`, '--today=2026-07-18']);
84
+ assert.equal(c.code, 2, 'a symlinked navigator is REFUSED, never compared');
85
+ assert.match(c.stderr, linked);
86
+ const w = await cli(['--write-index', `--root=${root}`]);
87
+ assert.equal(w.code, 2);
88
+ assert.match(w.stderr, linked);
89
+ const d = await cli([`--root=${root}`]);
90
+ assert.equal(d.code, 1);
91
+ assert.match(d.stdout, /docs\/ai\/index\.md[\s\S]*?ERROR\s+is a symlink/);
92
+ },
93
+ );
94
+ });
95
+ });
96
+
97
+ describe('check-docs-size — a symlink under docs/ai is NAMED, never read', () => {
98
+ it('a link to a READABLE in-tree doc errors, its target is inspected once, and --report still exits 0', async () => {
99
+ await withRoot(
100
+ ({ docs }) => symlinkSync(join(docs, 'a.md'), join(docs, 'link.md')),
101
+ async ({ root }) => {
102
+ const { code, stdout } = await cli([`--root=${root}`]);
103
+ assert.equal(code, 1);
104
+ assert.match(stdout, /docs\/ai\/link\.md[\s\S]*?ERROR\s+is a symlink/);
105
+ assert.equal(stdout.match(/docs\/ai\/a\.md/g).length, 1, 'the target is inspected exactly once');
106
+ assert.match(stdout, /2 files inspected {2}— {2}1 error/, 'the refused link is counted');
107
+ const r = await cli(['--report', `--root=${root}`]);
108
+ assert.equal(r.code, 0);
109
+ assert.match(r.stdout, /ERROR\s+is a symlink/);
110
+ },
111
+ );
112
+ });
113
+
114
+ it('a symlinked DIRECTORY errors instead of hiding its subtree', async () => {
115
+ await withRoot(
116
+ ({ root, docs }) => {
117
+ mkdirSync(join(root, 'elsewhere'));
118
+ writeFileSync(join(root, 'elsewhere', 'hidden.md'), 'no frontmatter at all\n');
119
+ symlinkSync(join(root, 'elsewhere'), join(docs, 'history'));
120
+ },
121
+ async ({ root }) => {
122
+ const { code, stdout } = await cli([`--root=${root}`]);
123
+ assert.equal(code, 1);
124
+ assert.match(stdout, /docs\/ai\/history[\s\S]*?ERROR\s+is a symlink to a directory/);
125
+ assert.doesNotMatch(stdout, /hidden\.md/, 'the subtree behind the link is never walked');
126
+ },
127
+ );
128
+ });
129
+
130
+ // The name carries no regex metacharacter on purpose: a red-proof testId is matched as a pattern,
131
+ // and a literal star makes the arm unselectable (measured — the mint refused it as unresolvable).
132
+ it('a DANGLING doc link errors by NAME, never as an ENOENT stack trace', async () => {
133
+ await withRoot(
134
+ ({ docs }) => symlinkSync(join(docs, 'gone.md'), join(docs, 'dangling.md')),
135
+ async ({ root }) => {
136
+ const { code, stdout } = await cli([`--root=${root}`]);
137
+ assert.equal(code, 1);
138
+ assert.match(stdout, /docs\/ai\/dangling\.md[\s\S]*?ERROR\s+is a symlink/);
139
+ assert.doesNotMatch(stdout, /ENOENT/);
140
+ },
141
+ );
142
+ });
143
+
144
+ it('a link under adr/ gets its OWN index row and joins neither the collapse count nor its range', async () => {
145
+ await withRoot(
146
+ ({ docs }) => {
147
+ mkdirSync(join(docs, 'adr'));
148
+ writeFileSync(join(docs, 'adr', 'AD-001-real.md'), DOC('AD-001', 400));
149
+ writeFileSync(join(docs, 'adr', 'log.md'), DOC('log', 200));
150
+ symlinkSync(join(docs, 'adr', 'AD-001-real.md'), join(docs, 'adr', 'AD-999-link.md'));
151
+ },
152
+ async ({ root }) => {
153
+ assert.equal((await cli(['--write-index', `--root=${root}`])).code, 1);
154
+ assert.match(indexOf(root), /\| 1 records \|/, 'the real record is the only one counted');
155
+ assert.doesNotMatch(indexOf(root), /AD-999 \|/, 'the link never enters the id range');
156
+ assert.match(indexOf(root), /adr\/AD-999-link\.md/, 'the link renders its own row');
157
+ },
158
+ );
159
+ });
160
+
161
+ it('--check-index reds until the regenerated navigator carries the LINK row', async () => {
162
+ await withRoot(
163
+ () => {},
164
+ async ({ root, docs }) => {
165
+ // The BASELINE navigator is written first, with no link present, so the red below can only
166
+ // come from the added row — not from a missing index.md, which would let an implementation
167
+ // that merely checks the navigator EXISTS pass this arm.
168
+ await cli(['--write-index', `--root=${root}`, '--today=2026-07-18']);
169
+ assert.equal((await cli(['--check-index', `--root=${root}`])).code, 0, 'the baseline is fresh');
170
+ symlinkSync(join(docs, 'a.md'), join(docs, 'link.md'));
171
+ assert.equal((await cli(['--check-index', `--root=${root}`])).code, 1, 'the link makes it stale');
172
+ await cli(['--write-index', `--root=${root}`]);
173
+ assert.equal((await cli(['--check-index', `--root=${root}`])).code, 0);
174
+ assert.match(indexOf(root), /link\.md/);
175
+ },
176
+ );
177
+ });
178
+
179
+ // The PARTITION itself, both sides of it — not one example. ENOENT and ENOTDIR are the only codes
180
+ // that mean "nothing is there"; every other one leaves the kind UNKNOWN, and unknown must never
181
+ // read as skip, because that is how a link standing where a directory would, with a whole .md
182
+ // subtree behind it, escapes the gate again. An implementation that skipped EACCES, or that
183
+ // refused on ENOTDIR, passes a single-code arm and fails this one.
184
+ const STAT_CASES = [
185
+ { code: 'ELOOP', named: true },
186
+ { code: 'EACCES', named: true },
187
+ { code: 'EIO', named: true },
188
+ { code: 'ENOENT', named: false },
189
+ { code: 'ENOTDIR', named: false },
190
+ ];
191
+
192
+ it('a link whose kind cannot be determined is NAMED, never skipped', async () => {
193
+ for (const { code, named } of STAT_CASES) {
194
+ await withRoot(
195
+ ({ root, docs }) => {
196
+ mkdirSync(join(root, 'elsewhere'));
197
+ writeFileSync(join(root, 'elsewhere', 'hidden.md'), 'no frontmatter at all\n');
198
+ symlinkSync(join(root, 'elsewhere'), join(docs, 'history'));
199
+ },
200
+ async ({ root }) => {
201
+ const stat = async () => {
202
+ throw Object.assign(new Error(`${code}: injected`), { code });
203
+ };
204
+ const { code: exit, stdout } = await runCli([`--root=${root}`], { stat });
205
+ if (named) {
206
+ assert.equal(exit, 1, code);
207
+ assert.match(stdout, new RegExp(`docs/ai/history[\\s\\S]*?ERROR\\s+is a symlink this run could not classify \\(${code}\\)`), code);
208
+ } else {
209
+ assert.equal(exit, 0, code);
210
+ assert.doesNotMatch(stdout, /docs\/ai\/history/, code);
211
+ }
212
+ assert.doesNotMatch(stdout, /hidden\.md/, code);
213
+ },
30
214
  );
31
- // The index path is a symlink into /dev/null: the contained write refuses to publish THROUGH
32
- // the link (it would clobber the link target), names the path, and writes nothing.
33
- const indexPath = join(root, 'docs', 'ai', 'index.md');
34
- symlinkSync('/dev/null', indexPath);
35
- const { code, stderr } = await cli(['--write-index', `--root=${root}`]);
36
- assert.equal(code, 2);
37
- assert.match(stderr, new RegExp(`${indexPath} is a symlink`));
38
- } finally {
39
- rmSync(root, { recursive: true, force: true });
40
215
  }
41
216
  });
42
217
  });
218
+
219
+ // Characterization, GREEN before the refusal landed as well as after — these two pin what the
220
+ // change must NOT move, so neither carries a red-proof record.
221
+ describe('check-docs-size — what the symlink refusal preserves', () => {
222
+ it('a symlinked NON-.md regular file is still skipped', async () => {
223
+ await withRoot(
224
+ ({ root, docs }) => {
225
+ writeFileSync(join(root, 'orchestration.json'), '{ "plan-authoring": { "review": "reviewed" } }\n');
226
+ symlinkSync(join(root, 'orchestration.json'), join(docs, 'orchestration.json'));
227
+ },
228
+ async ({ root }) => {
229
+ const { code, stdout } = await cli([`--root=${root}`]);
230
+ assert.equal(code, 0);
231
+ assert.doesNotMatch(stdout, /orchestration\.json/);
232
+ assert.match(stdout, /1 files inspected {2}— {2}0 error/);
233
+ },
234
+ );
235
+ });
236
+
237
+ it('a DANGLING link that is not named .md is skipped — an unclassifiable link is not in scope', async () => {
238
+ await withRoot(
239
+ ({ root, docs }) => symlinkSync(join(root, 'never-existed.json'), join(docs, 'orchestration.json')),
240
+ async ({ root }) => {
241
+ // stat throws here, so the kind is unknowable — and the NAME never put it in scope, so it
242
+ // stays as skipped as a real non-.md file. Only a name-based refusal survives a failed stat.
243
+ const { code, stdout } = await cli([`--root=${root}`]);
244
+ assert.equal(code, 0);
245
+ assert.doesNotMatch(stdout, /orchestration\.json/);
246
+ assert.match(stdout, /1 files inspected {2}— {2}0 error/);
247
+ },
248
+ );
249
+ });
250
+
251
+ it('an OVER-CAP real adr/ record still collapses — the guard keys on the refusal, not on errors', async () => {
252
+ await withRoot(
253
+ ({ docs }) => {
254
+ mkdirSync(join(docs, 'adr'));
255
+ // 12 body lines under a maxLines of 5: a genuine cap ERROR on a genuine record.
256
+ writeFileSync(join(docs, 'adr', 'AD-001-fat.md'), DOC('AD-001', 5) + 'x\n'.repeat(12));
257
+ writeFileSync(join(docs, 'adr', 'log.md'), DOC('log', 200));
258
+ },
259
+ async ({ root }) => {
260
+ assert.equal((await cli(['--write-index', `--root=${root}`])).code, 1, 'the cap error still reds the run');
261
+ assert.match(indexOf(root), /\| 1 records \|/);
262
+ assert.doesNotMatch(indexOf(root), /AD-001-fat\.md/, 'an errored REAL record stays inside the collapse');
263
+ },
264
+ );
265
+ });
266
+
267
+ it('a tree with NO symlink under docs/ai writes a BYTE-IDENTICAL navigator', async () => {
268
+ await withRoot(
269
+ () => {},
270
+ async ({ root }) => {
271
+ const { code, stdout } = await cli([`--root=${root}`, '--today=2026-07-18', '--write-index']);
272
+ assert.equal(code, 0);
273
+ assert.match(stdout, /1 files inspected {2}— {2}0 error\(s\), 0 warning\(s\)/);
274
+ // The WHOLE file, not a filtered row: header, preamble, table and footer alike are what a
275
+ // symlink-free tree must keep producing.
276
+ assert.equal(indexOf(root), EXPECTED_INDEX);
277
+ },
278
+ );
279
+ });
280
+ });
@@ -178,23 +178,69 @@ export const parseStaleAfter = (value) => {
178
178
  return Number(m[1]);
179
179
  };
180
180
 
181
- // Discover the docs to validate: ONLY `*.md` files (recursively). Non-`.md` files e.g. a hand-edited
182
- // `docs/ai/orchestration.json` config are inherently skipped, so they are never subject to the
183
- // frontmatter / maxLines caps. Exported so that skip is pinned by a regression test.
184
- export const walkMarkdownFiles = async (dir) => {
185
- const entries = await readdir(dir, { withFileTypes: true });
186
- const files = [];
181
+ // A symlink is neither `isFile()` nor `isDirectory()`, so it used to fall through BOTH arms of the
182
+ // walk and leave it entirely — silently, not even counted in the report's file total. It is REFUSED
183
+ // instead, and the NAME decides first: a link named `*.md` is refused with no stat at all, and only
184
+ // a differently named one is stat'ed, to learn whether it stands where a directory would and would
185
+ // therefore hide a whole subtree. A stat that throws leaves a link this run cannot classify, which
186
+ // is a refusal only when the name already put it in scope — so a dangling `notes.txt` link stays as
187
+ // out of scope as a real one. Refusing rather than FOLLOWING is deliberate: following would need
188
+ // realpath, a containment test and a cycle guard, while the write side of this very module already
189
+ // refuses to publish through a link (assertContainedNoSymlink).
190
+ // The ONLY stat failures that mean "there is nothing there": the target is absent, or a path
191
+ // component is not a directory. Every other code — EACCES, EIO, and ELOOP from a symlink cycle —
192
+ // means the kind is UNKNOWN, and "unknown" must never read as "skip": that is how a link standing
193
+ // where a directory would, with a whole `.md` subtree behind it, would silently escape again.
194
+ const ABSENT_STAT_CODES = new Set(['ENOENT', 'ENOTDIR']);
195
+
196
+ const symlinkRefusal = async (full, name, statPath) => {
197
+ if (name.endsWith('.md')) {
198
+ return 'is a symlink — the caps gate reads only real files it owns; replace it or move it out of docs/ai';
199
+ }
200
+ try {
201
+ if (!(await statPath(full)).isDirectory()) return null;
202
+ } catch (err) {
203
+ if (ABSENT_STAT_CODES.has(err?.code)) return null;
204
+ return `is a symlink this run could not classify (${err?.code ?? 'unknown'}) — it may stand where a directory would and hide a whole subtree; resolve it or move it out of docs/ai`;
205
+ }
206
+ return 'is a symlink to a directory — its whole subtree would escape the caps gate; replace it or move it out of docs/ai';
207
+ };
208
+
209
+ // Discover what the docs walk found: ONLY `*.md` files (recursively) plus the symlinks it refuses.
210
+ // Non-`.md` files — e.g. a hand-edited `docs/ai/orchestration.json` config — are inherently skipped,
211
+ // so they are never subject to the frontmatter / maxLines caps. Each entry is `{ path, refusal }`:
212
+ // a refusal rides out as a row nobody reads, never as a file nobody sees.
213
+ const walkDocsEntries = async (dir, deps = {}) => {
214
+ const readDir = deps.readdir ?? readdir;
215
+ const statPath = deps.stat ?? stat;
216
+ const entries = await readDir(dir, { withFileTypes: true });
217
+ const found = [];
187
218
  for (const entry of entries) {
188
219
  const full = join(dir, entry.name);
189
- if (entry.isDirectory()) {
190
- files.push(...(await walkMarkdownFiles(full)));
220
+ if (entry.isSymbolicLink()) {
221
+ const refusal = await symlinkRefusal(full, entry.name, statPath);
222
+ if (refusal !== null) found.push({ path: full, refusal });
223
+ } else if (entry.isDirectory()) {
224
+ found.push(...(await walkDocsEntries(full, deps)));
191
225
  } else if (entry.isFile() && entry.name.endsWith('.md')) {
192
- files.push(full);
226
+ found.push({ path: full, refusal: null });
193
227
  }
194
228
  }
195
- return files;
229
+ return found;
196
230
  };
197
231
 
232
+ // The historical export, signature and contract BOTH unchanged: the `*.md` files this run may READ,
233
+ // as paths, from one argument. A refused symlink is not one of them — it is a row, not a file — so
234
+ // the pinned non-`.md` skip still reads exactly as it always did. The entry walk above and the
235
+ // comparator below stay private: they are this module's internals, and the injectable they carry has
236
+ // no business in a deployed script's public surface.
237
+ export const walkMarkdownFiles = async (dir) =>
238
+ (await walkDocsEntries(dir)).filter((entry) => entry.refusal === null).map((entry) => entry.path);
239
+
240
+ // The walk's ORDER is unchanged: a bare `.sort()` over the old string list compared UTF-16 code
241
+ // units, and `<`/`>` on strings compares them the same way.
242
+ const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
243
+
198
244
  export const computeToday = (todayStr) =>
199
245
  todayStr
200
246
  ? new Date(`${todayStr}T00:00:00Z`)
@@ -206,11 +252,19 @@ export const computeToday = (todayStr) =>
206
252
  const readSpecVerdict = (rel, text) =>
207
253
  rel.startsWith(SPECS_COLLAPSE.prefix) ? readSpecDocument(text, rel.slice(SPECS_COLLAPSE.prefix.length)) : null;
208
254
 
209
- export const inspectFile = async (filePath, today, root = ROOT) => {
255
+ // Takes a walk entry; a bare path string still works, so a caller holding one file keeps its call.
256
+ export const inspectFile = async (found, today, root = ROOT) => {
257
+ const filePath = typeof found === 'string' ? found : found.path;
258
+ const refusal = typeof found === 'string' ? null : found.refusal;
259
+ const rel = relative(root, filePath);
260
+ // A refused entry is REPORTED, never read — reading through the link is the whole thing being
261
+ // refused. `refused` rides the row so the navigator's collapse can keep it visible.
262
+ if (refusal) {
263
+ return { path: rel, lineCount: 0, frontmatter: null, spec: null, refused: true, errors: [refusal], warnings: [] };
264
+ }
210
265
  const text = await readFile(filePath, 'utf8');
211
266
  const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
212
267
  const fm = parseFrontmatter(text);
213
- const rel = relative(root, filePath);
214
268
  const spec = readSpecVerdict(rel, text);
215
269
 
216
270
  if (!fm) {
@@ -330,8 +384,11 @@ const formatSpecsCollapseRow = (specRows) => {
330
384
  return `| ${link} | ${SPECS_COLLAPSE.type} | ${count('spec')} specs | ${count('part')} parts · ${count('index')} indexes | — |`;
331
385
  };
332
386
 
387
+ // Membership keys on the REFUSAL, never on `errors.length`: a refused symlink is exactly the row
388
+ // this gate exists to make visible, so it must not be absorbed into a count — while an over-cap or
389
+ // frontmatter-less REAL record keeps collapsing as it always did, so no deployment's navigator moves.
333
390
  const COLLAPSE_GROUPS = [
334
- { sortPath: ADR_DIR_PREFIX, isMember: (r) => r.path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(r.path) || r.path === ADR_NAV_PATH), format: formatAdrCollapseRow },
391
+ { sortPath: ADR_DIR_PREFIX, isMember: (r) => !r.refused && r.path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(r.path) || r.path === ADR_NAV_PATH), format: formatAdrCollapseRow },
335
392
  { sortPath: SPECS_COLLAPSE.prefix, isMember: (r) => Boolean(r.spec) && r.spec.errors.length === 0, format: formatSpecsCollapseRow },
336
393
  ];
337
394
 
@@ -398,15 +455,18 @@ const lstatNoFollow = (target, lstat) => {
398
455
 
399
456
  // The target is always DERIVED from `root` here (the navigator and its temp sibling), never handed
400
457
  // in by a caller, so there is no escape arm to guard: what remains is the no-follow walk.
401
- const assertContainedNoSymlink = (root, target, lstat) => {
458
+ // `verb` names what the CALLER was about to do, because the refusal is now shared with a read-only
459
+ // mode: `--check-index` compares the navigator's bytes, and a comparison that followed a link would
460
+ // judge a file this deployment does not own.
461
+ const assertContainedNoSymlink = (root, target, lstat, verb = 'write') => {
402
462
  const rel = relative(root, target);
403
463
  if (lstatNoFollow(root, lstat)?.isSymbolicLink()) {
404
- throw refuse(`${root} is a symlink — refusing to write the navigator through it`);
464
+ throw refuse(`${root} is a symlink — refusing to ${verb} the navigator through it`);
405
465
  }
406
466
  rel.split(sep).filter(Boolean).reduce((walked, part) => {
407
467
  const current = join(walked, part);
408
468
  if (lstatNoFollow(current, lstat)?.isSymbolicLink()) {
409
- throw refuse(`${current} is a symlink — refusing to write the navigator through it`);
469
+ throw refuse(`${current} is a symlink — refusing to ${verb} the navigator through it`);
410
470
  }
411
471
  return current;
412
472
  }, root);
@@ -452,7 +512,7 @@ const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PA
452
512
  export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
453
513
  const paths = pathsFor(root);
454
514
  const today = computeToday(todayStr);
455
- const files = (await walkMarkdownFiles(paths.docsDir)).sort();
515
+ const files = (await walkDocsEntries(paths.docsDir, deps)).sort(byPath);
456
516
  const inspected = await Promise.all(files.map((f) => inspectFile(f, today, paths.root)));
457
517
  const rows = inspected.map(formatRow);
458
518
  const meta = await discoverMeta(paths.root);
@@ -474,7 +534,7 @@ const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
474
534
  let writing = false;
475
535
  try {
476
536
  assertContainedNoSymlink(root, indexPath, lstat);
477
- const files = (await walkMarkdownFiles(docsDir)).sort();
537
+ const files = (await walkDocsEntries(docsDir, deps)).sort(byPath);
478
538
  const inspected = await Promise.all(files.map((file) => inspectFile(file, today, root)));
479
539
  const rows = inspected.map(formatRow);
480
540
  const meta = await discoverMeta(root, { strict: true, deps });
@@ -523,7 +583,7 @@ export const runCli = async (argv, deps = {}) => {
523
583
  return result(code);
524
584
  }
525
585
 
526
- const files = (await walkMarkdownFiles(docsDir)).sort();
586
+ const files = (await walkDocsEntries(docsDir, deps)).sort(byPath);
527
587
  const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
528
588
  const rows = inspected.map(formatRow);
529
589
 
@@ -545,6 +605,16 @@ export const runCli = async (argv, deps = {}) => {
545
605
  }
546
606
 
547
607
  if (flags.checkIndex) {
608
+ // BEFORE the read, and by NAME: `buildIndex` drops the navigator's own row, so a symlinked
609
+ // index.md contributes nothing to compare against — and a link whose target happened to hold
610
+ // the current bytes would compare EQUAL and report the navigator fresh. Freshness can never
611
+ // see this one; only the containment walk can.
612
+ try {
613
+ assertContainedNoSymlink(root, indexPath, deps.lstat ?? lstatSync, 'read');
614
+ } catch (err) {
615
+ logError(`[check-docs-size] FAIL: ${indexPath}: ${err.message}`);
616
+ return result(2);
617
+ }
548
618
  const onDisk = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : null;
549
619
  const { fresh } = checkIndexFreshness(rows, onDisk, meta);
550
620
  if (!fresh) {