@sabaiway/agent-workflow-kit 9.0.0 → 10.1.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,75 @@ 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
+ ## 10.1.0 — no work without a specification, and a ratchet that does not trust its own file (AD-121)
8
+
9
+ A shipped tool that no LIVE contract claims is now a REFUSAL, not a preference. `tools/spec-coverage.mjs`
10
+ (the rule) and `tools/spec-coverage-cli.mjs` (argv, fs and the one write) read each contract's own
11
+ `## Module` list as the coverage map — coverage is never declared twice — and a project declares the
12
+ check as a gate. Measured at adoption in this repo: 122 tool modules, 14 governed by a contract.
13
+
14
+ The other 108 are owed, and what is owed is never STORED — it is a subtraction. `adopted` is the set
15
+ measured once at adoption and never rewritten; `settled` names the adopted paths whose contract has
16
+ since been written; the debt is the difference. So there is no list a hand can edit into a lie: the
17
+ only editable claim is "this one was paid", and every run checks that claim against the contracts
18
+ themselves — a settled path no live contract covers refuses, and refuses twice, because the tool is
19
+ uncovered again. A stored debt list was the first design and a review killed it: `--write-debt`
20
+ refusing to add a path guaranteed nothing while nobody was obliged to use it.
21
+
22
+ The scope file is validated before it is used: a wrong schema, an empty `roots` or `extensions`, a
23
+ non-string entry, or a census of ZERO files all refuse — a gate that passes because it looked at
24
+ nothing is not a pass. Exclusions are path-component boundaries, so excluding `x/fixtures` never also
25
+ hides `x/fixtures-escape.mjs`. `draft` and `retired` contracts cover nothing.
26
+
27
+ Also in this release: `tools/spec-check.mjs` counts a scenario marker as a WHOLE ordinal, so a store
28
+ reaching ten scenarios no longer refuses a correct binding while naming the wrong scenario
29
+ (`spec:x/S1` used to occur twice the moment `spec:x/S11` was written beside it).
30
+
31
+ ## 10.0.0 — a symlinked docs file gets named instead of skipped (AD-119)
32
+
33
+ Mirrors the memory canon: `references/scripts/check-docs-size.mjs` no longer lets a symlinked docs
34
+ file leave the walk in silence. The predicate `entry.isFile()` is FALSE for a symlink and so is
35
+ `isDirectory()`, so a symlinked `*.md` under `docs/ai` fell through both arms of the walk and was not
36
+ even counted in the report's file total.
37
+
38
+ > ### ⚠ BREAKING — a docs tree that was green can now go RED
39
+ >
40
+ > A symlink under `docs/ai` named `*.md`, or resolving to a directory, is a NAMED error row and is
41
+ > never read.
42
+ >
43
+ > `--check-index` additionally runs the navigator's containment guard BEFORE it reads. On a chain
44
+ > the walk can TRAVERSE, that guard rejects a symlink at the project root, `docs`, `docs/ai` or
45
+ > `docs/ai/index.md` and exits 2 — where the mode used to compare and pass; it is the same guard
46
+ > `--write-index` and `--ensure-index` already applied. (A chain the walk cannot traverse at all
47
+ > still ends the run as it always did, before any guard — unchanged by this release.) It matters
48
+ > because `buildIndex` drops the navigator's own row, so a symlinked `index.md` whose target held
49
+ > the current bytes compared EQUAL and reported it fresh.
50
+ >
51
+ > **When it bites.** Only a deployment that actually has such a symlink, and only once these bytes
52
+ > reach its `scripts/`. `init` does NOT overwrite an existing deployed `check-docs-size.mjs`; the
53
+ > paths that do land it are a fresh bootstrap, a `migrate-adr-store` refresh, and the `specs` ensure's
54
+ > prior-matched refresh lane — which is what the catalog row below is for.
55
+ >
56
+ > **Remedy.** Replace it with a real file or a real directory, whichever it stands for, or move it out
57
+ > of `docs/ai`. The gate names the path and which case it is. For a symlink on the NAVIGATOR's own
58
+ > chain, note that regeneration is itself refused — `--write-index` cannot clear it, only replacing
59
+ > the link can.
60
+
61
+ **Deployments on the previous body stay upgradeable.** The prior catalog gains
62
+ `check-docs-size.mjs` `4.6.0..6.0.0`, so a project still carrying that body classifies `prior`
63
+ and remains inside the refresh lane instead of stranding as `custom` — which is what would otherwise
64
+ withhold every write that lane gates.
65
+
66
+ **Preserved, each pinned by its own arm.** A symlinked NON-`.md` regular file is still skipped; a
67
+ tree with no symlink under `docs/ai` writes a byte-identical navigator; an over-cap REAL `adr/`
68
+ record still collapses into the aggregate row, because the guard keys on the refusal and never on
69
+ `errors.length`; and `walkMarkdownFiles` keeps its exact historical one-argument signature, which is
70
+ why the parity-frozen spec file needed no edit.
71
+
72
+ **Only `ENOENT` and `ENOTDIR` mean absence.** Every other `stat` code — `EACCES`, `EIO`, `ELOOP` from
73
+ a symlink cycle — leaves the kind UNKNOWN and yields a named refusal carrying the code. Treating an
74
+ unclassifiable link as a skip is exactly how the subtree behind it escapes again.
75
+
7
76
  ## 9.0.0 — the archiver's cap table ships, and the migration seeds it behind its importer (AD-118)
8
77
 
9
78
  Memory **6.0.0** replaces the changelog archiver's fixed cap literals with a stamp it can honour,
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: '9.0.0'
6
+ version: '10.1.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": "9.0.0",
6
+ "version": "10.1.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": "9.0.0",
3
+ "version": "10.1.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",
@@ -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) {