@sabaiway/agent-workflow-memory 6.0.0 → 7.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,71 @@ 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.1.0 — the rules template names the adoption state a zero relies on, and the upgrade skips only on no Node evidence (AD-123)
8
+
9
+ `references/templates/agent_rules.md`: §1.2 no longer says "zero is legal during adoption" — a zero
10
+ names the adoption state it relies on (not adopted, adopting, or nothing spec-covered touched); §2.5
11
+ Communication gains its last bullet — a tool-composed `skipped-*` line whose stated reason the observed
12
+ tree disproves is raised as a FINDING, never pasted as neutral, and a tool may not emit a skip it could
13
+ itself disprove; §2.6 is the re-rendered engine 4.2.0 lens (the Spec-first bullet with the state
14
+ clause). The kit's template twin moves byte-identically.
15
+
16
+ `SKILL.md` upgrade step 2: the ADR enforcement pair and the spec layer skip ONLY when no Node evidence
17
+ exists — no regular `package.json` at the root and none of the kit-seeded `scripts/*.mjs` present; a
18
+ probe that cannot be read is a stated failure, never a skip. A deployment the kit itself made is
19
+ therefore always recognized, whatever its stack.
20
+
21
+ ## 7.0.0 — a symlinked docs file gets named instead of skipped (AD-119)
22
+
23
+ The docs cap-validator discovered files through `readdir(dir, { withFileTypes: true })` and kept an
24
+ entry only when `entry.isFile()`. For a symlink that predicate is FALSE — and so is `isDirectory()`
25
+ — so a symlinked `*.md` under `docs/ai` fell through BOTH arms of the walk and left it entirely.
26
+ Probed: a temp tree holding a 14-line `real.md` under `maxLines: 3`, a `linked.md` pointing at it and
27
+ an `escaped.md` pointing outside `docs/ai` reported `1 files inspected — 1 error(s)`. No cap check,
28
+ no missing-frontmatter error, no staleness check, no navigator row — and not counted in the file
29
+ total either, which is what made it a lie rather than a gap.
30
+
31
+ > ### ⚠ BREAKING — a docs tree that was green can now go RED
32
+ >
33
+ > A symlink under `docs/ai` named `*.md`, or resolving to a directory, is now a NAMED error row and
34
+ > is never read.
35
+ >
36
+ > `--check-index` additionally runs the navigator's containment guard BEFORE it reads. On a chain
37
+ > the walk can TRAVERSE, that guard rejects a symlink at the project root, `docs`, `docs/ai` or
38
+ > `docs/ai/index.md` and exits 2 — where the mode used to compare and pass. It is the same guard
39
+ > `--write-index` and `--ensure-index` already applied. (A chain the walk cannot traverse at all —
40
+ > a dangling or unreadable `docs/ai` — still ends the run as it always did, before any guard; only
41
+ > `--ensure-index` promises one named line there. That is unchanged by this release.)
42
+ > Reading through a link matters because `buildIndex` drops the navigator's own row, so a symlinked
43
+ > `index.md` whose target held the current bytes compared EQUAL and reported it fresh (measured:
44
+ > exit 0 before, exit 2 after).
45
+ >
46
+ > **When it bites.** Only a deployment that actually has such a symlink — and only once these bytes
47
+ > reach its `scripts/`, which happens on a fresh bootstrap, a `migrate-adr-store` refresh, a
48
+ > prior-matched `specs` ensure, or a hand copy; installing this package does not overwrite an
49
+ > existing deployed `check-docs-size.mjs`. Such a project sees `docs-caps` turn red with no edit of
50
+ > its own. For a symlinked docs FILE, `docs-index` reds until the navigator is regenerated with that
51
+ > link's row. For a symlink on the navigator's own chain the regeneration is itself REFUSED, so
52
+ > `--write-index` cannot clear it — only replacing the link can.
53
+ >
54
+ > **Remedy.** Replace it with a real file or a real directory, whichever it stands for, or move it out
55
+ > of `docs/ai`. The gate names the path and which case it is.
56
+
57
+ **What is deliberately preserved.** A symlinked NON-`.md` regular file is still skipped, so the
58
+ pinned `orchestration.json` skip is unchanged; a tree with no symlink under `docs/ai` writes a
59
+ byte-identical navigator; and an over-cap REAL `adr/` record still collapses into the aggregate row,
60
+ because the collapse guard keys on the refusal and never on `errors.length`. Each is pinned by its
61
+ own arm.
62
+
63
+ **Only `ENOENT` and `ENOTDIR` mean "nothing is there".** The name decides first — a link named `*.md`
64
+ is refused with no `stat` at all — and only a differently named one is stat'ed, to learn whether it
65
+ stands where a directory would and would hide a whole subtree. Every other code (`EACCES`, `EIO`,
66
+ `ELOOP` from a symlink cycle) yields a named refusal carrying the code, because treating an
67
+ unclassifiable link as a skip is how the subtree behind it escapes all over again.
68
+
69
+ `walkMarkdownFiles` keeps its exact historical one-argument signature and contract: the `*.md` files
70
+ a run may READ. A refused symlink is not one of them — it is a row, not a file.
71
+
7
72
  ## 6.0.0 — a rolling archive stamps a cap it can honour, and refuses past a ceiling (AD-118)
8
73
 
9
74
  The changelog archiver wrote each tier's frontmatter `maxLines` as a LITERAL in the builder that
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: '6.0.0'
6
+ version: '7.1.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
@@ -211,7 +211,9 @@ Fill strategy:
211
211
  **Same gate, also stamp-independent: ensure the ADR-store enforcement pair** — copy
212
212
  `archive-decisions.mjs` + `archive-decisions.test.mjs` from
213
213
  `${CLAUDE_SKILL_DIR}/references/scripts/` into the project's `scripts/` **if missing**
214
- (preserve an existing file byte-for-byte; skip on a No-Node project). **Legacy-monolith
214
+ (preserve an existing file byte-for-byte; skip ONLY when no Node evidence exists — no
215
+ regular `package.json` at the root and none of the kit-seeded `scripts/*.mjs` present; a
216
+ probe that cannot be read is a stated failure, never a skip). **Legacy-monolith
215
217
  gate:** if a retired `docs/ai/history/decisions-archive*.md` monolith is still on disk, do
216
218
  **NOT** copy the pair as a silent ensure — the new-scheme rotator must **never sit beside
217
219
  un-migrated monoliths** (its default/`--check` runs refuse such a tree). Instead ASK the
@@ -225,8 +227,9 @@ Fill strategy:
225
227
  hook gains its `archive-decisions.mjs --check` line only when the hook is next refreshed via
226
228
  `node scripts/install-git-hooks.mjs`; an old hook without the line stays consistent-safe (the
227
229
  decisions gate is simply not enforced yet — never a broken hook). **Same gate, also
228
- stamp-independent — ensure the SPEC LAYER (Node projects; skip on a No-Node project like the
229
- pair above):** copy `spec-schema.mjs` + `spec-schema.test.mjs` from
230
+ stamp-independent — ensure the SPEC LAYER (skip only on the same absence of Node evidence as
231
+ the pair above — a deployed kit script is evidence enough):** copy `spec-schema.mjs` +
232
+ `spec-schema.test.mjs` from
230
233
  `${CLAUDE_SKILL_DIR}/references/scripts/` into the project's `scripts/` **if missing** (preserve
231
234
  an existing file byte-for-byte). Then seed `docs/ai/specs/index.md` **if missing** from
232
235
  `${CLAUDE_SKILL_DIR}/references/templates/specs/` (date filled; an existing root is preserved
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": "6.0.0",
6
+ "version": "7.1.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": "6.0.0",
3
+ "version": "7.1.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",
@@ -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) {
@@ -23,7 +23,7 @@ Read in order, then confirm before starting:
23
23
  4. Confirm with the user: *"I'm taking task X. Confirm?"*
24
24
 
25
25
  ### 1.2. During Work
26
- **Before any feature:** name its governing spec(s) — the feature spec under `docs/ai/specs/` for every touched spec-covered slice (zero is legal during adoption; each cited spec's Out of scope bounds that slice's work). Where only a page spec (`docs/ai/pages/<page>.md`) exists it governs as an ADOPTION SHIM: state Out of scope + Revision inline in the plan. If the contract changes, the spec revision is authored WITH the plan (visible at review) and lands with the code, so docs and code never diverge.
26
+ **Before any feature:** name its governing spec(s) — the feature spec under `docs/ai/specs/` for every touched spec-covered slice (a zero names the adoption state it relies on — not adopted, adopting, or nothing spec-covered touched; each cited spec's Out of scope bounds that slice's work). Where only a page spec (`docs/ai/pages/<page>.md`) exists it governs as an ADOPTION SHIM: state Out of scope + Revision inline in the plan. If the contract changes, the spec revision is authored WITH the plan (visible at review) and lands with the code, so docs and code never diverge.
27
27
 
28
28
  **For every code change:**
29
29
  1. Grep for similar implementations — reuse existing patterns.
@@ -74,13 +74,14 @@ Apply this as part of §2 before any user-facing summary:
74
74
  - **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.
75
75
  - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.
76
76
  - **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.
77
+ - **A skip that contradicts the tree is a finding.** A tool-composed `skipped-*` line whose stated reason the observed tree disproves (a "no Node" skip beside deployed Node scripts) is raised as a FINDING in the report, never pasted as a neutral outcome — and a tool may not emit a skip whose reason it could itself disprove.
77
78
 
78
79
  ### 2.6. Planning, review & process-fidelity invariants
79
80
  Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon. This section is rendered from that canon and refreshed on upgrade; a custom edit is preserved verbatim, but flagged.)
80
81
  - **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
81
82
  - **Finding scope (plan-execution) — name the invariant BEFORE the edit.** During EXECUTION only — a plan under authoring has no shipped behaviour to call a live defect in, so plan-review carries none of this. Every finding names the invariant its fix would enforce, and where that invariant already lives decides the disposition: already an acceptance criterion of the phase → **fold here**; it would have to be ADDED → ship the **narrow fix** for the found site (red first, then green) and queue ONLY the generalization — a deferral row carries the invariant, the origin `file:line`, the narrow fix, its proof and a residual exposure declared NOT live; no correct narrow fix → **blocking**: the phase does not close, and it is **never queued**. Two bars declared before each round: a finding counts only if it changes a **WRITE/REMOVE decision** or is a false statement in shipped text; a repeat finding in one subarea **routes to SUBTRACTION**, not a fourth patch.
82
83
  - **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
83
- - **Spec-first.** A plan names its GOVERNING spec(s) — zero, one or many, one per touched spec-covered slice (the feature spec under `docs/ai/specs/`; page-only coverage governs as an ADOPTION SHIM, with Out of scope + Revision stated inline in the plan). Each cited spec's Out of scope bounds that slice's work and the plan's non-goals restate it per slice — no global union; a cross-spec conflict is resolved by a spec revision BEFORE approval, never by silent precedence. A NEW feature's draft spec exists AT plan review (a `create` row); a change to a governed contract rides the plan as its proposed revision (a `modify` row); approval confirms plan and contract atomically, and the revision lands with the code. Scenario bindings are per scenario: a new scenario is `unbound` until its test lands in the same plan, and a status never regresses for an extension.
84
+ - **Spec-first.** A plan names its GOVERNING spec(s) — zero, one or many, one per touched spec-covered slice (the feature spec under `docs/ai/specs/`; page-only coverage governs as an ADOPTION SHIM, with Out of scope + Revision stated inline in the plan). A ZERO names the adoption state it relies on — `not adopted` (no store) or `adopting` (a store with no live contract), either with a recorded decline, or `nothing spec-covered touched` (a store with live contracts) — a bare zero is never a licence; the store's own state is what `status` and the upgrade advisor report. Each cited spec's Out of scope bounds that slice's work and the plan's non-goals restate it per slice — no global union; a cross-spec conflict is resolved by a spec revision BEFORE approval, never by silent precedence. A NEW feature's draft spec exists AT plan review (a `create` row); a change to a governed contract rides the plan as its proposed revision (a `modify` row); approval confirms plan and contract atomically, and the revision lands with the code. Scenario bindings are per scenario: a new scenario is `unbound` until its test lands in the same plan, and a status never regresses for an extension.
84
85
  - **No code-mechanics in the plan.** A ledger row carries its path and anchor, and Verification carries the exact commands (the plan-shape canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL — never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
85
86
  - **Test-as-spec.** Fold a code-touching finding into a red→green TEST, not a prose paragraph — the gate is the only deterministic checker; a paragraph cannot self-check.
86
87
  - **Characterize-first.** Before editing UNCOVERED code, pin its current behavior in a green test, then edit — any unintended change goes red. Never edit what has no checker; first give it one. Keep edits atomic/reversible; prefer SUBTRACTIVE folds.