@sabaiway/agent-workflow-kit 14.29.1 → 14.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 14.30.0 — the upgrade brings the docs navigator back to green past 80 lines: one yes replaces every edited spec-layer script, and a navigator write the project's own checker rejects is put back (AD-182)
8
+
9
+ **`/agent-workflow-kit upgrade` now leaves a project with more docs than the navigator's old cap on a navigator its
10
+ own docs check accepts — behind an edited spec-layer file, once the user says yes to replacing it.** Before, the bundled checker stamped `maxLines: 80` into `docs/ai/index.md` whatever its
11
+ length, the navigator step wrote it whatever checker the project's hook runs, and the spec-layer step told the user to
12
+ copy the scripts by hand when one was edited.
13
+
14
+ - **The bundled checker stamps a cap it honours.** `references/scripts/check-docs-size.mjs` is byte-identical to memory
15
+ 8.2.0's: `maxLines: 80` while the navigator fits, its own line count past that.
16
+ - **An unedited older checker refreshes.** The prior catalog gains the memory 7.0.0–8.1.5 checker body, so the
17
+ spec-layer step refreshes a deployed checker still on it (`refreshed`, or `seeded` when the run also seeds), unless
18
+ an edited file of either pair holds it back until the replace run.
19
+ - **One yes replaces every edited file.** While a file of the reader or checker pair carries a body this kit did not
20
+ ship, the spec-layer step names each such file, the store-root line names them as the reason it waits, and ONE line
21
+ states its condition and prints the command: `node <kit>/tools/ensure-configs.mjs --reconcile --replace-custom
22
+ <digest> --cwd <project>`, both paths shell-quoted. The digest binds the set of edited files and their bytes. The
23
+ upgrade asks one yes naming those files and saying their edits are overwritten, even on a non-zero exit.
24
+ - **The replace run overwrites no edited file the yes did not name.** `--replace-custom <digest>` re-surveys first:
25
+ while an edited file remains, another set or other bytes is `failed` / `race-unresolved` with nothing written and a
26
+ fresh replace line; with no edited file left it runs the ordinary step, so a second run of the same line changes
27
+ nothing. On a match it rewrites each
28
+ edited file to the bundled body (`carried an edited body — replaced by the bundled one on the user's yes`), refreshes
29
+ and seeds as the step always does, then runs the navigator step, only after a spec-layer step that did not fail;
30
+ otherwise its line ends `the navigator step did not run: …`. It is a usage error (exit 2, nothing run) with `--only`,
31
+ with `--dry-run`, with an empty value or passed twice. No backup copy is written.
32
+ - **The project's own checker judges the navigator.** Where the project deploys `scripts/check-docs-size.mjs` and it,
33
+ or the `scripts/spec-schema.mjs` it imports, is not the bundled body, the navigator step runs that checker's
34
+ `--check-index` (30 s, then killed): accepted means exit 0 and a navigator within the `maxLines` it stamps. A
35
+ navigator accepted as found is `already-current` with nothing written. Otherwise the bundled generator writes and the
36
+ checker judges again: accepted is `regenerated`; rejected puts the navigator back byte for byte (removed again if it
37
+ was absent) and reports `customized-preserved`, the run still exiting 0, its line naming the checker's exit and
38
+ output and the route. A later failure puts back first and keeps its own cause; a put-back that fails is `failed` /
39
+ `write-refused`, saying a navigator the project's checker rejects may stand; a checker that cannot start or does not
40
+ answer in time is `failed` / `index-probe-failed`. The dry run runs no project code and says that, unless the
41
+ spec-layer step first refreshes every such module, the real run judges by the project's checker. With no
42
+ `scripts/check-docs-size.mjs`, or both modules on the bundled body, the step is unchanged; where it exists, a hook
43
+ module that is not a regular file is `failed` / `wrong-node-kind` and one that cannot be read `failed` /
44
+ `unexpected-error`, nothing written.
45
+ - **No shipped instruction says to copy an edited spec-layer script over by hand; past CHANGELOG entries are history.**
46
+ `upgrade.md` row 3 carries the consent sentence and the `configs` block's STOP waits for that ask; the navigator
47
+ token list gains `customized-preserved`; the `spec-adoption` example names the replace line's own yes, and its risk
48
+ text the prior refresh and that yes; the help and header of `ensure-configs.mjs` and
49
+ the comments of the ensure modules say the same. No outcome token or failure cause is added.
50
+ - Known limits: rules a custom checker's default run adds beyond the cap are outside the judgement; after the yes the
51
+ upgrade report still relays the first spec-layer line (queued as `UPGRADE-REPORT-RELAYS-THE-PRE-REPLACE-SPECS-LINE`).
52
+
7
53
  ## 14.29.1 — an upgrade keeps the sandbox-masks block byte for byte and names every non-blank line it takes out (AD-181)
8
54
 
9
55
  **On a hidden deployment, the footprint step of `/agent-workflow-kit upgrade` (`hide-footprint --reconcile`) now leaves
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow". New to the epic, story and task tier? Run `/agent-workflow-kit tier`. A typed decision with a confidence from Jev? Run `/agent-workflow-kit jev`.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.29.1'
6
+ version: '14.30.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "14.29.1",
6
+ "version": "14.30.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "14.29.1",
3
+ "version": "14.30.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -17,7 +17,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root> [--
17
17
 
18
18
  1. **`--cwd` is REQUIRED** — the target project is explicit, never inferred from the shell's current directory (a subdirectory invocation still advises on the named root).
19
19
  2. **Verdict first (present-even-when-empty, opening at the `## Recommendations (agent-workflow)` header).** With everything optimal the body is exactly `no recommendations — flow optimal.` — the empty-state line ALONE is the verdict, zero added lines. Every other state opens the body with ONE verdict line composed from the frozen templates: `{K} item(s) need attention` leads when any item's severity is `attention` (a CONFIGURED declaration that is broken, drifted, degrading or invalid — the deployment needs review); `{N} optional recommendation(s), apply any you want` covers the `optional` class (offers to enable an unconfigured capability), led by `nothing is broken` ONLY when no item needs attention AND no probe check was skipped (a skipped probe could hide an attention-class problem — the claim never overreaches); `optimality NOT attested — {M} probe check(s) skipped` is appended last whenever probe checks were skipped. (The `(s)` invariant form IS the pinned pluralization — no singular/plural branching.) Items render attention-first, each tagged `needs attention:` / `optional:`. A failed probe renders as a stated `⚠ skipped item …` line — never a crash, never a fabricated item.
20
- 3. **The apply-through-agent lane — an explicit informed-consent checkpoint:** the agent applies chat and harness items on the chat yes, harness items by the consent route below. A chat item is never retried outside the sandbox and every failure of a chat step is a STOP. A user item, and any apply slot that opens with HAND-APPLY whatever its lane, is the user's own step, handed over as worded. After presenting, OFFER to apply; the user selects items in plain language; for each selected item you **surface its posture note inline** where one exists (the per-item notes below cover exactly the risk-marked keys). For a chat item: the user **explicitly confirms**, and **only then** — **no command runs before confirmation** — do you run the rendered command. Do NOT infer safety from the presence or absence of an `--apply` flag: most chat items' rendered command IS the mutation and completes on that one run, **including a no-`--apply` mutation** such as `spec-adoption`'s `ensure-configs --reconcile --only specs`. Some items instead render a **dry-run preview** that changes nothing and prints an explicit follow-up `--apply`/mutating command to run NEXT (e.g. the `gates-declaration` seeder): after the SAME confirmation you run that printed follow-up command — no second ask, no improvisation (each writer keeps its own consent semantics: previews, `--apply` flags, refusals). An item marked **HAND-APPLY** (e.g. `enforcement` or `read-lane.stale`) is **the user's own step** — hand the user its rendered line together with its posture note; HAND-APPLY is **maintainer territory wherever it renders** — the apply slot or the `recipe:` line. For `worktrees-dir` the two classes COMPOSE in one item and the ORDER is part of the contract: its `recipe:` line is the HAND-APPLY grant advice, its apply one-liner is the dry-run ack preview, and you **wait for the maintainer to confirm the grant is applied (or the terminal fallback chosen)** before running that preview and the exact `--apply` command it prints — the ack RECORDS a choice that must already have been made, and against a trusted host NO the item renders no ack at all.
20
+ 3. **The apply-through-agent lane — an explicit informed-consent checkpoint:** the agent applies chat and harness items on the chat yes, harness items by the consent route below. A chat item is never retried outside the sandbox and every failure of a chat step is a STOP. A user item, and any apply slot that opens with HAND-APPLY whatever its lane, is the user's own step, handed over as worded. After presenting, OFFER to apply; the user selects items in plain language; for each selected item you **surface its posture note inline** where one exists (the per-item notes below cover exactly the risk-marked keys). For a chat item: the user **explicitly confirms**, and **only then** — **no command runs before confirmation** — do you run the rendered command. Do NOT infer safety from the presence or absence of an `--apply` flag: most chat items' rendered command IS the mutation and completes on that one run, **including a no-`--apply` mutation** such as `spec-adoption`'s `ensure-configs --reconcile --only specs` (behind an edited file it prints a replace line that takes its own yes). Some items instead render a **dry-run preview** that changes nothing and prints an explicit follow-up `--apply`/mutating command to run NEXT (e.g. the `gates-declaration` seeder): after the SAME confirmation you run that printed follow-up command — no second ask, no improvisation (each writer keeps its own consent semantics: previews, `--apply` flags, refusals). An item marked **HAND-APPLY** (e.g. `enforcement` or `read-lane.stale`) is **the user's own step** — hand the user its rendered line together with its posture note; HAND-APPLY is **maintainer territory wherever it renders** — the apply slot or the `recipe:` line. For `worktrees-dir` the two classes COMPOSE in one item and the ORDER is part of the contract: its `recipe:` line is the HAND-APPLY grant advice, its apply one-liner is the dry-run ack preview, and you **wait for the maintainer to confirm the grant is applied (or the terminal fallback chosen)** before running that preview and the exact `--apply` command it prints — the ack RECORDS a choice that must already have been made, and against a trusted host NO the item renders no ack at all.
21
21
  4. Registry strings (benefits + item texts) are frozen tool data, fact-true, ONE line under the pinned shape cap — posture/risk detail lives in the notes below at the consent moment, never inline in the overview. The dual velocity+security wording (`safer — blast radius bounded by the OS sandbox, not human attention`) rides ONLY the items with a real security delta (the autonomy render, the sandbox provisioning); the bridge-wrappers item claims **velocity only**.
22
22
 
23
23
  The **consent route** for a harness item: run its `check:` line in the sandbox and show its result. On the check's `masked:` line, say so and run that same check line once more outside the sandbox, so it re-judges in the apply's own environment; the yes is taken on the outside result. Take the user's yes on that result and run its `apply:` line with that result's digest once, outside the sandbox at once, so the harness asks at the point of action (with the apply ask rule seeded, its own dialog); never another line, never unseen. When velocity-core, kit-tools-tier or bridge-tier is among the user's picks (velocity-core preferred), check, confirm and apply it, or leave it pending, before any other pick's check runs, so its apply seeds the ask rule before the other harness applies. When the harness's auto mode refuses the check or the apply, say so and give the one plain step: leave auto mode for this one action (Shift+Tab in the terminal, the mode indicator in an editor) and say yes, or run `npx @sabaiway/agent-workflow-kit@latest init` in a terminal of your own. On the user's word, run the same line once more when they left auto mode, never another, never unseen; when they ran `init` instead, run the advisor again and offer only what is still pending, each through this route from its check (after a restart `init` asked for, the next session's upgrade does it). When that refusal hits the velocity-core, kit-tools-tier or bridge-tier pick taken first, run no other pick's check until the user answers the plain step. A check that refuses (exit 1) leaves the item unapplied and pending, its result shown; a changed digest (exit 5) applies nothing, shows the new result and asks a new yes. A no, a harness refusal and any other failure of a harness apply leave the item pending with its output shown, and the agent continues. `init`, run in a console from the project's folder, is the route the user may take instead.
@@ -40,7 +40,7 @@ A variant's posture note, where its key has one, is printed by `init` from this
40
40
  - `enforcement` — copies the bundled `references/scripts/*.mjs` runnable scripts and their tests, then installs the pre-commit hook. It is HAND-APPLY, the user's own step; the offer renders only when `scripts/` is absent or a plain directory contains no bundled basename, so the copy never clobbers an existing file and never follows a link. The hook invokes `node` directly, so a committer without it on PATH gets a loud failure. The installer refuses to overwrite a pre-commit hook it did not write — on that refusal stop and merge the hook by hand (bootstrap step 9); the copied scripts stay in place. Risk profile: local tooling and a Git hook are added only when the maintainer applies the shown command.
41
41
  - `mcp-channel` — the offer is to REGISTER the kit's read-only stdio MCP server in this project on the harness lane: a registration is a command your MCP client will RUN. The agent applies it on the chat yes after the check; `init` previews the exact entry and applies it on the terminal y as the route the user may take instead. What the registration buys: path questions and literal searches become typed tool calls whose arguments are named JSON fields instead of a string handed to a shell, so a pipe, a redirect or a quote inside a pattern or a path stays DATA and is never interpreted — they are perfectly legal bytes to search for, there is simply no shell to read them as operators. Posture: the server is a **read-only child of your client** (path/type/size/line facts and literal search over this project root; no write and no exec API), and like the client itself it runs **outside the Bash sandbox** — the sandbox is not what bounds it, the server's own root containment is. The two allow rules it adds make those two tools promptless and nothing else. The **`.differing`** arm is also a harness item: an `agent-workflow` entry already stands in `.mcp.json` and **structurally differs** from what this kit copy would write (another kit copy, a hand-edited path, an added `env`; the comparison ignores key order, so re-serialized identical bytes are the same registration). The check shows the entry standing in `.mcp.json` and the entry it would write; the chat yes confirms the replacement. `init` previews `mcp --replace` and replaces only on the terminal y. Stated limit: this item does **not** detect a `disabledMcpjsonServers` veto, so a converged `mcp-channel` means *what the mode writes is in place*, not *the client will load it* — see `${CLAUDE_SKILL_DIR}/references/modes/mcp.md` for why that check was subtracted rather than half-built. Risk profile: a new read-only channel your client will launch; no write or exec exposure; a differing registration is replaced only on the checked chat yes or the terminal y and every other declaration is kept.
42
42
  - ADDITIONAL `gates-inert` arms (the third outcomes) — two further arms, and they differ in whether anything is BROKEN. **`producer-unrecognized`** — a checker with no producer anywhere in the declaration, on a tracked tree the changed-line coverage domain cannot reach (`.ts`/`.tsx`/`.jsx`/`.mts`/`.cts` strictly outnumber `.mjs`/`.cjs`/`.js`): the dead pair is real, so this arm is **HAND-APPLY** and the two remedies are marking the real producer with `"lcovProducer": true` or dropping the checker. Never a `node --test` prescription over a project that has no such suite, never the fill preview, and never an acknowledgement — a dead pair is broken, not narrow, and removing a producer after an acknowledgement lands right back in this arm. **`coverage-domain-narrow`** — the producer/checker pair IS live and the tree is still dominated by what the domain excludes: nothing is broken, and the honest sentence is that certification covers the assessable minority. Its apply is the consent-gated **ack writer** preview (a NEUTRAL fingerprint into `docs/ai/acks.json` as `coverageDomainAck`, never a security key); after the SAME confirmation you run the `--apply` it prints. The fingerprint binds the FACT — the verdict plus the unsupported extensions present, never the file counts — so an acknowledged project stays quiet as it grows and re-fires when a new unsupported language arrives or the verdict flips. The census reads the TRACKED tree with a read-only `git ls-files`; a tree it cannot read (a non-git deployment) becomes a stated skip, so optimality is withheld rather than assumed. Risk profile: no enforcement change of any kind — one acknowledgement recorded in a family-owned file, and one hand edit that stays the maintainer's.
43
- - `spec-adoption` — the feature-spec layer's ADOPTION STATE, judged from `docs/ai/specs/` alone through the same census `spec-check --all` runs, so a store the checker would refuse to observe is never counted here either. Both arms are OFFERS (`optional`) — the layer is opt-in, and `attention` stays reserved for a configured declaration that is broken. **`not-adopted`** (no store): every plan then cites zero governing specs by default and nothing says adoption never started; its apply is the spec-layer ensure one-liner (`ensure-configs --reconcile --only specs`, the same seed `upgrade` runs — it writes the reader/checker pairs and the store root, create-only, and skips with a stated reason where no Node evidence exists), and its `recipe:` line carries a NAMED **HAND-APPLY alternative** — the DECLINE preview (`ack-write --lane spec-adoption`, a dry-run that prints the exact `--apply`), which records `specAdoptionAck` in the family-owned `docs/ai/acks.json`. **The two are exclusive, and the order is part of the contract:** ask which the maintainer wants BEFORE the confirmation; on "seed" run the apply; on "decline" run NOTHING — hand over the preview line and let them run it and its printed `--apply`; never run the seed after a decline was chosen, and never run the decline preview for them. **`adopting`** (a store with no live contract): an optional item naming the draft count; its apply is that same decline preview (a dry-run; after the SAME confirmation you run the `--apply` it prints), and the honest remedy is authoring a contract from the skill's `SPEC_TEMPLATE.md`. A recorded decline silences BOTH arms and shows on `status` as ` — declined`; one live contract makes the decline moot. An unreadable store is a stated skip, never `flow optimal`. Risk profile: the seed writes only create-only files under `scripts/` and `docs/ai/specs/`; the decline writes one key into the ack store; nothing here authors a spec or blocks a plan.
43
+ - `spec-adoption` — the feature-spec layer's ADOPTION STATE, judged from `docs/ai/specs/` alone through the same census `spec-check --all` runs, so a store the checker would refuse to observe is never counted here either. Both arms are OFFERS (`optional`) — the layer is opt-in, and `attention` stays reserved for a configured declaration that is broken. **`not-adopted`** (no store): every plan then cites zero governing specs by default and nothing says adoption never started; its apply is the spec-layer ensure one-liner (`ensure-configs --reconcile --only specs`, the same seed `upgrade` runs — it seeds the reader/checker pairs and the store root, and skips with a stated reason where no Node evidence exists), and its `recipe:` line carries a NAMED **HAND-APPLY alternative** — the DECLINE preview (`ack-write --lane spec-adoption`, a dry-run that prints the exact `--apply`), which records `specAdoptionAck` in the family-owned `docs/ai/acks.json`. **The two are exclusive, and the order is part of the contract:** ask which the maintainer wants BEFORE the confirmation; on "seed" run the apply; on "decline" run NOTHING — hand over the preview line and let them run it and its printed `--apply`; never run the seed after a decline was chosen, and never run the decline preview for them. **`adopting`** (a store with no live contract): an optional item naming the draft count; its apply is that same decline preview (a dry-run; after the SAME confirmation you run the `--apply` it prints), and the honest remedy is authoring a contract from the skill's `SPEC_TEMPLATE.md`. A recorded decline silences BOTH arms and shows on `status` as ` — declined`; one live contract makes the decline moot. An unreadable store is a stated skip, never `flow optimal`. Risk profile: the seed writes create-only files under `scripts/` and `docs/ai/specs/` and refreshes a script body a release shipped; behind an edited file it prints a replace line that takes its own yes; the decline writes one key into the ack store; nothing here authors a spec or blocks a plan.
44
44
  - `profile-gap` — each item's apply is a PREVIEW that writes nothing, and consent takes two steps: the confirmed pick runs the preview and you relay every line it prints (the environment-resolved slot values, each checker `entry:` line with its trust-chain disclosure, a Communication region the insert plans in the same run); the same line with ` --apply` appended runs only on a SECOND yes over those lines, never carried by the first. Items whose apply lines are byte-equal are one writer and take one answer: the tier preview's `--apply` writes the absent slots, the store seed and the queue seed together; the checker verb writes only its own entries; the insert writes every region it plans, all or nothing, so a `communication-absent` skip beside a declined `story-sessions-section` gets its own ask after the insert's full preview. A no records nothing and the item stays on the screen. A DECLINE is asked apart from later, before anything runs, and runs `node ${CLAUDE_SKILL_DIR}/tools/tier-preview.mjs --decline --cwd <project-root>` only when the user refuses EVERY item the tier preview then reports offered: it records each of them at the shipped lineage, and a declined item leaves the screen until a lineage step changes the profile. An undecidable entry is a stated skip naming its reason, the remedy by reason: a prior → the upgrade's `lens` reconcile; an absent region → the insert; a customized region → a hand edit only (the lens preserves it), so that skip stands and withholds `flow optimal`. Risk profile: the tier preview's `--apply` rewrites `docs/ai/orchestration.json` in canonical form with the `_README` note and creates `docs/ai/epics/.gitkeep` and `docs/plans/queue.md` create-only; the insert adds regions to `docs/ai/agent_rules.md`; the checker verb adds entries to the PRIVILEGED `docs/ai/gates.json`; the decline writes `docs/ai/profile-declines.json`; nothing here commits.
45
45
  - `jev-connect` — the lane is user and env-dependent: the connect command is the user's own step in a terminal of their own, where it asks for the key; the key never reaches the agent or the chat (the command refuses without a terminal, and a terminal is not consent). When the recipe line opens with `run the apply in <place>`, that place is the terminal to run it in; `init`, run there, offers the command on its own consent line. The command reads the key with no echo, makes one network call to api.typesafe.ai with the vendor's sample text only on the user's own run, and writes the key in plain text — as managed export lines in the shell's startup files (bash: ~/.bashrc and the login file, created as ~/.bash_profile when none exists; zsh: ~/.zshrc), as the kit's own file ~/.config/fish/conf.d/typesafe-api-key.fish for fish, or on Windows as the user environment variable TYPESAFE_API_KEY, stored in the Windows user environment; a process already running at the save can keep its old environment and hand it to what it starts — the guide's key mark confirms the agent sees it (a file is readable by any process running as that user, and by other local accounts when one is world-readable in a traversable home); the vendor keeps data under its processing agreement and offers zero retention only to enterprise accounts. The item reads the environment the agent's commands run in: a host setting that filters it shows the item while the key is set in the user's shell. The decline is the HAND-APPLY alternative on the recipe line, asked apart BEFORE the confirmation (the `spec-adoption` order): on "connect" hand over the apply line, on "decline" run nothing and hand over the recipe line; the decline is recorded per project as `jevConnectAck` in `docs/ai/acks.json`. Risk profile: a plain-text key in the user's startup files or the Windows user environment, written by a command the user runs; one network call from that run.
46
46
  - `jev-skill` — the lane is harness; both `jev-skill` and `jev-skill.earlier` are env-dependent: the agent applies the item on the chat yes after the check, showing the files it would write; it changes no harness setting. `init` applies it on the batch terminal yes as the route the user may take instead. When the recipe line opens with `run the apply in <place>`, that place is where a user who prefers their own terminal runs it. The skill is third-party text — the vendor's, MIT — that the kit ships pinned and installs as a verified copy into three agent skill roots: `~/.agents/skills`, the Claude Code skills dir (`CLAUDE_CONFIG_DIR` or `~/.claude`) and `~/.gemini/config/skills`. It is never updated silently: a copy at an earlier pin is replaced only by that command, and a foreign copy is left untouched and named on the recipe line. A copy the vendor's `skills` CLI wrote with the same bytes reads current, and a later pin move replaces it like a kit copy; a vendor plugin install is invisible to the file check, so the Claude Code root may read absent while the plugin serves it. Only when the user asks to see the plan does the agent run the command without `--apply` (a read-only dry run). The decline is the HAND-APPLY alternative on the recipe line, asked apart BEFORE the confirmation: on "install" run the check line and, on the yes given on its result, the apply line by the consent route, on "decline" run nothing and hand over the recipe line; the decline is recorded per project as `jevSkillAck` in `docs/ai/acks.json`. Risk profile: third-party text written into the user's agent skill roots by the kit's skill command; no network.
@@ -12,7 +12,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
12
12
 
13
13
  1. `pointers` — `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs reconcile <project>/AGENTS.md` → per pointer: added · already present · skipped (reported) · a hard STOP.
14
14
  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).
15
- 3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `refreshed` / `regenerated` / `re-derived` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `not-placed` / `skipped-no-node-evidence` / `old-adr-layout-migration-instructed` / `failed`.
15
+ 3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `refreshed` / `regenerated` / `re-derived` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `not-placed` / `skipped-no-node-evidence` / `old-adr-layout-migration-instructed` / `failed` — consent (conditional): a specs replace line → ONE yes naming every file the custom lines name and saying their edits are overwritten, asked even on a non-zero exit; on the yes run the printed line, then STOP if that run fails or an op it does not re-run failed; on a no STOP on any failed op.
16
16
  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`.
17
17
  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.
18
18
  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.
@@ -33,7 +33,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
33
33
 
34
34
  **`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.
35
35
 
36
- **`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all eight** ensures — orchestration config · gate declaration · autonomy declaration · vehicles declaration · executor re-derivation · enforcement scripts · spec layer · 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); a refresh-class op rewrites only what it owns — the outcome tokens by ensure, below, name each arm, the executor's re-derivation among them. 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, autonomy and vehicles → `seeded` / `already-present`; executor → `not-placed` / `already-current` / `re-derived`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node-evidence`; specs → `seeded` / `refreshed` / `already-present` / `customized-preserved` / `skipped-no-node-evidence`; 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`, `node-evidence-unverifiable`, `wrong-node-kind`, `vehicle-settings-unreadable`, `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 eight reach an equal-head deployment **without a lineage-head bump or a migration file** (all inherently outside the docs cap-validator).
36
+ **`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all eight** ensures — orchestration config · gate declaration · autonomy declaration · vehicles declaration · executor re-derivation · enforcement scripts · spec layer · 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); a refresh-class op rewrites only what it owns — the outcome tokens by ensure, below, name each arm, the executor's re-derivation among them. 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, autonomy and vehicles → `seeded` / `already-present`; executor → `not-placed` / `already-current` / `re-derived`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node-evidence`; specs → `seeded` / `refreshed` / `already-present` / `customized-preserved` / `skipped-no-node-evidence`; index → `regenerated` / `already-current` / `customized-preserved`; 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`, `node-evidence-unverifiable`, `wrong-node-kind`, `vehicle-settings-unreadable`, `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 — once row 3's replace ask is answered when a specs replace line printed, then as that ask says** — 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 eight reach an equal-head deployment **without a lineage-head bump or a migration file** (all inherently outside the docs cap-validator).
37
37
 
38
38
  **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.)
39
39
 
@@ -45,9 +45,9 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
45
45
 
46
46
  **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). **Whether Node runs here is PROVEN, never proxied:** the ensure seeds on a regular `package.json` at the root OR on any kit-seeded `scripts/*.mjs` already deployed (a bootstrap places them into projects that never carry a `package.json`); only when every probe answers absent does it report `skipped-no-node-evidence`, a line naming every probe it checked, while the config ensures still run; a probe that cannot be read is `failed` with the cause `node-evidence-unverifiable`, nothing written. **A `skipped-*` outcome whose stated reason this tool could itself disprove may not exist** — the retired `skipped-no-node` was exactly that, printed beside twenty deployed Node scripts. 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.
47
47
 
48
- **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.
48
+ **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); under a hook module that is not the bundled one, a write the project's own checker rejects is put back → `customized-preserved`. 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: an untouched tree changes nothing.
49
49
 
50
- **What the spec-layer ensure does.** A deployment older than the feature-spec layer lacks `scripts/spec-schema.mjs` and `docs/ai/specs/index.md`, and its deployed `check-docs-size.mjs` predates the store collapse. In ONE order: the reader pair, then the checker pair — each seeded when absent and REFRESHED only while a file's bytes are a body a release shipped (an append-only digest catalog → `refreshed`; an edited body is preserved verbatim and withholds the writes that depend on it, the checker lane waiting on a byte-current reader pair); the store root is seeded from the bundled template, date rendered, ONLY behind a checker pair current after the run — an older or edited checker renders the store row by row and reds the hook's `--check-index`, so behind a custom checker the store root is NOT seeded and the line names the remedy (copy the pair from `${CLAUDE_SKILL_DIR}/references/scripts/` by hand, re-run). One token by precedence: `seeded` > `refreshed` > `customized-preserved` (an edited pair, and this run wrote nothing) > `already-present`; `skipped-no-node-evidence` only when neither a root `package.json` nor any kit-seeded `scripts/*.mjs` is present (the same Node-evidence probe the enforcement-script ensure uses; an unreadable probe is `failed` / `node-evidence-unverifiable`); every line states what this run did, and a write that stops partway names what landed. The legacy-ADR instruct never withholds it. **The adoption state this seed leaves behind is reported, never assumed:** the Recommendations section (step 4 / 8) carries the `spec-adoption` item — `not adopted` (the store is absent: the seed offered, the decline a named hand-apply alternative), `adopting` (a store with no live contract: the decline offered), silenced by a recorded decline (`ack-write --lane spec-adoption`), and a store the probe cannot read is a stated skip — and `status` prints the same state on its own line.
50
+ **What the spec-layer ensure does.** A deployment older than the feature-spec layer lacks `scripts/spec-schema.mjs` and `docs/ai/specs/index.md`, and its deployed `check-docs-size.mjs` predates the store collapse. In ONE order: the reader pair, then the checker pair — each seeded when absent and REFRESHED only while a file's bytes are a body a release shipped (an append-only digest catalog → `refreshed`; an edited body is preserved verbatim and withholds the writes that depend on it, the checker lane waiting on a byte-current reader pair); the store root is seeded from the bundled template, date rendered, ONLY behind a checker pair current after the run — an older or edited checker renders the store row by row and reds the hook's `--check-index`; edited files are replaced only on row 3's yes. One token by precedence: `seeded` > `refreshed` > `customized-preserved` (an edited pair, and this run wrote nothing) > `already-present`; `skipped-no-node-evidence` only when neither a root `package.json` nor any kit-seeded `scripts/*.mjs` is present (the same Node-evidence probe the enforcement-script ensure uses; an unreadable probe is `failed` / `node-evidence-unverifiable`); every line states what this run did, and a write that stops partway names what landed. The legacy-ADR instruct never withholds it. **The adoption state this seed leaves behind is reported, never assumed:** the Recommendations section (step 4 / 8) carries the `spec-adoption` item — `not adopted` (the store is absent: the seed offered, the decline a named hand-apply alternative), `adopting` (a store with no live contract: the decline offered), silenced by a recorded decline (`ack-write --lane spec-adoption`), and a store the probe cannot read is a stated skip — and `status` prints the same state on its own line.
51
51
 
52
52
  **`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. Of step 3's run-list, only this migration and `tools/checker-gates.mjs` under the `tier` ask change an existing gates.json (the FILL preview runs at init).
53
53
 
@@ -108,7 +108,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
108
108
  verbatim**: it validates the deployed host settings file's keys against the bundled manifests and
109
109
  **NEVER writes** it (the file lives outside every kit tree — D2), so an unknown/retired key is
110
110
  flagged + preserved, never edited. Runs on **every** upgrade; exit 0 covers every outcome.
111
- **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.
111
+ **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 changes nothing, 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.
112
112
  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:
113
113
  - **A skip line that contradicts the observed tree is a FINDING, never pasted as neutral.** Every ensure skip names the fact it proved (the probes that answered absent, the layout it read); if the tree you can see disproves that reason — a "no Node" skip beside deployed Node scripts, a "not deployed" skip beside a stamped `docs/ai` — do not relay the line as an outcome: raise it in the report as a defect of the tool, with the contradicting fact named, and stop short of any step that would build on the skip.
114
114
  - **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 **eight project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (the ensures and 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) and the **story-sessions section** outcome (its own set: refreshed / already current / custom edit preserved + note / section absent — noted / over the file's line cap — refused); the **full-flow offer** (`tier`) lines; 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`).
@@ -120,5 +120,5 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
120
120
  - **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).
121
121
  5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
122
122
  6. **Collect the migration answers FIRST, then apply.** Run `node ${CLAUDE_SKILL_DIR}/tools/migration-notes.mjs --cwd <project>` and relay its stdout lines verbatim, in order; a non-zero exit relays its one named line and STOPs, except `stamp-absent`, which routes back to step 1. Then run the preview `node ${CLAUDE_SKILL_DIR}/tools/migration-blocks.mjs --cwd <project>`. If its lines say both blocks are missing (pre-1.1.0), 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; a single missing block keeps its single ask (step 7). Then apply exactly the notes the tool listed, in its order ("only those newer than the project's stamp" is why, never a second selection). Migrations are **idempotent** — safe to re-run.
123
- 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. For a block the preview names as needing its answer, **ask the user their conversational language** (`--language`) or **whether the agent may attribute work to itself / AI** (`--attribution`, defaulting to `off`), never re-asking a step-6 answer; then re-run the preview with `--apply` and the answers, relaying its lines. Exit 1 `answer-missing`: ask exactly what its lines name and re-run `--apply` once; a second one STOPs. Any other exit 1 is a named refusal: relay it with its recovery (`cap`: trim `AGENTS.md`, re-run) and STOP before the finalizer and the stamp. **Then, before the stamp, re-run the LATE navigator finalizer:** the migrations of steps 6–7 may have 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).
123
+ 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. For a block the preview names as needing its answer, **ask the user their conversational language** (`--language`) or **whether the agent may attribute work to itself / AI** (`--attribution`, defaulting to `off`), never re-asking a step-6 answer; then re-run the preview with `--apply` and the answers, relaying its lines. Exit 1 `answer-missing`: ask exactly what its lines name and re-run `--apply` once; a second one STOPs. Any other exit 1 is a named refusal: relay it with its recovery (`cap`: trim `AGENTS.md`, re-run) and STOP before the finalizer and the stamp. **Then, before the stamp, re-run the LATE navigator finalizer:** the migrations of steps 6–7 may have 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 changes nothing; a failure STOPs this upgrade like any other ensure).
124
124
  8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`4.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 **eight project-configuration ensure** (`configs`) lines (enumerated in step 3) — 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** + **story-sessions section** outcomes (same outcome sets as step 4), the **full-flow offer** (`tier`) outcome, 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 route (per picked item: posture note → explicit confirm → chat: the one-liner; harness: the check line, then on the yes to its result the apply line; user: the user's own step — `${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.
@@ -58,6 +58,51 @@ maxLines: 80
58
58
  | [\`a.md\`](./a.md) | state | 10/10 | 2026-07-18 | never |
59
59
  `;
60
60
 
61
+ describe('spec:docs-navigator/S1 the navigator stamps a cap it honours', () => {
62
+ const createNames = (count) => Array.from({ length: count }, (_, index) => `n${String(index + 1).padStart(2, '0')}`);
63
+ const buildDocs = (names) => ({ docs }) => {
64
+ for (const name of names) writeFileSync(join(docs, `${name}.md`), DOC(name));
65
+ };
66
+ const countLines = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
67
+
68
+ it("a tree whose navigator renders exactly 80 lines stamps maxLines 80, byte-identical to today's render", async () => {
69
+ const names = createNames(57);
70
+ await withRoot(buildDocs(names), async ({ root }) => {
71
+ assert.equal((await cli(['--write-index', `--root=${root}`, '--today=2026-07-18'])).code, 0);
72
+ const index = indexOf(root);
73
+ const rows = names.map((name) => `| [\`${name}.md\`](./${name}.md) | state | 10/10 | 2026-07-18 | never |\n`);
74
+ assert.equal(countLines(index), 80);
75
+ assert.equal(index, EXPECTED_INDEX + rows.join(''));
76
+ });
77
+ });
78
+
79
+ it('a tree of 81 lines stamps maxLines 81 and both checker runs exit 0', async () => {
80
+ await withRoot(buildDocs(createNames(58)), async ({ root }) => {
81
+ assert.equal((await cli(['--write-index', `--root=${root}`, '--today=2026-07-18'])).code, 0);
82
+ const index = indexOf(root);
83
+ const checked = await runCli([`--root=${root}`]);
84
+ const fresh = await runCli(['--check-index', `--root=${root}`]);
85
+ assert.equal(countLines(index), 81);
86
+ assert.equal(index.match(/^maxLines: \d+$/m)?.[0], 'maxLines: 81', `default: ${checked.code}, --check-index: ${fresh.code}`);
87
+ assert.equal(checked.code, 0, checked.stdout + checked.stderr);
88
+ assert.equal(fresh.code, 0, fresh.stdout + fresh.stderr);
89
+ });
90
+ });
91
+
92
+ it('a hand-edited cap line is stale under --check-index', async () => {
93
+ await withRoot(buildDocs(createNames(58)), async ({ root, docs }) => {
94
+ assert.equal((await cli(['--write-index', `--root=${root}`, '--today=2026-07-18'])).code, 0);
95
+ const index = indexOf(root);
96
+ const edited = index.replace(/^maxLines: \d+$/m, 'maxLines: 90');
97
+ assert.notEqual(edited, index);
98
+ writeFileSync(join(docs, 'index.md'), edited);
99
+ const { code, stdout, stderr } = await runCli(['--check-index', `--root=${root}`]);
100
+ assert.equal(code, 1);
101
+ assert.match(stdout + stderr, /is stale/);
102
+ });
103
+ });
104
+ });
105
+
61
106
  describe('check-docs-size runCli — refusal branches', () => {
62
107
  it('an unknown argument exits 2 naming it', async () => {
63
108
  const { code, stderr } = await cli(['--bogus']);
@@ -11,6 +11,9 @@ import { runCli } from './check-docs-size.mjs';
11
11
  const DOC = (title) =>
12
12
  `---\ntype: state\nlastUpdated: 2026-08-15\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# ${title}\n`;
13
13
 
14
+ const NAVIGATOR_LINE_FLOOR = 80;
15
+ const EXTRA_DOC_COUNT = 85;
16
+
14
17
  const makeTree = (prefix) => {
15
18
  const dir = mkdtempSync(join(tmpdir(), prefix));
16
19
  mkdirSync(join(dir, 'docs', 'ai'), { recursive: true });
@@ -330,3 +333,28 @@ describe('check-docs-size --ensure-index', () => {
330
333
  }
331
334
  });
332
335
  });
336
+
337
+ describe('spec:docs-navigator/S2 --ensure-index past the 80-line floor', () => {
338
+ it('writes a navigator past 80 lines that the default run and --check-index accept', async () => {
339
+ const root = makeTree('cds-ensure-past-floor-');
340
+ try {
341
+ for (const number of Array.from({ length: EXTRA_DOC_COUNT }, (_, index) => index)) {
342
+ writeFileSync(join(root, 'docs', 'ai', `z-extra-${number}.md`), DOC(`extra ${number}`));
343
+ }
344
+ const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
345
+ assert.equal(code, 0);
346
+ assert.match(stdout, /ensure-index: regenerated/);
347
+ const navigator = readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
348
+ const lineCount = navigator.split('\n').length - (navigator.endsWith('\n') ? 1 : 0);
349
+ const maxLines = Number(navigator.match(/^maxLines: (\d+)$/m)?.[1]);
350
+ assert.ok(lineCount > NAVIGATOR_LINE_FLOOR);
351
+ const defaultRun = await runCli([`--root=${root}`]);
352
+ const indexCheck = await runCli(['--check-index', `--root=${root}`]);
353
+ assert.equal(defaultRun.code, 0, defaultRun.stdout);
354
+ assert.equal(maxLines, lineCount);
355
+ assert.equal(indexCheck.code, 0, indexCheck.stdout);
356
+ } finally {
357
+ drop(root);
358
+ }
359
+ });
360
+ });
@@ -45,6 +45,8 @@ const pathsFor = (root) => {
45
45
  };
46
46
 
47
47
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
48
+ const NAVIGATOR_CAP_FLOOR = 80;
49
+ const countLines = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
48
50
 
49
51
  // The one token every `--ensure-index` outcome line opens with — deploy/upgrade prose relays it
50
52
  // verbatim and the kit's ensure op reads it, so it is a contract, not a message.
@@ -263,7 +265,7 @@ export const inspectFile = async (found, today, root = ROOT) => {
263
265
  return { path: rel, lineCount: 0, frontmatter: null, spec: null, refused: true, errors: [refusal], warnings: [] };
264
266
  }
265
267
  const text = await readFile(filePath, 'utf8');
266
- const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
268
+ const lineCount = countLines(text);
267
269
  const fm = parseFrontmatter(text);
268
270
  const spec = readSpecVerdict(rel, text);
269
271
 
@@ -332,7 +334,7 @@ lastUpdated: __TODAY__
332
334
  scope: permanent
333
335
  staleAfter: 30d
334
336
  owner: none
335
- maxLines: 80
337
+ maxLines: __CAP__
336
338
  ---
337
339
 
338
340
  # Memory Map — __PROJECT__ \`docs/ai/\`
@@ -398,7 +400,6 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
398
400
  const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
399
401
  const onDemandLinks = meta.onDemandLinks ?? [];
400
402
  const hierarchicalLinks = meta.hierarchicalLinks ?? [];
401
- const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
402
403
  const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
403
404
  const singles = [];
404
405
  const grouped = COLLAPSE_GROUPS.map(() => []);
@@ -421,7 +422,8 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
421
422
  hierarchicalLinks.length > 0
422
423
  ? `\n\n## Subdirectory \`AGENTS.md\` (hierarchical)\n\n${hierarchicalLinks.map((link) => `- ${link}`).join('\n')}`
423
424
  : '';
424
- return `${header}${tableHeader}\n${tableRows}${onDemandSection}${hierarchicalSection}\n`;
425
+ const render = (cap) => `${INDEX_HEADER.replace('__CAP__', cap).replace('__TODAY__', todayStr).replace('__PROJECT__', projectName)}${tableHeader}\n${tableRows}${onDemandSection}${hierarchicalSection}\n`;
426
+ return render(Math.max(NAVIGATOR_CAP_FLOOR, countLines(render(NAVIGATOR_CAP_FLOOR))));
425
427
  };
426
428
 
427
429
  // Freshness regenerates the index with the on-disk header's OWN `lastUpdated`, so a mere day-rollover
@@ -8,8 +8,11 @@
8
8
  // executor .claude/agents/executor.md re-derived from docs/ai/vehicles.json when its bytes differ; absent stays absent
9
9
  // scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
10
10
  // specs scripts/<spec layer> reader + checker pairs seeded when absent, refreshed only on
11
- // + docs/ai/specs/index.md a shipped prior body, store root seeded behind a current checker
12
- // index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact)
11
+ // + docs/ai/specs/index.md a shipped prior body, an edited one replaced only on the
12
+ // user's yes; store root seeded behind a current checker
13
+ // index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact); under
14
+ // a project hook module that is not the bundled one, a write
15
+ // the project's own checker rejects is put back
13
16
  //
14
17
  // Each was prose in references/modes/upgrade.md that an agent performed by hand. One command instead
15
18
  // of one per ensure is deliberate: independent runs would be as many chances to skip one, and the mode
@@ -19,7 +22,8 @@
19
22
  // • --reconcile is REQUIRED. A bare run is a usage error, so nothing writes by accident.
20
23
  // • --dry-run reports `would-*` tokens and writes nothing — never a write token.
21
24
  // • The ops run in a FIXED order and one op's failure NEVER skips the rest: every op reports its own
22
- // token, and the exit is non-zero when any of them failed.
25
+ // token, and the exit is non-zero when any of them failed. The one exception is the replace run
26
+ // (--replace-custom <digest>): specs, then index only when specs did not fail.
23
27
  // • The deployment gate runs ONCE, before any op: an absent/symlinked docs/ai stops the whole run
24
28
  // with the gate's own message rather than one copy of it per op.
25
29
  //
@@ -53,15 +57,25 @@ const EMPTY_CWD = '--cwd needs a path argument (an empty value would silently me
53
57
  const EMPTY_ONLY = `--only needs one operation name (${ENSURE_OPS.join(' | ')}) — an empty value would silently widen the run`;
54
58
  const REPEATED_ONLY = '--only was passed more than once — this selector names exactly ONE operation';
55
59
  const unknownOp = (value) => `--only ${value}: no such operation (${ENSURE_OPS.join(' | ')}) — nothing was run`;
60
+ const EMPTY_REPLACE = '--replace-custom needs the digest its replace line printed — an empty value names no file to replace';
61
+ const REPEATED_REPLACE = '--replace-custom was passed more than once — a replace run carries exactly ONE digest';
62
+ const REPLACE_WITH_ONLY = '--replace-custom runs the spec layer and then the navigator, so it cannot be combined with --only — nothing was run';
63
+ const REPLACE_WITH_DRY_RUN = "--replace-custom writes on the user's yes, so it cannot be combined with --dry-run — nothing was run";
64
+ const INDEX_NOT_RUN = 'the navigator step did not run: a replace run runs it only after the spec layer succeeds';
56
65
 
57
66
  const HELP = `ensure-configs — the eight stamp-independent upgrade ensures, as ONE command.
58
67
 
59
68
  Usage:
60
69
  node ensure-configs.mjs --reconcile [--dry-run] [--only <op>] [--cwd <project>]
70
+ node ensure-configs.mjs --reconcile --replace-custom <digest> [--cwd <project>]
61
71
 
62
72
  --reconcile required — run the eight ensures (${ENSURE_OPS.join(', ')})
63
73
  --dry-run report what each ensure WOULD do; write nothing
64
74
  --only <op> run EXACTLY ONE of them (an unknown, missing or repeated value is a usage error)
75
+ --replace-custom <digest>
76
+ the replace run the specs ensure prints behind an edited spec-layer file, run only on
77
+ the user's yes: specs replaces every file its digest names, then index runs when
78
+ specs did not fail (not with --only or --dry-run; an empty value is a usage error)
65
79
  --cwd <dir> the target project (default: the current directory)
66
80
  --help, -h this help
67
81
 
@@ -69,19 +83,22 @@ Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never cl
69
83
  refreshed in place. Four ops refresh instead: the orchestration onboarding note, only while it still
70
84
  matches a canonical the kit shipped (your own wording is preserved verbatim); the spec-layer reader
71
85
  and checker pairs, only while a file's bytes are a body a release shipped (an edited file is
72
- preserved, and the writes that depend on it — the spec store root included — are withheld behind
73
- it); the placed executor vehicle (.claude/agents/executor.md), re-derived from docs/ai/vehicles.json
86
+ preserved, the writes that depend on it — the spec store root included — are withheld behind it,
87
+ and ONE printed replace line replaces every edited file only on the user's yes); the placed executor
88
+ vehicle (.claude/agents/executor.md), re-derived from docs/ai/vehicles.json
74
89
  whenever its bytes differ from the derived body (a hand edit is replaced, never preserved; an absent
75
90
  file is never placed here); and the navigator index — a GENERATED artifact, regenerated
76
- whenever it is missing or stale. The enforcement-script ensure detects an older ADR-store layout
77
- FIRST and instructs the opt-in migration instead of seeding.
91
+ whenever it is missing or stale; where a module the project's hook loads is not the bundled one,
92
+ the project's own checker judges the navigator and a write it rejects is put back byte for byte.
93
+ The enforcement-script ensure detects an older ADR-store layout FIRST and instructs the opt-in
94
+ migration instead of seeding.
78
95
 
79
96
  Exit codes: 0 every op fine; 1 an op failed (its line says so) or there is no deployment here; 2 usage.`;
80
97
 
81
98
  // argv → { reconcile, dryRun, cwd, help }. Order-independent; an unknown flag or a missing --cwd
82
99
  // value is a usage error, never a silently-ignored argument.
83
100
  export const parseArgs = (argv) => {
84
- const out = { reconcile: false, dryRun: false, cwd: undefined, only: undefined, help: false };
101
+ const out = { reconcile: false, dryRun: false, cwd: undefined, only: undefined, replaceCustom: undefined, help: false };
85
102
  for (let i = 0; i < argv.length; i += 1) {
86
103
  const a = argv[i];
87
104
  if (a === '--help' || a === '-h') out.help = true;
@@ -98,6 +115,13 @@ export const parseArgs = (argv) => {
98
115
  if (!ENSURE_OPS.includes(value)) throw fail(EXIT_USAGE, unknownOp(value));
99
116
  out.only = value;
100
117
  if (!inline) i += 1;
118
+ } else if (a === '--replace-custom' || a.startsWith('--replace-custom=')) {
119
+ if (out.replaceCustom !== undefined) throw fail(EXIT_USAGE, REPEATED_REPLACE);
120
+ const inline = a.startsWith('--replace-custom=');
121
+ const value = inline ? a.slice('--replace-custom='.length) : argv[i + 1];
122
+ if (value === undefined || value === '' || (!inline && value.startsWith('-'))) throw fail(EXIT_USAGE, EMPTY_REPLACE);
123
+ out.replaceCustom = value;
124
+ if (!inline) i += 1;
101
125
  } else if (a === '--cwd') {
102
126
  // An EMPTY value resolves to the ambient cwd — a writing CLI would then act on a different
103
127
  // project than the caller named, silently. Both spellings refuse it.
@@ -114,25 +138,34 @@ export const parseArgs = (argv) => {
114
138
  if (!out.help && !out.reconcile) {
115
139
  throw fail(EXIT_USAGE, 'nothing to do — pass --reconcile (see --help). This tool never writes without it.');
116
140
  }
141
+ if (out.replaceCustom !== undefined && out.only !== undefined) throw fail(EXIT_USAGE, REPLACE_WITH_ONLY);
142
+ if (out.replaceCustom !== undefined && out.dryRun) throw fail(EXIT_USAGE, REPLACE_WITH_DRY_RUN);
117
143
  return out;
118
144
  };
119
145
 
146
+ const runOne = (op, args) => {
147
+ try {
148
+ return ENSURE_IMPLEMENTATIONS[op](args);
149
+ } catch (err) {
150
+ return failedOutcome(op, err);
151
+ }
152
+ };
153
+
120
154
  // Run every op in ENSURE_OPS order. A throw from one op becomes THAT op's failed outcome — the
121
155
  // remaining ops still run, because a project missing its gate declaration should not also be left
122
- // without its autonomy seed just because the first ensure hit an unreadable file.
123
- export const runEnsures = ({ cwd, kitRoot, dryRun, deps, only }) =>
124
- (only ? [only] : ENSURE_OPS).map((op) => {
125
- try {
126
- return ENSURE_IMPLEMENTATIONS[op]({ cwd, kitRoot, dryRun, deps });
127
- } catch (err) {
128
- return failedOutcome(op, err);
129
- }
130
- });
131
-
132
- const render = (outcomes, dryRun, only) => {
156
+ // without its autonomy seed just because the first ensure hit an unreadable file. The replace run is
157
+ // the one exception: index runs only after a specs op that did not fail, and otherwise says so.
158
+ export const runEnsures = ({ cwd, kitRoot, dryRun, deps, only, replaceCustom }) => {
159
+ if (replaceCustom === undefined) return (only ? [only] : ENSURE_OPS).map((op) => runOne(op, { cwd, kitRoot, dryRun, deps }));
160
+ const specs = runOne('specs', { cwd, kitRoot, dryRun: false, deps, replaceCustom });
161
+ if (specs.failed) return [{ ...specs, lines: [...specs.lines, INDEX_NOT_RUN] }];
162
+ return [specs, runOne('index', { cwd, kitRoot, dryRun: false, deps })];
163
+ };
164
+
165
+ const render = (outcomes, dryRun, only, replace) => {
133
166
  // The banner names the tool + the flag it ran under (both machine tokens the L2 rule exempts);
134
167
  // the failure footer is a user-grade sentence — the composed-lines guard scans both.
135
- const scope = only ? `, --only ${only}` : '';
168
+ const scope = only ? `, --only ${only}` : replace ? ', --replace-custom' : '';
136
169
  const lines = [dryRun ? `ensure-configs (--reconcile${scope}, dry run — nothing written)` : `ensure-configs (--reconcile${scope})`];
137
170
  for (const o of outcomes) {
138
171
  lines.push(` ${o.op}: ${o.token}`);
@@ -155,10 +188,10 @@ export const main = (argv = [], ctx = {}) => {
155
188
  // ONE deployment gate for the whole run (see the header): with no docs/ai there is nothing to
156
189
  // reconcile, and one identical STOP per op would read as that many separate problems.
157
190
  assertDocsAiDeployment(cwd, deps, { noun: 'the project configuration', rel: 'under docs/ai' });
158
- const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps, only: args.only });
191
+ const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps, only: args.only, replaceCustom: args.replaceCustom });
159
192
  return {
160
193
  code: outcomes.some((o) => o.failed) ? EXIT_FAILED : EXIT_OK,
161
- stdout: render(outcomes, args.dryRun, args.only),
194
+ stdout: render(outcomes, args.dryRun, args.only, args.replaceCustom !== undefined),
162
195
  stderr: '',
163
196
  };
164
197
  } catch (err) {
@@ -24,7 +24,7 @@
24
24
  //
25
25
  // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
26
26
 
27
- import { readFileSync, lstatSync } from 'node:fs';
27
+ import { readFileSync, lstatSync, unlinkSync } from 'node:fs';
28
28
  import { spawnSync } from 'node:child_process';
29
29
  import { join } from 'node:path';
30
30
  import { CANON_README, CONFIG_REL, SEED_CONFIG, loadConfig, normalizeCanonical, refreshReadme } from './orchestration-config.mjs';
@@ -36,6 +36,8 @@ import { VEHICLES_REL } from './vehicle-settings.mjs';
36
36
  import { surveyAdrLayoutStrict } from './family-registry.mjs';
37
37
  import { ENSURE_TOKENS, FAILURE_CAUSES, SEED_SCRIPTS } from './ensure-vocabulary.mjs';
38
38
  import { NODE_EVIDENCE, describeNodeProbes, probeNodeEvidence } from './node-evidence.mjs';
39
+ import { classifyDeployedScript } from './script-priors.mjs';
40
+ import { parseFrontmatter } from '../references/scripts/check-docs-size.mjs';
39
41
 
40
42
  // The closed vocabulary lives in its own PURE leaf so the read-only doc-parity lint can bind the
41
43
  // relayed token set without importing this module's writer graph. Re-exported here because every
@@ -297,11 +299,12 @@ export const ensureScripts = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
297
299
 
298
300
  // ── docs/ai/index.md — the GENERATED navigator, regenerated when missing or stale ───────────────
299
301
 
300
- // The only ensure whose target is generated rather than authored: there is nothing to preserve, and
301
- // nothing to seed from either — the bundled generator IS the writer, driven through its idempotent
302
+ // The only ensure whose target is generated rather than authored: nothing to seed from, and on the
303
+ // bundled path nothing to preserve — the bundled generator IS the writer, driven through its idempotent
302
304
  // finalizer mode. Sync-over-async by the same precedent the ADR rotator uses (spawnSync the CLI):
303
305
  // the ensure framework is synchronous, and a second index implementation here would be the drift
304
- // the one-generator rule exists to prevent.
306
+ // the one-generator rule exists to prevent. Under a hook module that is not the bundled one, the
307
+ // project's own checker judges the navigator and a write it rejects is put back (spec docs-navigator).
305
308
  const INDEX_REL = 'docs/ai/index.md';
306
309
  const GENERATOR_PATH = ['references', 'scripts', 'check-docs-size.mjs'];
307
310
  const ENSURE_INDEX_OUTCOME = /^ensure-index: (regenerated|already-current)\b/m;
@@ -317,9 +320,90 @@ const MAY_HAVE_WRITTEN = 'the navigator may already have been written — re-rea
317
320
 
318
321
  const describeExit = (result) => (result.signal ? `on signal ${result.signal}` : `with code ${result.status}`);
319
322
  const bothStreams = (result) => `${String(result.stdout ?? '')}${String(result.stderr ?? '')}`.trim();
323
+ const describeExitWithOutput = (result, subject = 'it') => `${subject} exited ${describeExit(result)}${bothStreams(result) ? `: ${bothStreams(result)}` : ''}`;
324
+
325
+ const HOOK_MODULES = ['check-docs-size.mjs', 'spec-schema.mjs'];
326
+ const KEPT_JUDGE = `the project's own checker (${SCRIPTS_DIR}/check-docs-size.mjs --check-index)`;
327
+ const KEPT_FITS = `in sync under ${KEPT_JUDGE} and within the maxLines it stamps`;
328
+ const KEPT_PROBE_TIMEOUT_MS = 30_000;
329
+ const countLines = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
330
+ const readStampedCap = (text) => { const value = parseFrontmatter(text)?.maxLines; const cap = value ? Number(value) : NaN; return Number.isNaN(cap) ? undefined : cap; };
331
+
332
+ const surveyHookModule = (cwd, kitRoot, name, read, lstat) => {
333
+ const rel = `${SCRIPTS_DIR}/${name}`;
334
+ const probe = (() => { try { return probeSeedTarget(join(cwd, rel), lstat); } catch (err) { if (err?.code === 'ENOTDIR') return { present: false }; throw err; } })();
335
+ if (probe.wrongKind || !probe.present) return { rel, state: probe.wrongKind ? 'wrong-kind' : 'absent', wrongKind: probe.wrongKind };
336
+ const deployed = read(join(cwd, rel));
337
+ try {
338
+ return { rel, state: classifyDeployedScript(deployed, name, read(join(kitRoot, ...GENERATOR_PATH.slice(0, 2), name))) };
339
+ } catch (err) {
340
+ return { rel, state: 'bundle-unreadable', error: causeOf(err) };
341
+ }
342
+ };
343
+
344
+ const judgeKept = ({ cwd, spawn, read, lstat, deps }) => {
345
+ const result = spawn(process.execPath, [join(cwd, SCRIPTS_DIR, 'check-docs-size.mjs'), '--check-index'], { cwd, encoding: 'utf8', timeout: deps.keptProbeTimeoutMs ?? KEPT_PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' });
346
+ if (result.error) return { error: `${causeOf(result.error)}${result.signal ? `, killed on signal ${result.signal}` : ''}` };
347
+ if (result.status !== 0) return { accepted: false, said: describeExitWithOutput(result), half: 'its index check' };
348
+ const text = lstatNoFollow(join(cwd, INDEX_REL), lstat)?.isFile() ? String(read(join(cwd, INDEX_REL), 'utf8')) : null;
349
+ const cap = text === null ? undefined : readStampedCap(text);
350
+ if (cap !== undefined && countLines(text) <= Number(cap)) return { accepted: true };
351
+ const why = text === null ? 'no navigator file stands' : cap === undefined ? 'the navigator stamps no maxLines' : `${countLines(text)} lines against the maxLines ${cap} it stamps`;
352
+ return { accepted: false, said: describeExitWithOutput(result), half: `the cap it stamps (${why})` };
353
+ };
354
+
355
+ const removeIndexIfPresent = (cwd, deps) => {
356
+ try {
357
+ (deps.unlink ?? unlinkSync)(join(cwd, INDEX_REL));
358
+ } catch (err) {
359
+ if (err?.code !== 'ENOENT') throw err;
360
+ }
361
+ };
362
+
363
+ const putBackIndex = (cwd, found, deps) => {
364
+ try {
365
+ if (found !== null) writeDocsAiFileAtomic(cwd, INDEX_REL, found, deps, { noun: 'the navigator' });
366
+ else removeIndexIfPresent(cwd, deps);
367
+ } catch (err) {
368
+ return causeOf(err);
369
+ }
370
+ return null;
371
+ };
372
+
373
+ const ensureKeptIndex = ({ cwd, kitRoot, drive, spawn, read, lstat, deps, hook }) => {
374
+ const judge = () => judgeKept({ cwd, spawn, read, lstat, deps });
375
+ const reportUnanswered = (verdict, after) => loud('index', 'index-probe-failed', `${INDEX_REL}: ${KEPT_JUDGE} could not be launched or did not answer in time (${verdict.error}) — ${after}`);
376
+ const first = judge();
377
+ if (first.error) return reportUnanswered(first, 'nothing was written');
378
+ if (first.accepted) return ok('index', 'already-current', `${INDEX_REL}: ${KEPT_FITS} — nothing written`);
379
+ const custom = [...hook, ...['check-docs-size.test.mjs', 'spec-schema.test.mjs'].map((name) => surveyHookModule(cwd, kitRoot, name, read, lstat))].filter((entry) => entry.state === 'custom').map((entry) => entry.rel).sort();
380
+ const route = custom.length > 0 ? `the replace line printed for the spec layer (${custom.join(', ')}), run on the user's yes` : "the spec layer's refresh of the modules the project's hook loads, then this step again";
381
+ const found = lstatNoFollow(join(cwd, INDEX_REL), lstat) === null ? null : read(join(cwd, INDEX_REL));
382
+ const restored = found === null ? 'the navigator was put back: found absent, it was removed again' : 'the navigator was put back byte for byte';
383
+ const restoreThen = (failure, compose) => {
384
+ const refused = putBackIndex(cwd, found, deps);
385
+ return refused === null ? compose() : loud('index', 'write-refused', `${INDEX_REL}: putting the navigator back failed (${refused})${failure ? ` after ${failure}` : ''} — a navigator ${KEPT_JUDGE} rejects may stand; re-read it before re-running`);
386
+ };
387
+ const run = drive('--ensure-index');
388
+ const refusal = String(run.stderr ?? '').match(ENSURE_INDEX_REFUSAL);
389
+ if (run.error || run.status !== 0 || !String(run.stdout ?? '').match(ENSURE_INDEX_OUTCOME)) {
390
+ const cause = run.error ? 'generator-unlaunchable' : run.status === 2 && refusal ? (refusal[1] === 'write-refused' ? 'write-refused' : 'index-probe-failed') : 'generator-failed';
391
+ const what = run.error ? `the bundled generator could not be started (${causeOf(run.error)})` : describeExitWithOutput(run, 'the bundled generator');
392
+ return restoreThen(what, () => loud('index', cause, `${INDEX_REL}: ${what} — ${restored}`));
393
+ }
394
+ let second;
395
+ try { second = judge(); } catch (err) { return restoreThen(`the navigator could not be read back (${causeOf(err)})`, () => loud('index', 'unexpected-error', `${INDEX_REL}: the navigator could not be read back after the bundled write (${causeOf(err)}) — ${restored}`)); }
396
+ if (second.error) return restoreThen(`${KEPT_JUDGE} could not answer (${second.error})`, () => reportUnanswered(second, restored));
397
+ if (second.accepted) return ok('index', 'regenerated', `${INDEX_REL}: written by the bundled generator — ${KEPT_FITS}`);
398
+ return restoreThen(null, () => ok('index', 'customized-preserved', `${INDEX_REL}: ${KEPT_JUDGE} rejected the bundled render on ${second.half} (${second.said}), and the navigator as found did not pass it either (${first.half}) — ${restored}. The route: ${route}`));
399
+ };
400
+
401
+ const buildKeptDryRunNote = (hook) =>
402
+ `a module the project's hook loads (${hook.filter((entry) => entry.state !== 'current').map((entry) => entry.rel).join(', ')}) is not the bundled one: unless the spec-layer step of this run first refreshes every such module, the real run judges the navigator by ${KEPT_JUDGE} and puts back a write it rejects`;
320
403
 
321
404
  export const ensureIndex = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
322
405
  const lstat = deps.lstat ?? lstatSync;
406
+ const read = deps.readFile ?? readFileSync;
323
407
  const spawn = deps.spawnSync ?? spawnSync;
324
408
  const generator = join(kitRoot, ...GENERATOR_PATH);
325
409
  const drive = (mode) => spawn(process.execPath, [generator, mode, `--root=${cwd}`], { encoding: 'utf8' });
@@ -330,14 +414,21 @@ export const ensureIndex = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
330
414
  if (probe.wrongKind) {
331
415
  return loud('index', 'wrong-node-kind', `${INDEX_REL}: exists but is ${probe.wrongKind} — the navigator is a generated file and this is not one; nothing was read or written, resolve it by hand and re-run`);
332
416
  }
333
-
417
+ const hook = HOOK_MODULES.reduce((acc, name) => (acc[0]?.state === 'absent' || acc[0]?.wrongKind ? acc : [...acc, surveyHookModule(cwd, kitRoot, name, read, lstat)]), []);
418
+ const wrong = hook.find((entry) => entry.state === 'wrong-kind');
419
+ if (wrong) return loud('index', 'wrong-node-kind', `${wrong.rel}: exists but is ${wrong.wrongKind} — the project's hook loads it, so nothing was read or written; resolve it by hand and re-run`);
420
+ const kept = hook[0].state !== 'absent' && !hook.every((entry) => entry.state === 'current');
421
+ const unreadable = kept && hook.find((entry) => entry.state === 'bundle-unreadable');
422
+ if (unreadable) return loud('index', 'bundle-unreadable', `${unreadable.rel}: the bundled module could not be read, so nothing was written — reinstall the kit. ${unreadable.error}`);
423
+ if (kept && !dryRun) return ensureKeptIndex({ cwd, kitRoot, drive, spawn, read, lstat, deps, hook });
424
+ const dryNote = kept ? [buildKeptDryRunNote(hook)] : [];
334
425
  if (dryRun) {
335
426
  const check = drive('--check-index');
336
427
  if (check.error) return unlaunchable(check);
337
428
  const out = bothStreams(check);
338
- if (check.status === 0 && PROBE_FRESH.test(out)) return ok('index', 'already-current', `${INDEX_REL}: in sync with the source frontmatter — nothing would be written`);
339
- if (check.status === 1 && PROBE_STALE.test(out)) return ok('index', 'would-regenerate', `${INDEX_REL}: missing or stale — would be written from the source frontmatter`);
340
- return loud('index', 'index-probe-failed', `${INDEX_REL}: the freshness probe answered neither fresh nor stale (exited ${describeExit(check)}), so nothing was written. ${out}`);
429
+ if (check.status === 0 && PROBE_FRESH.test(out)) return ok('index', 'already-current', `${INDEX_REL}: in sync with the source frontmatter — nothing would be written`, ...dryNote);
430
+ if (check.status === 1 && PROBE_STALE.test(out)) return ok('index', 'would-regenerate', `${INDEX_REL}: missing or stale — would be written from the source frontmatter`, ...dryNote);
431
+ return loud('index', 'index-probe-failed', `${INDEX_REL}: the freshness probe answered neither fresh nor stale (exited ${describeExit(check)}), so nothing was written. ${out}`, ...dryNote);
341
432
  }
342
433
 
343
434
  const run = drive('--ensure-index');
@@ -3,8 +3,8 @@
3
3
  // a FIXED order the measured coupling dictates:
4
4
  // 1. the reader pair scripts/spec-schema.mjs + .test.mjs — created when absent; since 2a
5
5
  // also REFRESHED when the deployed bytes are a body a release shipped (script-priors.mjs), a
6
- // custom body preserved verbatim (brief D1 shipped it create-only; the 4.6.x bodies joining the
7
- // catalog is what keeps those deployments out of the stranded-custom cell);
6
+ // custom body preserved verbatim until the user's yes runs the printed replace line
7
+ // (--replace-custom <digest>, spec docs-navigator);
8
8
  // 2. the checker pair scripts/check-docs-size.mjs + .test.mjs — the same prior-refresh lane, and
9
9
  // only behind a reader pair this run leaves byte-current;
10
10
  // 3. the store root docs/ai/specs/index.md — seeded (placeholders rendered)
@@ -17,16 +17,18 @@
17
17
  // The state table (enumerate by PROOF, never by exclusion): every file is classified into exactly one
18
18
  // of current | prior | custom | absent | wrong-kind before anything is written, each write admits
19
19
  // through ONE conjunction of those facts (decideWrites), and every other cell is a stated refusal —
20
- // a custom file of either pair preserves itself AND withholds every write that depends on it. Writes
21
- // run in order and each is idempotent, so a run that stopped partway converges on the next run.
20
+ // a custom file of either pair preserves itself AND withholds every write that depends on it, and the
21
+ // ONE replace line names every custom file; on the yes, a replace run whose digest still matches
22
+ // rewrites them. Writes run in order and each is idempotent, so a stopped run converges on the next.
22
23
  //
23
24
  // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
24
25
 
25
26
  import { readFileSync, lstatSync } from 'node:fs';
26
27
  import { join } from 'node:path';
27
28
  import { writeContainedFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
28
- import { classifyDeployedScript } from './script-priors.mjs';
29
+ import { classifyDeployedScript, digestOf } from './script-priors.mjs';
29
30
  import { composeFailure, composeOutcome, nodeEvidenceRefusal, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
31
+ import { shellQuoteArg } from './repo-lex.mjs';
30
32
 
31
33
  const OP = 'specs';
32
34
  const SCRIPTS_DIR = 'scripts';
@@ -59,9 +61,14 @@ const surveyScript = ({ cwd, kitRoot, name, read, lstat }) => {
59
61
  const probe = probeSeedTarget(join(cwd, SCRIPTS_DIR, name), lstat);
60
62
  if (probe.wrongKind) return { rel, name, state: 'wrong-kind', wrongKind: probe.wrongKind, bundle };
61
63
  if (!probe.present) return { rel, name, state: 'absent', bundle };
62
- return { rel, name, state: classifyDeployedScript(read(join(cwd, SCRIPTS_DIR, name)), name, bundle), bundle };
64
+ const bytes = read(join(cwd, SCRIPTS_DIR, name));
65
+ return { rel, name, state: classifyDeployedScript(bytes, name, bundle), bundle, bytes };
63
66
  };
64
67
 
68
+ // The custom set's digest binds the set AND the bytes: the rel-sorted `<rel> <sha256>` lines, LF-joined.
69
+ export const customSetDigest = (files) =>
70
+ digestOf([...files].sort((a, b) => (a.rel < b.rel ? -1 : 1)).map((f) => `${f.rel} ${digestOf(f.bytes)}`).join('\n'));
71
+
65
72
  const surveyStoreRoot = (cwd, lstat) => {
66
73
  const probe = probeSeedTarget(join(cwd, STORE_ROOT_REL), lstat);
67
74
  if (probe.wrongKind) return { rel: STORE_ROOT_REL, state: 'wrong-kind', wrongKind: probe.wrongKind };
@@ -73,21 +80,25 @@ const surveyStoreRoot = (cwd, lstat) => {
73
80
  // Reader seeds are create-only and always admitted. A REFRESH overwrites bytes, so it runs only
74
81
  // inside a pair whose every file is proven refreshable (current | prior | absent) — one custom file
75
82
  // withholds its whole pair; the checker lane (its seeds included) and the store additionally wait
76
- // for a reader pair this run leaves byte-current (seeded + refreshed = current).
83
+ // for a reader pair this run leaves byte-current (seeded + refreshed = current). Under the user's
84
+ // replace yes a custom file is refreshable too: it is rewritten to the bundled body.
77
85
  const PAIR_ELIGIBLE = new Set(['current', 'prior', 'absent']);
86
+ const REPLACE_ELIGIBLE = new Set([...PAIR_ELIGIBLE, 'custom']);
78
87
 
79
- export const decideWrites = ({ reader, checker, store }) => {
80
- const readerEligible = reader.every((f) => PAIR_ELIGIBLE.has(f.state));
81
- const checkerEligible = readerEligible && checker.every((f) => PAIR_ELIGIBLE.has(f.state));
88
+ export const decideWrites = ({ reader, checker, store }, replace = false) => {
89
+ const eligible = replace ? REPLACE_ELIGIBLE : PAIR_ELIGIBLE;
90
+ const refreshable = replace ? ['prior', 'custom'] : ['prior'];
91
+ const readerEligible = reader.every((f) => eligible.has(f.state));
92
+ const checkerEligible = readerEligible && checker.every((f) => eligible.has(f.state));
82
93
  const writes = [];
83
94
  for (const f of reader) {
84
95
  if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
85
- if (readerEligible && f.state === 'prior') writes.push({ kind: 'refresh', file: f });
96
+ if (readerEligible && refreshable.includes(f.state)) writes.push({ kind: 'refresh', file: f });
86
97
  }
87
98
  if (checkerEligible) {
88
99
  for (const f of checker) {
89
100
  if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
90
- if (f.state === 'prior') writes.push({ kind: 'refresh', file: f });
101
+ if (refreshable.includes(f.state)) writes.push({ kind: 'refresh', file: f });
91
102
  }
92
103
  }
93
104
  if (checkerEligible && store.state === 'absent') writes.push({ kind: 'store' });
@@ -103,7 +114,10 @@ export const decideWrites = ({ reader, checker, store }) => {
103
114
  const LEFT_FOR = { withheld: 'it waits on the pair named above', stopped: 'the run stopped before it' };
104
115
  const SCRIPT_LINES = {
105
116
  current: () => 'already the bundled body — nothing written',
106
- custom: () => "carries a body this kit did not ship as current — preserved verbatim; the writes that depend on it wait until the pair matches the bundled scripts (copy them by hand from the kit's references/scripts/ when convenient, then re-run the upgrade)",
117
+ custom: (fate) => ({
118
+ written: "carried an edited body — replaced by the bundled one on the user's yes",
119
+ stopped: 'carries an edited body — not replaced; the run stopped before it',
120
+ })[fate] ?? "carries a body this kit did not ship as current — preserved verbatim; the writes that depend on it wait until it is replaced on the user's yes (the replace line below)",
107
121
  absent: (fate) => ({
108
122
  written: 'copied from the bundled scripts',
109
123
  stood: 'appeared while this run was seeding it — the bundled body stands',
@@ -120,10 +134,13 @@ const STORE_LINES = {
120
134
  written: "created from the bundled template (today's date rendered)",
121
135
  stood: 'appeared while this run was seeding it — the existing file stands',
122
136
  would: 'absent — would be created from the bundled template',
123
- withheld: "not seeded — an older or edited checker renders the spec store row by row and reds the hook's index check, so the store root waits for a current checker pair",
137
+ withheld: (custom) => `not seeded — it waits on the edited ${custom.join(', ')} (a body this kit did not ship): an older or edited checker renders the spec store row by row and reds the hook's index check, so the store root waits for a current checker pair`,
124
138
  stopped: 'absent — not created; the run stopped before it',
125
139
  };
126
- const storeLine = (store, fate) => `${STORE_ROOT_REL}: ${STORE_LINES[store.state === 'present' ? 'present' : fate]}`;
140
+ const storeLine = (store, fate, custom) => {
141
+ const line = STORE_LINES[store.state === 'present' ? 'present' : fate];
142
+ return `${STORE_ROOT_REL}: ${typeof line === 'function' ? line(custom) : line}`;
143
+ };
127
144
  const relOf = (write) => (write.kind === 'store' ? STORE_ROOT_REL : write.file.rel);
128
145
 
129
146
  // The fate of every path for the lines: a write that ran carries its result, an admitted write the
@@ -138,10 +155,17 @@ const composeLines = ({ reader, checker, store, writes, fates, withheld, stopped
138
155
  const fate = (rel) => fateOf(rel, { writes, fates, withheld, stopped });
139
156
  const lines = [...reader, ...checker].map((f) => scriptLine(f, fate(f.rel)));
140
157
  const storeFate = fate(STORE_ROOT_REL);
141
- if (store.state === 'present' || storeFate !== 'none') lines.push(storeLine(store, storeFate));
158
+ const custom = [...reader, ...checker].filter((f) => f.state === 'custom').map((f) => f.rel);
159
+ if (store.state === 'present' || storeFate !== 'none') lines.push(storeLine(store, storeFate, custom));
142
160
  return lines;
143
161
  };
144
162
 
163
+ // The ONE replace line: the exact command, its condition first, both paths shell-quoted.
164
+ const replaceLineFor = (custom, kitRoot, cwd) => (custom.length === 0 ? [] : [
165
+ `to replace the edited files named above, run this only on the user's yes — the bundled bodies overwrite their edits: node ${shellQuoteArg(join(kitRoot, 'tools', 'ensure-configs.mjs'))} --reconcile --replace-custom ${customSetDigest(custom)} --cwd ${shellQuoteArg(cwd)}`,
166
+ ]);
167
+ const RACE_LINE = 'the edited files changed since the replace line was printed (another set or other bytes), so nothing was written — ask the user again with the fresh replace line below';
168
+
145
169
  // A per-file loop can stop partway; the run says which files already landed — never "nothing happened".
146
170
  const partialNote = (count) =>
147
171
  (count > 0 ? [`the writes stopped PARTWAY — the ${count} file(s) named above were already written and are NOT rolled back`] : []);
@@ -156,7 +180,7 @@ const renderStoreRoot = (kitRoot, read, today) => {
156
180
  return body;
157
181
  };
158
182
 
159
- export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
183
+ export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {}, replaceCustom }) => {
160
184
  const lstat = deps.lstat ?? lstatSync;
161
185
  const read = deps.readFile ?? readFileSync;
162
186
  const refusal = nodeEvidenceRefusal(OP, cwd, lstat);
@@ -172,7 +196,15 @@ export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
172
196
  if (f.state === 'wrong-kind') return loud('wrong-node-kind', `${f.rel}: exists but is ${f.wrongKind} — nothing was read or written; resolve it by hand, then re-run`);
173
197
  }
174
198
 
175
- const { writes, withheld } = decideWrites({ reader, checker, store });
199
+ const custom = [...reader, ...checker].filter((f) => f.state === 'custom');
200
+ const replace = replaceCustom !== undefined && custom.length > 0;
201
+ const replaceLine = replaceLineFor(custom, kitRoot, cwd);
202
+ if (replace && replaceCustom !== customSetDigest(custom)) {
203
+ const asFound = decideWrites({ reader, checker, store });
204
+ return loud('race-unresolved', RACE_LINE, ...composeLines({ reader, checker, store, ...asFound, fates: new Map(), stopped: true }), ...replaceLine);
205
+ }
206
+ const { writes, withheld } = decideWrites({ reader, checker, store }, replace);
207
+ const tail = replace ? [] : replaceLine;
176
208
  const kinds = new Set(writes.map((w) => w.kind));
177
209
  // ONE token by precedence — a write that happened (or, dry, would happen) always outranks the
178
210
  // preserved-custom report, which names a run that wrote nothing behind an edited pair.
@@ -184,14 +216,14 @@ export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
184
216
  const fates = new Map();
185
217
  const linesNow = (stopped) => composeLines({ reader, checker, store, writes, fates, withheld, stopped });
186
218
  const writtenCount = () => [...fates.values()].filter((fate) => fate === 'written').length;
187
- if (dryRun) return ok(tokenFor(kinds.has('seed') || kinds.has('store'), kinds.has('refresh')), linesNow(false));
219
+ if (dryRun) return ok(tokenFor(kinds.has('seed') || kinds.has('store'), kinds.has('refresh')), [...linesNow(false), ...tail]);
188
220
 
189
221
  let storeBody = null;
190
222
  if (kinds.has('store')) {
191
223
  try {
192
224
  storeBody = renderStoreRoot(kitRoot, read, deps.today ?? todayIso());
193
225
  } catch (err) {
194
- return loud('template-unreadable', `${STORE_ROOT_REL}: ${causeOf(err)}`, ...linesNow(true));
226
+ return loud('template-unreadable', `${STORE_ROOT_REL}: ${causeOf(err)}`, ...linesNow(true), ...tail);
195
227
  }
196
228
  }
197
229
  const writeOne = ({ kind, file }) => {
@@ -209,14 +241,14 @@ export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
209
241
  try {
210
242
  result = writeOne(write);
211
243
  } catch (err) {
212
- return loud('write-refused', `${rel}: ${causeOf(err)}`, ...linesNow(true), ...notes, ...partialNote(writtenCount()));
244
+ return loud('write-refused', `${rel}: ${causeOf(err)}`, ...linesNow(true), ...notes, ...partialNote(writtenCount()), ...tail);
213
245
  }
214
246
  if (write.kind !== 'refresh' && !result.created && !stoodCurrent(write)) {
215
- return loud('race-unresolved', `${rel}: appeared while this run was seeding it and does not carry the bundled body — something is writing there underneath this run; nothing further written, re-run when the tree is settled`, ...linesNow(true), ...notes, ...partialNote(writtenCount()));
247
+ return loud('race-unresolved', `${rel}: appeared while this run was seeding it and does not carry the bundled body — something is writing there underneath this run; nothing further written, re-run when the tree is settled`, ...linesNow(true), ...notes, ...partialNote(writtenCount()), ...tail);
216
248
  }
217
249
  fates.set(rel, write.kind !== 'refresh' && !result.created ? 'stood' : 'written');
218
250
  notes.push(...tmpNote(rel, result.tmpLeftBehind));
219
251
  }
220
252
  const landed = (kind) => writes.some((w) => w.kind === kind && fates.get(relOf(w)) === 'written');
221
- return ok(tokenFor(landed('seed') || landed('store'), landed('refresh')), [...linesNow(false), ...notes]);
253
+ return ok(tokenFor(landed('seed') || landed('store'), landed('refresh')), [...linesNow(false), ...notes, ...tail]);
222
254
  };
@@ -16,7 +16,8 @@ export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', '
16
16
  // Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
17
17
  // walks this set), and each has exactly one `would-` counterpart below. `refreshed` is the spec-layer
18
18
  // ensure's pair refresh (reader or checker) — a deployed script on a body a release shipped,
19
- // rewritten to the bundled one (a custom body is never refreshed).
19
+ // rewritten to the bundled one; a custom body is rewritten only by the replace run the user's yes
20
+ // starts (--replace-custom), never by a plain run.
20
21
  // `re-derived` is the executor ensure rewriting a placed executor whose bytes differ from the body derived from docs/ai/vehicles.json.
21
22
  export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated', 're-derived']);
22
23
  export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate', 'would-re-derive']);
@@ -1,8 +1,8 @@
1
1
  // script-priors.mjs — the APPEND-ONLY catalog of every enforcement-script body this family has shipped
2
2
  // into a project's `scripts/`, as sha256 digests, and the ONE classifier the spec-layer ensure asks
3
3
  // before it refreshes a deployed script: `current` (byte-equal to the bundled copy) · `prior` (a body
4
- // a release shipped, never edited since) · `custom` (anything else — preserved verbatim, never
5
- // overwritten). The lens-region idiom (AD-041) over whole script bodies.
4
+ // a release shipped, never edited since) · `custom` (anything else — preserved verbatim, replaced only by
5
+ // the replace run the user's yes starts). The lens-region idiom (AD-041) over whole script bodies.
6
6
  //
7
7
  // Append discipline: when a release changes one of PRIOR_FILES, append the OUTGOING body's row here
8
8
  // in the same release and copy its bytes to test/fixtures/script-priors/<firstShipped>/<file>.txt —
@@ -36,6 +36,7 @@ export const SCRIPT_PRIORS = Object.freeze([
36
36
  prior('check-docs-size.mjs', '4.6.0', '6.0.0', '22d020c3668cdbfb4cbc1f67a8a85b2baa2d0c4956808a50626d37409e81ab38'),
37
37
  prior('spec-schema.mjs', '5.0.0', '7.2.1', '6a7d1e836c158baab5a412a1ad76d34891934dc28c88486c264a4b5d46f5892a'),
38
38
  prior('spec-schema.test.mjs', '5.0.0', '7.2.1', '698564d7283281be1826019d214df475bb876687915de086818d8f5ece0e0336'),
39
+ prior('check-docs-size.mjs', '7.0.0', '8.1.5', '0cf90ae2cbfc809847574a8c71a6b1322c783359740b08e0ff179f0d9d04dffc'),
39
40
  ]);
40
41
 
41
42
  export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
@@ -34,7 +34,7 @@ export const UPGRADE_RUNLIST = Object.freeze([
34
34
  entry(
35
35
  'configs',
36
36
  'node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>',
37
- null,
37
+ 'a specs replace line → ONE yes naming every file the custom lines name and saying their edits are overwritten, asked even on a non-zero exit; on the yes run the printed line, then STOP if that run fails or an op it does not re-run failed; on a no STOP on any failed op. The printed line: node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --replace-custom <digest> --cwd <project>',
38
38
  [
39
39
  'seeded',
40
40
  'note-refreshed',