@sabaiway/agent-workflow-kit 5.7.0 → 5.9.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 +67 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +26 -17
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +6 -5
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +13 -4
- package/bridges/antigravity-cli-bridge/bin/agy.sh +7 -4
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +24 -0
- package/bridges/antigravity-cli-bridge/capability.json +3 -3
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +9 -8
- package/bridges/antigravity-cli-bridge/references/models-and-flags.md +31 -14
- package/bridges/antigravity-cli-bridge/setup/README.md +4 -3
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +6 -2
- package/references/modes/grounding.md +4 -3
- package/references/modes/upgrade.md +8 -5
- package/references/scripts/check-docs-size-cli.test.mjs +5 -4
- package/references/scripts/check-docs-size-ensure.test.mjs +332 -0
- package/references/scripts/check-docs-size.mjs +181 -30
- package/references/shared/composition-handoff.md +10 -0
- package/tools/doc-parity.mjs +5 -1
- package/tools/ensure-configs.mjs +33 -15
- package/tools/ensure-ops.mjs +79 -1
- package/tools/ensure-vocabulary.mjs +17 -3
- package/tools/grounding.mjs +105 -16
- package/tools/known-footprint.mjs +10 -0
- package/tools/upgrade-runlist.mjs +1 -0
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- opt-in-capability: none — an on-demand assembler that leaves no persistent state the advisor could observe -->
|
|
4
4
|
|
|
5
|
-
The **grounded-review facts assembler** (AD-038) — an ungrounded `agy` review GUESSES, and while the grounding contract is mechanized (`agy-review code --facts @f`), populating the facts file was a manual chore. This mode emits the **mechanical** facts sources of a payload (constraints / autonomy / plan / ledger digest); the orchestrator still owns any judgment-bearing additions. **Catalogued honestly as a WRITER** — `--out <path>` writes one file — with the invariant: `--out` accepts **only system-temp destinations outside the repo ($TMPDIR / /tmp — rewritable scratch) or a FRESH gitignored in-repo path (create-only, exclusive write)** and REFUSES everything else: a tracked path, an in-repo not-ignored path (a new untracked file would itself move the review fingerprint the facts are about to ground), an EXISTING in-repo file even when gitignored (the `.env` clobber class — this writer is bridge-tier auto-allowable), any other outside-repo destination, and symlink/non-regular leaves; **stdout is the default**. It never commits and never runs a subscription CLI.
|
|
5
|
+
The **grounded-review facts assembler** (AD-038) — an ungrounded `agy` review GUESSES, and while the grounding contract is mechanized (`agy-review code --facts @f`), populating the facts file was a manual chore. This mode emits the **mechanical** facts sources of a payload (constraints / autonomy / plan / ledger digest); the orchestrator still owns any judgment-bearing additions — delivered via `--extra`, never a shell append onto the emitted file (corpus #88/#95). **Catalogued honestly as a WRITER** — `--out <path>` writes one file — with the invariant: `--out` accepts **only system-temp destinations outside the repo ($TMPDIR / /tmp — rewritable scratch) or a FRESH gitignored in-repo path (create-only, exclusive write)** and REFUSES everything else: a tracked path, an in-repo not-ignored path (a new untracked file would itself move the review fingerprint the facts are about to ground), an EXISTING in-repo file even when gitignored (the `.env` clobber class — this writer is bridge-tier auto-allowable), any other outside-repo destination, and symlink/non-regular leaves; **stdout is the default**. It never commits and never runs a subscription CLI.
|
|
6
6
|
|
|
7
|
-
Run `node ${CLAUDE_SKILL_DIR}/tools/grounding.mjs [--constraints] [--autonomy] [--plan <path>] [--reserve-bytes <n>] [--out <path>]`:
|
|
7
|
+
Run `node ${CLAUDE_SKILL_DIR}/tools/grounding.mjs [--constraints] [--autonomy] [--plan <path>] [--extra <text|@file>]... [--reserve-bytes <n>] [--out <path>]`:
|
|
8
8
|
|
|
9
9
|
1. **`--constraints`** — slice the root `AGENTS.md` **Hard Constraints** section, verbatim and whole (exactly ONE matching heading; zero or several is a loud STOP, never a guess — the marker-slot discipline).
|
|
10
10
|
2. **`--autonomy`** (AD-044 Plan 3) — append the COMPUTED effective autonomy policy: the shared resolver (`resolveAutonomy`) over the **git-top** `docs/ai/autonomy.json` renders every red-line + per-activity level with a stated source line. An ABSENT file is exit 0 — the computed defaults ARE the effective policy (unlike `velocity-profile --autonomy`, which writes settings and refuses an undeclared policy); a malformed/unreadable policy fails CLOSED (exit 1). Informational for the reviewing backend — enforcement stays the OS sandbox + the orchestrator. Rides between the constraints and the plan sections.
|
|
11
11
|
3. **`--plan <path>`** — extract the plan's decision-bearing canonical sections, verbatim + whole: `## Approach` (REQUIRED — its "What we are NOT doing" text rides inside; it is not a heading in canon) and `## Verification` (REQUIRED — STOP if missing), plus `## Decisions (locked)` when present (the optional engine §7 heading); a DUPLICATE heading is always a STOP.
|
|
12
|
+
4. **`--extra <text|@file>`** (corpus #88/#95 — the fourth firing of the same pre-dispatch append step) — append orchestrator-supplied facts byte-VERBATIM after the mechanical sections (repeatable, argv order; the `agy-review --facts` convention: literal text, or `@path` read whole through a race-free descriptor). An `@file` must resolve inside the PROVEN git work tree (rev-parse success — no cwd fallback; the git dir itself is refused) or the system temp surface — anything else refuses loudly (the exfil class: this tool is bridge-tier auto-allowable), as does a missing, EMPTY, or non-regular file (a FIFO cannot block the read). The merge happens INSIDE the tool, so the promptless lane is the invocation itself.
|
|
12
13
|
5. **Byte budget** — the output honors the same `AGY_MAX_PROMPT_BYTES` contract the agy wrapper enforces (override may only tighten; the OS argv ceiling is rejected), MINUS **`--reserve-bytes <n>`** — the artifact share the caller expects `agy-review` to add around these facts. Overflow is trimmed tail-first with a loud in-band marker + stderr report — never a silent cut.
|
|
13
14
|
6. Feed the result to the wrapper: `agy-review code --facts @<out>`. The `procedures` advisor renders this invocation as a concrete pre-step whenever the resolved review dispatch includes agy — populated with the in-flight plan path when exactly one plan is in flight.
|
|
14
15
|
|
|
15
|
-
**Invariants:** writer (writes at most the ONE `--out` scratch file — system-temp, or a fresh gitignored in-repo path, exclusive-create) · never commits · never runs a subscription CLI · verbatim slices
|
|
16
|
+
**Invariants:** writer (writes at most the ONE `--out` scratch file — system-temp, or a fresh gitignored in-repo path, exclusive-create) · never commits · never runs a subscription CLI · verbatim slices + verbatim extras (assembly is mechanical; facts judgment stays with the orchestrator — `--extra` carries it, the tool never edits it).
|
|
@@ -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
|
|
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 **
|
|
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 **
|
|
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.
|
|
@@ -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
|
|
32
|
-
// the
|
|
33
|
-
|
|
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,
|
|
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
|
+
});
|