@sabaiway/agent-workflow-memory 4.2.0 → 4.4.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,68 @@ 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
+ ## 4.4.0 — the deploy finishes by writing the navigator its entry point declares (AD-096; ships with kit 5.9.0)
8
+
9
+ **The substrate deployed an `AGENTS.md` that calls `docs/ai/index.md` always-loaded, and no step
10
+ ever wrote it.** The navigator is generated — `check-docs-size.mjs` is its only writer — and neither
11
+ bootstrap nor upgrade ran the generator, so a fresh Node deployment started life with a pre-commit
12
+ hook failing its own index check, and a project without Node stayed silently broken. Fixed at the
13
+ source: the generator gains an idempotent finalizer, and every documented path now runs it.
14
+
15
+ - **`check-docs-size.mjs --ensure-index [--root=<dir>]`** — probe first, write only when the
16
+ navigator is missing or stale, and close with exactly ONE outcome line: `ensure-index:
17
+ regenerated` / `already-current` on stdout, or `write-refused` / `probe-failed` on stderr with
18
+ exit 2. It reuses the existing generator and freshness check — there is no second index
19
+ implementation, and no seed template that could drift from it.
20
+ - **The bootstrap and upgrade prose run it at the LAST `docs/ai` mutation of each path:** after the
21
+ template fill (which covers a No-Node target — the generator runs from the skill home, so the
22
+ step-8 script copy is not a precondition), stamp-independently BEFORE the equal-head short-circuit
23
+ on upgrade, and again after the migrations before the re-stamp. Either refusal is a STOP.
24
+ - **The write is contained and atomic.** Every component of `<root>/docs/ai/index.md` is lstat'ed
25
+ no-follow — a symlinked root, `docs`, `docs/ai` or leaf refuses by name — the body is published
26
+ through an exclusive-create temp renamed into place with the chain re-checked immediately before
27
+ the rename, and a failure ATTEMPTS the temp discard — never a name this run did not create, and a
28
+ cleanup that itself fails names the temp it could not remove instead of swallowing it. The
29
+ containment guard runs BEFORE the freshness read, so a symlink whose target happens to hold
30
+ current bytes is refused rather than reported "already current".
31
+ - **"Could not read" never passes for "nothing there".** Under the finalizer, only a genuine
32
+ `ENOENT` counts as an absence; any other read failure refuses instead of publishing an index
33
+ missing whatever the run could not see. A malformed `package.json` stays the documented
34
+ project-name fallback.
35
+ - **A closure guard** asserts every `docs/ai` reference in the entry-point template's Memory Map
36
+ resolves to a shipped template, a generated artifact whose finalizer the prose documents, or an
37
+ exact exception list — and the deploy fixture EXTRACTS the documented command from `SKILL.md` and
38
+ runs it, so a step that loses its finalizer line fails as a broken deployment.
39
+
40
+ Known residual: a project that already carries `scripts/check-docs-size.mjs` keeps its un-hardened
41
+ copy (the scripts ensure is create-only by contract) — repair rides the next lineage migration.
42
+
43
+ ## 4.3.0 — the migration emits a destination that refuses by name, accepts the producer marker, and preserves a vendored core check (AD-092 + AD-093 + AD-094; ships with kit 5.7.0)
44
+
45
+ The `migrate-gates.mjs` canon moves in lockstep with the kit it is byte-twinned to. Three changes,
46
+ all in the drift-guarded shared block or the branches around it:
47
+
48
+ - **The emitted coverage destination becomes `"${AW_GIT_DIR:?exported by run-gates}/…lcov.info"`.**
49
+ Where the runner injects the variable, behaviour is byte-identical; where a human pastes the cmd
50
+ into a bare shell, bash refuses loudly by name — the old form expanded to empty and wrote the
51
+ lcov to the filesystem root. Recognition stays APPEND-ONLY: every previously emitted form is
52
+ still recognised, so an old-form declaration migrates as `keep` with zero diff.
53
+ - **The migration recognises the `lcovProducer` marker.** An optional gate-level boolean; only the
54
+ literal `true` is a producer claim, and producer-ness stays POSITIONAL — a marker on the coverage
55
+ checker itself never self-pairs. Strict schema validation of the key lives in the kit
56
+ (`gates-declaration.mjs`), which accepts it in lockstep with this release; the published 5.6.0
57
+ kit rejects a marker-carrying `gates.json` at exit 5 by design (forward-only; the kit's
58
+ Issue-016 note owns the cross-version statement).
59
+ - **A vendored copy of a core check is PRESERVED instead of stopping the upgrade.** The
60
+ three-outcome claim classifier (`canonical` / `tool-elsewhere` / `not-the-tool`) routes a
61
+ shape-matching copy at a non-canonical realpath into a `keep` row plus separately rendered
62
+ verify metadata — exit 0, nothing auto-added, a zero-diff apply, with the preview stating the
63
+ verification instead of
64
+ claiming final-run-capability the runner would refuse. A genuine id-squatter still hard-stops.
65
+
66
+ This package stays standalone — it imports nothing from the kit; the shared block is held equal by
67
+ the text drift guard, never by an import.
68
+
7
69
  ## 4.2.0 — the gates migration stops handing you a coverage checker with nothing to read (AD-089)
8
70
 
9
71
  `migrate-gates.mjs` added the canonical `coverage-check` gate to any legacy declaration that lacked
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-memory
3
3
  description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '4.2.0'
6
+ version: '4.4.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
@@ -113,7 +113,15 @@ bootstrapping over a live system, but the user makes the final call.
113
113
  `docs/ai/autonomy.json` — the per-project autonomy policy, seeded SPARSE (the onboarding note
114
114
  only, defaults-equivalent — behavior changes only when the user declares levels). Keep each
115
115
  `.md` file's frontmatter.
116
- 7. **Fill templates** per the table below.
116
+ 7. **Fill templates** per the table below. **Then materialize the navigator, as the last `docs/ai`
117
+ step:** the entry point declares `docs/ai/index.md` always-loaded and it is a GENERATED artifact
118
+ with no template, so run
119
+ `node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
120
+ and relay its one outcome line (`ensure-index: regenerated` / `already-current`, or a
121
+ `write-refused` / `probe-failed` line naming the offending path — either is a STOP: resolve it
122
+ before reporting the deploy, since the navigator the entry point declares does not exist yet). The
123
+ generator runs from the **skill home**, so a **No-Node target** is covered too: the operator's own
124
+ host runs it, and step 8's copy is not a precondition.
117
125
  8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs`
118
126
  (+ `*.test.mjs`) into the project's `scripts/`. **No Node runtime** → skip this + the hook;
119
127
  follow the cap/archive/index policy manually.
@@ -214,7 +222,14 @@ Fill strategy:
214
222
  the tree untouched and report the pending migration. The deployed pre-commit
215
223
  hook gains its `archive-decisions.mjs --check` line only when the hook is next refreshed via
216
224
  `node scripts/install-git-hooks.mjs`; an old hook without the line stays consistent-safe (the
217
- decisions gate is simply not enforced yet — never a broken hook). **Then**, if the stamp **equals** the head → the substrate is
225
+ decisions gate is simply not enforced yet — never a broken hook). **Same gate, also
226
+ stamp-independent — ensure the NAVIGATOR:** `docs/ai/index.md` is a GENERATED artifact the entry
227
+ point declares always-loaded, so a deployment that never had one must gain it even at head — run
228
+ `node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
229
+ and relay its outcome line. It writes only when the navigator is missing or stale
230
+ (`already-current` otherwise), so a re-run changes nothing; a `write-refused` (the write) or
231
+ `probe-failed` (the tree could not be read) line names the offending path and STOPs this
232
+ reconcile like any other failed ensure. **Then**, if the stamp **equals** the head → the substrate is
218
233
  current (no structure migration is due), and stop after reporting. Report **in the user's
219
234
  conversational language**: if step 2's reconcile just **changed something** (a footprint move /
220
235
  config seed), say **what changed** in plain terms and ask before committing; if **nothing changed at
@@ -242,7 +257,10 @@ Fill strategy:
242
257
  absent (a legacy `AGENTS.md`), gracefully **no-op** on that slot (adding a slot to
243
258
  already-deployed files is the composition root's reconcile, not this substrate's job). On any
244
259
  malformed marker state (single, reversed, nested, or duplicate pair), **no-op with an error** —
245
- never edit.
260
+ never edit. **Then, before the stamp, re-run the navigator finalizer:** steps 4–6 may have added
261
+ or changed `docs/ai` files, so run
262
+ `node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
263
+ once more (idempotent — an untouched tree reports `already-current`) and relay its outcome line.
246
264
  7. **Re-stamp** `docs/ai/.memory-version` to the lineage head (atomic write — mechanics unchanged).
247
265
  Report changes **in plain human terms** (which parts of the deployment are now different);
248
266
  **omit the raw structure number**, and do not recite the two-axes note here (it belongs to
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-memory",
5
5
  "kind": "memory-substrate",
6
- "version": "4.2.0",
6
+ "version": "4.4.0",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "4.2.0",
3
+ "version": "4.4.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",
@@ -28,12 +28,13 @@ describe('check-docs-size runCli — refusal branches', () => {
28
28
  join(root, 'docs', 'ai', 'a.md'),
29
29
  '---\ntype: state\nlastUpdated: 2026-07-18\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# a\n',
30
30
  );
31
- // The index path is a symlink into /dev/null: the write lands, the stat reads size 0 —
32
- // the guard must refuse loudly instead of reporting a written index.
33
- symlinkSync('/dev/null', join(root, 'docs', 'ai', 'index.md'));
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);
34
35
  const { code, stderr } = await cli(['--write-index', `--root=${root}`]);
35
36
  assert.equal(code, 2);
36
- assert.match(stderr, /index\.md was written empty/);
37
+ assert.match(stderr, new RegExp(`${indexPath} is a symlink`));
37
38
  } finally {
38
39
  rmSync(root, { recursive: true, force: true });
39
40
  }
@@ -0,0 +1,332 @@
1
+ // check-docs-size-ensure.test.mjs — the navigator WRITE contract (containment + atomic publication)
2
+ // and the idempotent `--ensure-index` finalizer mode. A separate file because the main spec pair is
3
+ // suite-parity-frozen; single responsibility: what the write refuses, and what the mode reports.
4
+ import { describe, it } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, symlinkSync, rmSync, existsSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { runCli } from './check-docs-size.mjs';
10
+
11
+ const DOC = (title) =>
12
+ `---\ntype: state\nlastUpdated: 2026-08-15\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# ${title}\n`;
13
+
14
+ const makeTree = (prefix) => {
15
+ const dir = mkdtempSync(join(tmpdir(), prefix));
16
+ mkdirSync(join(dir, 'docs', 'ai'), { recursive: true });
17
+ writeFileSync(join(dir, 'docs', 'ai', 'a.md'), DOC('a'));
18
+ return dir;
19
+ };
20
+
21
+ const drop = (...dirs) => {
22
+ for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
23
+ };
24
+
25
+ const tempFiles = (docsAi) => readdirSync(docsAi).filter((name) => name.endsWith('.tmp'));
26
+
27
+ describe('check-docs-size navigator write — containment', () => {
28
+ it('refuses a symlinked index.md, exit 2, naming the path', async () => {
29
+ const root = makeTree('cds-leaf-');
30
+ const target = mkdtempSync(join(tmpdir(), 'cds-leaf-target-'));
31
+ try {
32
+ const indexPath = join(root, 'docs', 'ai', 'index.md');
33
+ symlinkSync(join(target, 'sink.md'), indexPath);
34
+ const { code, stderr } = await runCli(['--write-index', `--root=${root}`]);
35
+ assert.equal(code, 2);
36
+ assert.match(stderr, /symlink/);
37
+ assert.ok(stderr.includes(indexPath), `refusal must name ${indexPath}, got: ${stderr}`);
38
+ } finally {
39
+ drop(root, target);
40
+ }
41
+ });
42
+
43
+ it('refuses a symlinked docs/ai, exit 2, naming the path', async () => {
44
+ const root = mkdtempSync(join(tmpdir(), 'cds-docsai-'));
45
+ const real = mkdtempSync(join(tmpdir(), 'cds-docsai-real-'));
46
+ try {
47
+ mkdirSync(join(root, 'docs'), { recursive: true });
48
+ writeFileSync(join(real, 'a.md'), DOC('a'));
49
+ symlinkSync(real, join(root, 'docs', 'ai'));
50
+ const { code, stderr } = await runCli(['--write-index', `--root=${root}`]);
51
+ assert.equal(code, 2);
52
+ assert.ok(stderr.includes(join(root, 'docs', 'ai')), `refusal must name the symlinked dir, got: ${stderr}`);
53
+ } finally {
54
+ drop(root, real);
55
+ }
56
+ });
57
+
58
+ it('refuses a symlinked docs, exit 2, naming the path', async () => {
59
+ const root = mkdtempSync(join(tmpdir(), 'cds-docs-'));
60
+ const real = mkdtempSync(join(tmpdir(), 'cds-docs-real-'));
61
+ try {
62
+ mkdirSync(join(real, 'ai'), { recursive: true });
63
+ writeFileSync(join(real, 'ai', 'a.md'), DOC('a'));
64
+ symlinkSync(real, join(root, 'docs'));
65
+ const { code, stderr } = await runCli(['--write-index', `--root=${root}`]);
66
+ assert.equal(code, 2);
67
+ assert.ok(stderr.includes(join(root, 'docs')), `refusal must name the symlinked dir, got: ${stderr}`);
68
+ } finally {
69
+ drop(root, real);
70
+ }
71
+ });
72
+
73
+ it('refuses a symlinked project root, exit 2, naming the path', async () => {
74
+ const real = makeTree('cds-root-real-');
75
+ const host = mkdtempSync(join(tmpdir(), 'cds-root-host-'));
76
+ const link = join(host, 'project');
77
+ try {
78
+ symlinkSync(real, link);
79
+ const { code, stderr } = await runCli(['--write-index', `--root=${link}`]);
80
+ assert.equal(code, 2);
81
+ assert.ok(stderr.includes(link), `refusal must name the symlinked root, got: ${stderr}`);
82
+ } finally {
83
+ drop(real, host);
84
+ }
85
+ });
86
+
87
+ it('writes an absent index rather than refusing it', async () => {
88
+ const root = makeTree('cds-absent-');
89
+ try {
90
+ const { code } = await runCli(['--write-index', `--root=${root}`]);
91
+ assert.equal(code, 0);
92
+ assert.ok(existsSync(join(root, 'docs', 'ai', 'index.md')));
93
+ } finally {
94
+ drop(root);
95
+ }
96
+ });
97
+
98
+ // The temp name is exclusive-create for a reason: a collision means the name is SOMEONE ELSE's
99
+ // file, which this run never wrote and must never delete.
100
+ it('never removes a temp name it lost the exclusive-create race for', async () => {
101
+ const root = makeTree('cds-collision-');
102
+ const removed = [];
103
+ try {
104
+ const { code, stderr } = await runCli(['--write-index', `--root=${root}`], {
105
+ writeFile: () => {
106
+ throw Object.assign(new Error('EEXIST: file already exists'), { code: 'EEXIST' });
107
+ },
108
+ rm: (target) => { removed.push(target); },
109
+ });
110
+ assert.equal(code, 2);
111
+ assert.match(stderr, /EEXIST/);
112
+ assert.deepEqual(removed, [], 'a collision is not this run\'s file to discard');
113
+ } finally {
114
+ drop(root);
115
+ }
116
+ });
117
+
118
+ it('discards the temp when the write itself dies mid-flight', async () => {
119
+ const root = makeTree('cds-partial-');
120
+ const removed = [];
121
+ try {
122
+ const { code } = await runCli(['--write-index', `--root=${root}`], {
123
+ writeFile: () => {
124
+ throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' });
125
+ },
126
+ rm: (target) => { removed.push(target); },
127
+ });
128
+ assert.equal(code, 2);
129
+ assert.equal(removed.length, 1, 'the partially-created temp is the run\'s own to discard');
130
+ assert.match(removed[0], /\.tmp$/);
131
+ } finally {
132
+ drop(root);
133
+ }
134
+ });
135
+
136
+ it('names the temp it could not remove instead of swallowing the cleanup failure', async () => {
137
+ const root = makeTree('cds-cleanup-');
138
+ try {
139
+ const { code, stderr } = await runCli(['--write-index', `--root=${root}`], {
140
+ rename: () => {
141
+ throw Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' });
142
+ },
143
+ rm: () => {
144
+ throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
145
+ },
146
+ });
147
+ assert.equal(code, 2);
148
+ assert.match(stderr, /could not be removed/);
149
+ assert.match(stderr, /\.tmp/);
150
+ } finally {
151
+ drop(root);
152
+ }
153
+ });
154
+
155
+ it('leaves no temp file and no index behind when the publication fails', async () => {
156
+ const root = makeTree('cds-tmp-');
157
+ try {
158
+ const { code } = await runCli(['--write-index', `--root=${root}`], {
159
+ rename: () => {
160
+ throw Object.assign(new Error('injected publication failure'), { code: 'EIO' });
161
+ },
162
+ });
163
+ assert.equal(code, 2);
164
+ assert.deepEqual(tempFiles(join(root, 'docs', 'ai')), []);
165
+ assert.equal(existsSync(join(root, 'docs', 'ai', 'index.md')), false);
166
+ } finally {
167
+ drop(root);
168
+ }
169
+ });
170
+ });
171
+
172
+ describe('check-docs-size --ensure-index', () => {
173
+ it('reports regenerated and materializes the navigator on a fresh tree', async () => {
174
+ const root = makeTree('cds-ensure-fresh-');
175
+ try {
176
+ const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
177
+ assert.equal(code, 0);
178
+ assert.match(stdout, /ensure-index: regenerated/);
179
+ const { code: checkCode } = await runCli(['--check-index', `--root=${root}`]);
180
+ assert.equal(checkCode, 0);
181
+ } finally {
182
+ drop(root);
183
+ }
184
+ });
185
+
186
+ it('reports already-current on a second run and leaves the bytes identical', async () => {
187
+ const root = makeTree('cds-ensure-idem-');
188
+ try {
189
+ await runCli(['--ensure-index', `--root=${root}`]);
190
+ const first = readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
191
+ const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`, '--today=2027-01-01']);
192
+ assert.equal(code, 0);
193
+ assert.match(stdout, /ensure-index: already-current/);
194
+ assert.equal(readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8'), first);
195
+ } finally {
196
+ drop(root);
197
+ }
198
+ });
199
+
200
+ // A symlink whose TARGET happens to hold the current bytes reads as fresh through the link — so a
201
+ // freshness-first finalizer would report `already-current` over a file it would never write
202
+ // through: an exit 0 that proves nothing about the deployment's own navigator.
203
+ it('refuses a symlinked navigator even when its target holds the CURRENT bytes', async () => {
204
+ const root = makeTree('cds-ensure-symlink-fresh-');
205
+ try {
206
+ const indexPath = join(root, 'docs', 'ai', 'index.md');
207
+ await runCli(['--ensure-index', `--root=${root}`]);
208
+ const current = readFileSync(indexPath, 'utf8');
209
+ rmSync(indexPath);
210
+ const target = join(root, 'elsewhere-index.md');
211
+ writeFileSync(target, current);
212
+ symlinkSync(target, indexPath);
213
+
214
+ const { code, stdout, stderr } = await runCli(['--ensure-index', `--root=${root}`]);
215
+ assert.equal(code, 2, `a symlinked navigator must be refused, got: ${stdout}${stderr}`);
216
+ assert.match(stderr, /ensure-index: write-refused/);
217
+ assert.equal(readFileSync(target, 'utf8'), current, 'the link target is untouched');
218
+ } finally {
219
+ drop(root);
220
+ }
221
+ });
222
+
223
+ // The two refusals name STAGES: a reader told the PROBE failed will go looking at the tree, while
224
+ // a write that died may have left the navigator half-published.
225
+ it('reports a WRITE failure as write-refused, never as a failed probe', async () => {
226
+ const root = makeTree('cds-write-stage-');
227
+ try {
228
+ const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`], {
229
+ writeFile: () => {
230
+ throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' });
231
+ },
232
+ rm: () => {},
233
+ });
234
+ assert.equal(code, 2);
235
+ assert.match(stderr, /ensure-index: write-refused/);
236
+ assert.match(stderr, /EIO/);
237
+ } finally {
238
+ drop(root);
239
+ }
240
+ });
241
+
242
+ // "Could not read" is not "nothing there": a finalizer that swallowed the difference would publish
243
+ // a navigator missing whatever it failed to see, and report success over it.
244
+ it('refuses a tree it cannot fully read instead of writing an incomplete navigator', async () => {
245
+ const root = makeTree('cds-strict-meta-');
246
+ try {
247
+ const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`], {
248
+ readdir: () => {
249
+ throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
250
+ },
251
+ });
252
+ assert.equal(code, 2);
253
+ assert.match(stderr, /ensure-index: probe-failed/);
254
+ assert.match(stderr, /EACCES/);
255
+ assert.equal(existsSync(join(root, 'docs', 'ai', 'index.md')), false, 'nothing written over a tree it could not read');
256
+ } finally {
257
+ drop(root);
258
+ }
259
+ });
260
+
261
+ // "Unknown" must not read as "empty" either: a reader that throws WITHOUT an fs code is still not
262
+ // evidence that the tree is absent, so the finalizer refuses rather than publishing a short index.
263
+ it('refuses a code-less read failure too, instead of treating it as an absence', async () => {
264
+ const root = makeTree('cds-codeless-');
265
+ try {
266
+ const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`], {
267
+ readdir: () => {
268
+ throw new Error('the injected reader failed without an fs code');
269
+ },
270
+ });
271
+ assert.equal(code, 2);
272
+ assert.match(stderr, /ensure-index: probe-failed/);
273
+ assert.equal(existsSync(join(root, 'docs', 'ai', 'index.md')), false);
274
+ } finally {
275
+ drop(root);
276
+ }
277
+ });
278
+
279
+ it('a MALFORMED package.json stays the documented basename fallback, never a refusal', async () => {
280
+ const root = makeTree('cds-badpkg-');
281
+ try {
282
+ writeFileSync(join(root, 'package.json'), '{ not json');
283
+ const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
284
+ assert.equal(code, 0, 'authored content that is malformed is not an unreadable tree');
285
+ assert.match(stdout, /ensure-index: regenerated/);
286
+ } finally {
287
+ drop(root);
288
+ }
289
+ });
290
+
291
+ it('closes with ONE named probe-failed line when the tree cannot be read', async () => {
292
+ const root = mkdtempSync(join(tmpdir(), 'cds-ensure-nodocs-'));
293
+ try {
294
+ const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`]);
295
+ assert.equal(code, 2);
296
+ assert.match(stderr, /ensure-index: probe-failed/);
297
+ assert.equal(/at .*check-docs-size/.test(stderr), false, 'a named refusal, never a stack trace');
298
+ } finally {
299
+ drop(root);
300
+ }
301
+ });
302
+
303
+ it('reports a named write-refused, exit 2, when the write cannot be published', async () => {
304
+ const root = makeTree('cds-ensure-refused-');
305
+ const target = mkdtempSync(join(tmpdir(), 'cds-ensure-refused-target-'));
306
+ try {
307
+ const indexPath = join(root, 'docs', 'ai', 'index.md');
308
+ symlinkSync(join(target, 'sink.md'), indexPath);
309
+ const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`]);
310
+ assert.equal(code, 2);
311
+ assert.match(stderr, /ensure-index: write-refused/);
312
+ assert.ok(stderr.includes(indexPath), `the refusal must name ${indexPath}, got: ${stderr}`);
313
+ } finally {
314
+ drop(root, target);
315
+ }
316
+ });
317
+
318
+ it('reports regenerated when the on-disk navigator went stale', async () => {
319
+ const root = makeTree('cds-ensure-stale-');
320
+ try {
321
+ await runCli(['--ensure-index', `--root=${root}`]);
322
+ writeFileSync(join(root, 'docs', 'ai', 'b.md'), DOC('b'));
323
+ const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
324
+ assert.equal(code, 0);
325
+ assert.match(stdout, /ensure-index: regenerated/);
326
+ const { code: checkCode } = await runCli(['--check-index', `--root=${root}`]);
327
+ assert.equal(checkCode, 0);
328
+ } finally {
329
+ drop(root);
330
+ }
331
+ });
332
+ });