@sabaiway/agent-workflow-kit 5.8.0 → 5.10.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/SKILL.md +1 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +32 -11
  4. package/bridges/antigravity-cli-bridge/bin/agy-envelope.mjs +160 -0
  5. package/bridges/antigravity-cli-bridge/bin/agy-envelope.test.mjs +235 -0
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +23 -1
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +242 -38
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +482 -38
  9. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  10. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +45 -12
  11. package/bridges/antigravity-cli-bridge/references/review-prompt.md +6 -3
  12. package/bridges/antigravity-cli-bridge/setup/README.md +18 -5
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +1 -1
  14. package/capability.json +1 -1
  15. package/package.json +1 -1
  16. package/references/hooks/state-block-guard.mjs +107 -45
  17. package/references/modes/bootstrap.md +6 -2
  18. package/references/modes/set-recipe.md +8 -5
  19. package/references/modes/state-block-guard.md +39 -31
  20. package/references/modes/upgrade.md +8 -5
  21. package/references/scripts/check-docs-size-cli.test.mjs +7 -6
  22. package/references/scripts/check-docs-size-ensure.test.mjs +332 -0
  23. package/references/scripts/check-docs-size.mjs +181 -30
  24. package/references/shared/composition-handoff.md +10 -0
  25. package/references/shared/report-footer.md +2 -2
  26. package/references/templates/agent_rules.md +1 -0
  27. package/tools/detect-backends.mjs +1 -0
  28. package/tools/doc-parity.mjs +5 -1
  29. package/tools/ensure-configs.mjs +37 -19
  30. package/tools/ensure-ops.mjs +79 -1
  31. package/tools/ensure-vocabulary.mjs +17 -3
  32. package/tools/known-footprint.mjs +10 -0
  33. package/tools/lens-region.mjs +13 -1
  34. package/tools/source-size-scope.mjs +3 -1
  35. package/tools/upgrade-runlist.mjs +1 -0
@@ -19,12 +19,12 @@ this as preventing the defect is describing it wrongly.
19
19
  - **The «what I need from you» slot answering "nothing".** A turn that ends is by definition idle, so
20
20
  a resume from the reader IS required; a slot saying otherwise states something untrue. Judged over
21
21
  the slot's **first clause only** — a slot that names a real ask and then, after a `;` or a full
22
- stop, adds «ничего другого не нужно» is honest and passes. A COMMA does not open a new clause, so
23
- the comma-joined version of that sentence is flagged; see the residuals. Hedged forms count: «не
24
- требуется», «ваше присутствие не требуется прямо сейчас», «nothing right now», «n/a».
25
- - **Announce-and-stop.** The «what next» slot promising first-person imminent work («беру…»,
26
- «начинаю…», «I'll start…») while the turn ends. A promise **gated on something named** («после
27
- вашего да беру…», «once CI finishes…») states a dependency instead of a false start, and passes.
22
+ stop, adds «nothing else is needed» is honest and passes. A COMMA does not open a new clause, so
23
+ the comma-joined version of that sentence is flagged; see the residuals. Hedged forms count: «not
24
+ required», «your presence is not required right now», «nothing right now», «n/a».
25
+ - **Announce-and-stop.** The «what next» slot promising first-person imminent work («I take the
26
+ class…», «I'll start…») while the turn ends. A promise **gated on something named** («after your
27
+ yesI publish», «once CI finishes…») states a dependency instead of a false start, and passes.
28
28
 
29
29
  **A message with no recognisable block at all is SILENT by default.** This kit does not mandate the
30
30
  three-part closing block — it is a per-project dialogue contract — so warning whenever a block is
@@ -75,10 +75,13 @@ gets no detection at all**, silently — not a warning on every turn, which is w
75
75
  condition would produce. An EMPTY delivered message is different: it is text, so a turn that ended
76
76
  with no prose is judged as having no block, which `--require-block` will report.
77
77
 
78
- **Language.** The slot labels and both banned sets carry Russian and English twins, because the
79
- contract this enforces was written for a Russian-dialogue deployment. A deployment in another
80
- dialogue language gets no detection until its labels are added that is a real limit, not a
81
- configuration you can set today.
78
+ **Language one vocabulary, English, and no list of others.** The slot labels and every phrase set
79
+ are English. The kit enumerates no second language: a shipped language list is a guess about somebody
80
+ else's dialogue, and it never stops growing. A deployment whose dialogue language is not English
81
+ extends the arrays **in its own placed copy** — the runtime is copied into the project and
82
+ self-contained, so that edit is local and survives an uninstalled kit. Until it is made, such a
83
+ deployment gets **no detection**, silently. That is a real limit, stated rather than hidden, and it is
84
+ not a configuration you can set today.
82
85
 
83
86
  **No writer yet — and why, plainly.** Every other placed thing in this kit arrives through a
84
87
  consent-gated writer. This one does not, deliberately:
@@ -162,28 +165,33 @@ the warning text. It is not a sandbox and not a permission control.
162
165
 
163
166
  - **Detection, not prevention** — restated because it is the one thing that must not blur.
164
167
  - **The judgement is LEXICAL, and that is a layer with limits, not a temporary weakness.** It matches
165
- slot labels and phrase sets on Unicode-aware word boundaries, so an honest «мне нужно ваше
166
- подтверждение» is not read as «не нужно» and an interrogative «не нужно ли…» is not read as a
167
- refusal. It cannot parse a sentence, and it cannot recognise a wording it has never been told
168
- about. Two rules that tried to close an edge here were **deleted rather than tightened a third
169
- time**, because each next version needed a second classifier the residuals below are what
170
- replaced them. A named specimen of the cost: «подтвердите, что ничего не упущено» is a real ask
171
- and is FLAGGED, because the banned word sits inside it. Phrase the ask without the word.
172
- - **A comma-joined qualifier is flagged.** «одно да, ничего другого не нужно» warns; the same
173
- sentence with a `;` or a full stop passes. The rule that tried to exempt the comma form kept
174
- letting a real "nothing" through behind a harmless prefix, so it was removed and the false flag is
175
- accepted instead. It costs one line and names its own fix.
168
+ slot labels and phrase sets on **Unicode-aware** word boundaries the markers are English but the
169
+ message is the project's dialogue language, which need not be ASCII, and JavaScript's `\b` would
170
+ read every non-ASCII letter as a word break and match a marker sitting inside a longer word. On the
171
+ English side the same class is what stops «none» matching inside «nonexistent». It cannot parse a
172
+ sentence, and it cannot recognise a wording it has never been told about. Two rules that tried to
173
+ close an edge here were **deleted rather than tightened a third time**, because each next version
174
+ needed a second classifier the residuals below are what replaced them. A named specimen of the
175
+ cost: «confirm that nothing was missed» is a real ask and is FLAGGED, because the banned word sits
176
+ inside it. Phrase the ask without the word.
177
+ - **A comma-joined qualifier is flagged.** «one yes, nothing else is needed» warns; the same sentence
178
+ with a `;` or a full stop passes. The rule that tried to exempt the comma form kept letting a real
179
+ "nothing" through behind a harmless prefix, so it was removed and the false flag is accepted
180
+ instead. It costs one line and names its own fix.
176
181
  - **A condition is bound to a promise by TOKEN ORDER inside one segment, which is an approximation.**
177
- «после вашего даберу класс» passes; «беру класс, а если тест упадёт, сообщу» is flagged. Two
178
- known misreadings follow from the approximation, both accepted: an honest TRAILING gate («беру,
179
- когда вы скажете») is flagged, and a gate belonging to an earlier comma-clause («если тест упадёт,
180
- сообщу, а сейчас начинаю…») wrongly excuses the promise after it. Leading with the gate avoids the
181
- first; the second is a miss this layer cannot close without parsing.
182
- - **The English side is weaker than the Russian side, structurally.** Russian promises are action
183
- verbs («беру», «начинаю»); English ones are pronoun+modal («I'll», «I will»), which cannot tell
184
- starting from waiting. Waiting is explicitly excluded «I'll wait for your approval» passes,
185
- because waiting is what a turn that ends actually does but the exclusion is a list, and an
186
- unusual way of saying "I am waiting" will be flagged.
182
+ «after your yesI take the class» passes; «I take the class, and if the test fails I'll report»
183
+ is flagged. Two known misreadings follow from the approximation, both accepted: an honest TRAILING
184
+ gate («I take it when you say so») is flagged, and a gate belonging to an earlier comma-clause («if
185
+ the test fails I'll report, and now I start the next class») wrongly excuses the promise after it.
186
+ Leading with the gate avoids the first; the second is a miss this layer cannot close without
187
+ parsing.
188
+ - **The promise markers are a MIXED set, and each half misses differently.** Some are pronoun+modal
189
+ («I'll», «I will», «I'm going to»), which name no verb of their own and so cannot tell starting
190
+ from waiting; waiting is therefore excluded by an explicit list«I'll wait for your approval»
191
+ passes, because waiting is what a turn that ends actually does — and an unusual way of saying "I am
192
+ waiting" is flagged. The others are pronoun+verb («I take», «I start», «I begin», «I move on»),
193
+ which name the action but only in the exact wording listed: a synonym nobody wrote down is missed
194
+ entirely, and a stative use of a listed verb («I take that as settled») is a false flag.
187
195
  - **A host that does not deliver `last_assistant_message` gets no detection**, silently. There is no
188
196
  transcript fallback, on purpose — see above.
189
197
  - **Without `--require-block`, a turn that drops the block entirely is not detected.** That is the
@@ -10,7 +10,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
10
10
 
11
11
  1. `pointers` — `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs reconcile <project>/AGENTS.md` → per pointer: added · already present · skipped (reported) · a hard STOP.
12
12
  2. `footprint` — `node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile --dry-run` → visibility: visible · ambiguous · hidden — consent (conditional): ambiguous → ask which it is BEFORE anything; hidden → the conditional re-run without `--dry-run` (its surfaced paths ask per bootstrap step 9).
13
- 3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `skipped-no-node` / `old-adr-layout-migration-instructed` / `failed`.
13
+ 3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `regenerated` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `skipped-no-node` / `old-adr-layout-migration-instructed` / `failed`.
14
14
  4. `gates-migration` — `node ${CLAUDE_SKILL_DIR}/references/scripts/migrate-gates.mjs --kit-tools ${CLAUDE_SKILL_DIR}/tools --cwd <project>` → the preview plan · INERT checker · CUSTOMIZED entries, each named — consent: apply only on an explicit yes, re-run with `--apply`.
15
15
  5. `bridges` — `node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs --refresh-placed` → per-bridge lines: refreshed · already current · skipped — with its stated reason (not placed / newer than the bundle / unsupported host) · `skipped-readonly` · could not refresh.
16
16
  6. `lens` — `node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md` → per section: refreshed · already current · custom edit preserved · file absent / engine too old — skipped · over the line cap — refused · section absent — noted · a fully absent/invalid engine → hard STOP.
@@ -30,7 +30,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
30
30
 
31
31
  **`footprint` — hidden-mode footprint reconcile (D9 / AD-014).** A deployment does not record whether it chose `hidden`, so first **infer visibility** — the dry-run writes **zero bytes** and reports one of — **visible** (the entry point is tracked) → nothing to do; **ambiguous** (untracked but not ignored — could be a fresh uncommitted repo, or a hide that broke) → **ASK** the user which it is, never guess; **hidden** → re-run without `--dry-run` to migrate any older **machine-global** hide to the **project-local** `.git/info/exclude` (one managed block; folds in the legacy `.claude/skills/` line), idempotently (a clean re-run is zero-diff). Handle its surfaced paths exactly as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`) (already-committed → show `git rm --cached`, ask before `--include`; generic-name present file → ask; **leftover machine-wide ignore block → ASK before `--remove-global`**, default keep + report). No Node on the agent host / Windows → as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`). This runs on **every** hidden upgrade, like the methodology slot — no lineage-head bump, no migration file.
32
32
 
33
- **`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all four** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts — in a fixed order, and prints **one outcome line per ensure**: paste those lines into the step 4 / step 8 success report. Every seed is **create-only** (an existing file is preserved byte-for-byte), the ONE refresh is the orchestration onboarding note while it still matches a canonical this kit shipped, and one ensure failing **never** skips the others: each reports its own outcome and the run exits non-zero when any of them `failed`. The outcome tokens, by ensure: orchestration → `seeded` / `note-refreshed` / `already-current` / `customized-preserved` / `malformed-preserved`; gates and autonomy → `seeded` / `already-present`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node`; and any ensure may report `failed`, whose line OPENS with the cause (relay it with that cause — never soften it into a skip; an op that copies file by file also states when it stopped partway). **A non-zero exit STOPs this upgrade** — report the failed line and stop there, before the equal-head exit, the migrations and the re-stamp. Add `--dry-run` to preview without writing a byte. Like the pointer slots + the footprint reconcile, all four reach an equal-head deployment **without a lineage-head bump or a migration file** (they are `.json` / `scripts/`, inherently outside the docs cap-validator).
33
+ **`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all five** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts · navigator index — in a fixed order, and prints **one outcome line per ensure**: paste those lines into the step 4 / step 8 success report. Every SEED is **create-only** (an existing file is preserved byte-for-byte); the two refresh-class ops are named apart — the orchestration onboarding note (refreshed only while it still matches a canonical this kit shipped) and the **navigator index**, a GENERATED artifact regenerated whenever it is missing or stale (never authored content, so there is nothing to preserve). One ensure failing **never** skips the others: each reports its own outcome and the run exits non-zero when any of them `failed`. The outcome tokens, by ensure: orchestration → `seeded` / `note-refreshed` / `already-current` / `customized-preserved` / `malformed-preserved`; gates and autonomy → `seeded` / `already-present`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node`; index → `regenerated` / `already-current`; and any ensure may report `failed`, whose line OPENS with the cause (relay it with that cause — never soften it into a skip; an op that copies file by file also states when it stopped partway). The cause vocabulary is CLOSED — one of `race-unresolved`, `template-unreadable`, `bundle-unreadable`, `adr-layout-unverifiable`, `wrong-node-kind`, `write-refused`, `unexpected-error`, `generator-unlaunchable`, `generator-failed`, `index-probe-failed`, `index-stale-after-write` — and every cause that can only arise AFTER the generator ran (`generator-failed`, and `index-probe-failed` / `index-stale-after-write` when they follow a reported regeneration) DISCLOSES in its own line that a write may already have landed. **A non-zero exit STOPs this upgrade** — report the failed line and stop there, before the equal-head exit, the migrations and the re-stamp. Add `--dry-run` to preview without writing a byte. Like the pointer slots + the footprint reconcile, all five reach an equal-head deployment **without a lineage-head bump or a migration file** (they are `.json` / `scripts/` / a generated artifact, inherently outside the docs cap-validator).
34
34
 
35
35
  **What the orchestration-config ensure does.** `docs/ai/orchestration.json` must exist **and its onboarding note must be current**: created from the canonical seed if missing; if it already exists, **every activity/slot the user set is preserved** and ONLY the `_README` note is refreshed, and only when the existing one still matches a known prior canonical — the tested `refreshIfCanonical` / `refreshReadme` in `tools/orchestration-config.mjs` is the source of truth for that decision (it normalizes CRLF/whitespace before comparing; a *customized* `_README` is preserved verbatim → `customized-preserved`; a *malformed* existing config is **preserved untouched + LOUD** → `malformed-preserved`, never clobbered and never silently skipped). The current note points at `/agent-workflow-kit set-recipe`. **Kit-owned:** in the **delegated** path memory only seeds/preserves the file (memory upgrade step 2) and this ensure applies the `_README` refresh; in the **fallback** path it does both. (Memory stays standalone.)
36
36
 
@@ -40,6 +40,8 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
40
40
 
41
41
  **What the enforcement-script ensure does.** A deployment older than the ADR-cascade feature has no `scripts/archive-decisions.mjs`, and an equal-head exit would otherwise never deliver it. The pairs must exist in the project's `scripts/`: `archive-decisions.mjs` + `archive-decisions.test.mjs` and `markdown-blocks.mjs` + `markdown-blocks.test.mjs`, copied from `${CLAUDE_SKILL_DIR}/references/scripts/` if missing; **an existing file is preserved, never overwritten** (drift repair belongs to a lineage migration). Nothing else is seeded — the other tokenizer-era tests red beside OLD archivers. **OLD ADR-store layout — DETECTED FIRST, never auto-migrated (AD-051, Decision 13):** on a `docs/ai/history/decisions-archive*.md` monolith (`old`), or no monolith but a deployed rotator predating the store (`old-unrotated`), the project is on the RETIRED 3-tier cascade, so the ensure writes NOTHING and reports `old-adr-layout-migration-instructed` (the new `archive-decisions.mjs` beside un-migrated monoliths would red their ADR gate). Relay it as the LOUD instruct it is: the fix is the opt-in **`/agent-workflow-kit migrate-adr-store`** (consent-gated; previews first, never commits), and the seed lands on the next upgrade. A layout the ensure cannot READ is `failed`, not a seed — it never writes on an unverifiable tree. The seed applies ONLY to a clean layout (neither signal), and a project with no `package.json` at its root reports `skipped-no-node` (these are Node enforcement scripts) while the three config ensures still run. The deployed pre-commit hook gains the `archive-decisions.mjs --check` line only when the hook itself is next refreshed (re-run `node scripts/install-git-hooks.mjs` after the ensure and it will refuse a non-marker hook as always); an OLD hook without the line stays consistent-safe — the decisions gate is simply not enforced yet, never a broken hook.
42
42
 
43
+ **What the navigator ensure does.** `docs/ai/index.md` is the always-loaded navigator the entry point declares, and it is GENERATED — no template ships it, so a deployment that never ran the generator boots from a broken entry point (and, on a Node project, carries a pre-commit hook that fails its own index check). The ensure runs the bundled generator's finalizer and reports `regenerated` (it was missing or stale — it was written) or `already-current` (nothing written). It never skips a No-Node project: the generator runs from `${CLAUDE_SKILL_DIR}/references/scripts/` on the agent host, not from the project's `scripts/`. **Its position in the run-list is EARLY and therefore NOT authoritative** — `lens` (and, on the migrated path, steps 6–7) still change `docs/ai` afterwards — so the authoritative run is the LATE `--only index` rung documented at both exits; the early one is idempotent and costs at most an `already-current` line.
44
+
43
45
  **`gates-migration` — legacy gates.json migration (consented preview — D8).** An EXISTING declaration may still carry the retired review-ledger / fold-completeness checks. Run the preview (dry-run — writes NOTHING), show the user the exact plan, and only on an explicit yes re-run it with `--apply`: canonical legacy entries (matched by their documented single-invocation cmd forms) are REMOVED, the canonical `unit-tests` cmd gains the built-in lcov reporters, and the coverage-check gate is ADDED last — atomic and COMPLETE, so the migrated declaration satisfies `run-gates --final`. **The checker rides a PRODUCER or is not declared at all** (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`): with no gate producing the lcov it reads, the migration does NOT add it, an already-declared one is reported INERT, the result is not called final-run-capable, and the preview prints the paste-ready suite cmd to declare by hand — nothing is ever removed for you. CUSTOMIZED entries are NEVER auto-touched: the preview names each with a paste-ready recovery, and the commit guard must NOT be installed until they are resolved. This is the ONLY gates.json writer at upgrade (the consented FILL preview runs at init).
44
46
 
45
47
  **`bridges` — placed-bridge refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.** Run
@@ -82,8 +84,9 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
82
84
  verbatim**: it validates the deployed host settings file's keys against the bundled manifests and
83
85
  **NEVER writes** it (the file lives outside every kit tree — D2), so an unknown/retired key is
84
86
  flagged + preserved, never edited. Runs on **every** upgrade; exit 0 covers every outcome.
87
+ **The LATE navigator finalizer — the AUTHORITATIVE run, after the last `docs/ai` mutation.** `lens` above rewrites `docs/ai/agent_rules.md`, so the step-3 index ensure is already behind by the time the run-list ends. Re-run that ONE op here, before the step-4 report: `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>`. Relay **this** line in the report (it supersedes the early one; an untouched tree reports `already-current`, a failure STOPs the upgrade like any other ensure). On the migrated path the same rung runs again at the END of step 7 — after the migrations, before the step-8 re-stamp.
85
88
  4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the stamp-independent reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
86
- - **Report step 3's outcome in plain language** — for **each** `pointers` slot (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); the **four project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (orchestration config, gate declaration, autonomy declaration, enforcement scripts — their outcome tokens are enumerated in step 3), each rendered in plain language: what was created, what was left exactly as the user wrote it, and — for a `failed` line — what stopped it; the **`gates-migration`** result — *nothing to migrate*, the shown plan *applied* on your explicit yes, or the plan *left unapplied* (consent not given), with any INERT checker or CUSTOMIZED entries named; the **placed-bridge refresh** (`bridges`) outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` with its re-scan verdict / *could not refresh* + recovery); the **agent-rules lens** (`lens`) outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*) and the **Communication-section** outcome (its own set: refreshed / already current / custom preserved + note / section absent — noted / over the cap — refused); the **bridge-settings reconcile** (`bridge-settings`) outcome (paste the tool's line(s) verbatim); and, for a hidden deployment, whether the hidden-mode footprint (`footprint`) was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
89
+ - **Report step 3's outcome in plain language** — for **each** `pointers` slot (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); the **five project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (orchestration config, gate declaration, autonomy declaration, enforcement scripts, navigator index — their outcome tokens are enumerated in step 3), each rendered in plain language: what was created, what was left exactly as the user wrote it, and — for a `failed` line — what stopped it; for the navigator, relay the **late** `--only index` line, not the early one; the **`gates-migration`** result — *nothing to migrate*, the shown plan *applied* on your explicit yes, or the plan *left unapplied* (consent not given), with any INERT checker or CUSTOMIZED entries named; the **placed-bridge refresh** (`bridges`) outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` with its re-scan verdict / *could not refresh* + recovery); the **agent-rules lens** (`lens`) outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*) and the **Communication-section** outcome (its own set: refreshed / already current / custom preserved + note / section absent — noted / over the cap — refused); the **bridge-settings reconcile** (`bridge-settings`) outcome (paste the tool's line(s) verbatim); and, for a hidden deployment, whether the hidden-mode footprint (`footprint`) was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
87
90
  - **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
88
91
  - **Render the mandatory Recommendations section — on this exit too, BEFORE the footer.** Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language: every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; show the raw tool block on request. The section is present-even-when-empty (with everything optimal the body is exactly `no recommendations — flow optimal.`) and VERDICT-FIRST — the composed verdict line renders from the frozen templates `{K} item(s) need attention` / `nothing is broken` / `{N} optional recommendation(s), apply any you want` / `optimality NOT attested — {M} probe check(s) skipped`. Then OFFER the consent-gated applies: the user picks items in plain language; surface each picked item's posture note, get the explicit confirm, then run EXACTLY the rendered one-liners (a HAND-APPLY item is never run by you) — the full lane in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`. Pinned order on this exit: Recommendations block → optional applies → report footer → the commit ask (the advisor/apply lane never lands after the commit ask).
89
92
  - **Live host/session facts are tool-composed only.** Any claim this report makes 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** (the lines you just composed, or a probe you ran this run); 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. Full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`.
@@ -91,5 +94,5 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
91
94
  - **Then ask before committing — never auto-commit.** If step 3 added the slot (or anything else changed), report it and ask. If step 3 was a pure zero-diff no-op and nothing else changed, give the plain **settings already current — no update needed** message (the *Success state* contract in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and still print the read-only version block (installed package versions) + backend line — but **no `docs/ai` structure version and no two-axes note** (nothing changed, so the number is inert here).
92
95
  5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
93
96
  6. **Collect the migration answers FIRST, then apply.** If `AGENTS.md` is missing BOTH the *Communication language* and *Attribution* blocks — i.e. both blocks are missing (a pre-1.1.0 deployment) — ask the two questions as ONE structured multi-question prompt; record each answer individually, write nothing until ALL are answered, and carry the answers into the migrations below: a migration whose answer was already collected never re-asks (its own "Ask the user" step is the standalone fallback); a single missing block keeps its single ask (step 7). Then apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those newer than the project's stamp. Migrations are **idempotent** — safe to re-run.
94
- 7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`. (An answer already collected by the step-6 batched prompt is carried in — never re-asked here.)
95
- 8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`3.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **four project-configuration ensure** (`configs`) lines, the **`gates-migration`** result, the **placed-bridge refresh** (`bridges`) lines (pasted verbatim), the **agent-rules lens** (`lens`) + **Communication-section** outcomes (same outcome sets as step 4), and the **bridge-settings reconcile** (`bridge-settings`) outcome (line(s) pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **render the mandatory Recommendations section**: run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language (every fact, count and item, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request; present-even-when-empty: `no recommendations — flow optimal.`), then OFFER the consent-gated applies (per picked item: posture note → explicit confirm → run EXACTLY the rendered one-liner; a HAND-APPLY item is never run by you — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). **Every current host/session claim in this report is tool-composed only** — prompts fired, sandbox scope, whether a bypass was needed, network reachability and approval counts must trace to **live tool output** from **this session**, a memory/handover snapshot is **context, never report facts**, and an unbacked claim is **omitted or explicitly marked unverified** (full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing** — the pinned order on this exit is: Recommendations block → optional applies → report footer → the commit ask.
97
+ 7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`. (An answer already collected by the step-6 batched prompt is carried in — never re-asked here.) **Then, before the stamp, re-run the LATE navigator finalizer:** the migrations of steps 6–7 may have added or changed `docs/ai` files, so run the one op again — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>` — and relay THAT line in the step-8 report (idempotent: an untouched tree reports `already-current`; a failure STOPs this upgrade like any other ensure).
98
+ 8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`3.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **five project-configuration ensure** (`configs`) lines — the navigator one being the late `--only index` run at the end of step 7, the **`gates-migration`** result, the **placed-bridge refresh** (`bridges`) lines (pasted verbatim), the **agent-rules lens** (`lens`) + **Communication-section** outcomes (same outcome sets as step 4), and the **bridge-settings reconcile** (`bridge-settings`) outcome (line(s) pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **render the mandatory Recommendations section**: run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language (every fact, count and item, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request; present-even-when-empty: `no recommendations — flow optimal.`), then OFFER the consent-gated applies (per picked item: posture note → explicit confirm → run EXACTLY the rendered one-liner; a HAND-APPLY item is never run by you — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). **Every current host/session claim in this report is tool-composed only** — prompts fired, sandbox scope, whether a bypass was needed, network reachability and approval counts must trace to **live tool output** from **this session**, a memory/handover snapshot is **context, never report facts**, and an unbacked claim is **omitted or explicitly marked unverified** (full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing** — the pinned order on this exit is: Recommendations block → optional applies → report footer → the commit ask.
@@ -1,6 +1,6 @@
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 written-empty-index guard.
3
+ // colocated file): the unknown-argument refusal and the pre-write symlink refusal on the index path.
4
4
  import { describe, it } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
6
  import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
@@ -20,7 +20,7 @@ describe('check-docs-size runCli — refusal branches', () => {
20
20
  assert.match(stderr, /Unknown argument: --bogus/);
21
21
  });
22
22
 
23
- it('--write-index landing on a sink path (index stat size 0) is the loud written-empty refusal', async () => {
23
+ it('--write-index refuses a symlinked index path BEFORE writing, naming the path', async () => {
24
24
  const root = mkdtempSync(join(tmpdir(), 'cds-cli-'));
25
25
  try {
26
26
  mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
@@ -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
+ });