@sabaiway/agent-workflow-kit 14.29.1 → 14.31.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.
@@ -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
@@ -15,6 +15,7 @@ import { KIT_GROUNDING_TOOL, bundledSandboxRecipe, usedBridges } from './bridge-
15
15
  import { mergeAllowWrite, stateDirsOf } from './bridge-state-dirs.mjs';
16
16
  import { loadConfig } from './orchestration-config.mjs';
17
17
  import { HOOK_FILE_REL, HOOKS_DIR, buildHookSettingsEntry, mergeHookEntry, writeGateHook } from './gate-hook.mjs';
18
+ import { STATE_BLOCK_HOOK_FILE_REL, buildStopHookEntry, mergeStopEntry, writeStateBlockHook } from './state-block-hook.mjs';
18
19
  import { writeMcp } from './mcp.mjs';
19
20
  import { ENABLED_KEY, MCP_JSON_REL, SERVERS_KEY, SERVER_NAME, allowRulesFor, buildServerEntry } from './mcp-registration.mjs';
20
21
  import { AGENTS_DIR, writeCheapAgents } from './cheap-agents.mjs';
@@ -28,10 +29,14 @@ import { dirCovers as covers } from './declared-paths.mjs';
28
29
  const TOOLS = dirname(fileURLToPath(import.meta.url));
29
30
  const SKILL_VARIANTS = ['jev-skill', 'jev-skill.earlier'];
30
31
  const VELOCITY_VARIANTS = ['velocity-core', 'kit-tools-tier', 'bridge-tier'];
31
- const HOOK_VARIANTS = ['gate-hook', 'read-lane.missing'];
32
+ const RESEED_VARIANTS = ['gate-hook.marker-stale', 'read-lane.stale'];
33
+ const HOOK_VARIANTS = ['gate-hook', 'read-lane.missing', ...RESEED_VARIANTS];
34
+ const STOP = 'state-block';
32
35
  const MCP_VARIANTS = ['mcp-channel', 'mcp-channel.differing'];
33
36
  const AGENT_VARIANTS = ['agents', 'executor-vehicle'];
34
- const VARIANTS = [...VELOCITY_VARIANTS, 'autonomy-render', ...HOOK_VARIANTS, ...MCP_VARIANTS, ...AGENT_VARIANTS, ...SKILL_VARIANTS];
37
+ const VARIANTS = [...VELOCITY_VARIANTS, 'autonomy-render', ...HOOK_VARIANTS, STOP, ...MCP_VARIANTS, ...AGENT_VARIANTS,
38
+ ...SKILL_VARIANTS];
39
+ const HOOK_KEYS = ['hooks.PreToolUse', 'hooks.Stop'];
35
40
  const CREDENTIALS = 'sandbox.credentials.envVars';
36
41
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
37
42
  const stable = (value) => object(value)
@@ -56,6 +61,7 @@ export const KEY_TABLE = Object.freeze({
56
61
  'permissions.deny': 3,
57
62
  'sandbox.enabled': 3,
58
63
  'hooks.PreToolUse': 4,
64
+ 'hooks.Stop': 4,
59
65
  [ENABLED_KEY]: 4,
60
66
  [SERVERS_KEY]: 4,
61
67
  [CREDENTIALS]: 5,
@@ -67,7 +73,7 @@ const valid = (key, value, element = false) => {
67
73
  if (SCALARS.includes(key)) return typeof value === 'boolean';
68
74
  if (!element) return Array.isArray(value) && value.every((entry) => valid(key, entry, true));
69
75
  if (key === CREDENTIALS) return object(value) && typeof value.name === 'string' && typeof value.mode === 'string';
70
- return key === 'hooks.PreToolUse' ? object(value) : typeof value === 'string';
76
+ return HOOK_KEYS.includes(key) ? object(value) : typeof value === 'string';
71
77
  };
72
78
  const keyOf = (key) => key.startsWith(`${SERVERS_KEY}.`) ? SERVERS_KEY : key;
73
79
 
@@ -174,6 +180,7 @@ export const scopeOf = (variant, facts, deps = {}) => {
174
180
  set('permissions.deny', RENDER_OWNED_REDLINE_RULES);
175
181
  allowed[CREDENTIALS] = (value) => value.mode === 'deny';
176
182
  } else if (HOOK_VARIANTS.includes(variant)) set('hooks.PreToolUse', [buildHookSettingsEntry()]);
183
+ else if (variant === STOP) set('hooks.Stop', [buildStopHookEntry()]);
177
184
  else if (MCP_VARIANTS.includes(variant)) {
178
185
  set(`${SERVERS_KEY}.${SERVER_NAME}`, [buildServerEntry(join(facts.toolsDir, 'mcp-server.mjs'))]);
179
186
  set(ENABLED_KEY, [SERVER_NAME]);
@@ -181,11 +188,10 @@ export const scopeOf = (variant, facts, deps = {}) => {
181
188
  }
182
189
  const targets = SKILL_VARIANTS.includes(variant) ? skillTargets({ home: facts.home,
183
190
  claudeDir: facts.claudeDir ?? claudeDirOf(facts.env, facts.home, facts.platform).dir }) : [];
184
- const filePaths = HOOK_VARIANTS.includes(variant) ? [join(facts.root, HOOK_FILE_REL)] : [];
185
- const directories = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render'
186
- || HOOK_VARIANTS.includes(variant) || MCP_VARIANTS.includes(variant) || AGENT_VARIANTS.includes(variant)
187
- ? [join(facts.root, CLAUDE_DIR)] : [];
188
- if (HOOK_VARIANTS.includes(variant)) directories.push(join(facts.root, HOOKS_DIR));
191
+ const filePaths = HOOK_VARIANTS.includes(variant) ? [join(facts.root, HOOK_FILE_REL)]
192
+ : variant === STOP ? [join(facts.root, STATE_BLOCK_HOOK_FILE_REL)] : [];
193
+ const directories = SKILL_VARIANTS.includes(variant) ? [] : [join(facts.root, CLAUDE_DIR)];
194
+ if (HOOK_VARIANTS.includes(variant) || variant === STOP) directories.push(join(facts.root, HOOKS_DIR));
189
195
  if (AGENT_VARIANTS.includes(variant)) directories.push(join(facts.root, AGENTS_DIR));
190
196
  for (const target of targets) {
191
197
  for (let at = target.root; at !== target.base && covers(target.base, at); at = dirname(at)) directories.push(at);
@@ -194,7 +200,7 @@ export const scopeOf = (variant, facts, deps = {}) => {
194
200
  return {
195
201
  admits: ({ path, key, change, value }) => {
196
202
  const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL : path === SETTINGS_FILE;
197
- const gains = variant === 'bridge-tier' || key === 'hooks.PreToolUse' || key === ENABLED_KEY || key === CREDENTIALS
203
+ const gains = variant === 'bridge-tier' || HOOK_KEYS.includes(key) || key === ENABLED_KEY || key === CREDENTIALS
198
204
  || (VELOCITY_VARIANTS.includes(variant) && key === 'permissions.ask');
199
205
  return correctFile && (!gains || change === 'added') && Boolean(allowed[key]?.(value));
200
206
  },
@@ -207,7 +213,7 @@ const jsonAt = (path) => existsSync(path) ? JSON.parse(readFileSync(path, 'utf8'
207
213
  const halted = (lines) => Object.assign(new Error(lines.join('\n')), { code: 'CHECK_HALTED', lines });
208
214
  const maskedFiles = (variant, facts) => {
209
215
  const paths = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render' || HOOK_VARIANTS.includes(variant)
210
- ? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
216
+ || variant === STOP ? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
211
217
  return paths.flatMap((path) => lstatSync(join(facts.root, path), { throwIfNoEntry: false })?.isCharacterDevice()
212
218
  ? [`masked: ${path}`] : []);
213
219
  };
@@ -243,9 +249,17 @@ export const plannedChange = (variant, facts, deps = {}) => {
243
249
  const before = jsonAt(join(facts.root, SETTINGS_FILE));
244
250
  addFile(SETTINGS_FILE, before, mergeAutonomySettings(before, render));
245
251
  } else if (HOOK_VARIANTS.includes(variant)) {
246
- const plan = writeGateHook({ cwd: facts.root, dryRun: true }, writerDeps);
252
+ const plan = writeGateHook({ cwd: facts.root, dryRun: true, reseed: RESEED_VARIANTS.includes(variant) }, writerDeps);
247
253
  if (plan.wirePlanned) addFile(SETTINGS_FILE, plan.projectSettings.data, mergeHookEntry(plan.projectSettings.data));
248
- if (plan.placePlanned) writes.push({ path: join(facts.root, HOOK_FILE_REL), bytes: plan.bundleContent });
254
+ const path = join(facts.root, HOOK_FILE_REL);
255
+ if (plan.placePlanned) writes.push({ path, bytes: plan.bundleContent });
256
+ if (plan.reseedPlanned) writes.push({ path, bytes: plan.bundleContent, replaces: readFileSync(path) });
257
+ } else if (variant === STOP) {
258
+ const plan = writeStateBlockHook({ cwd: facts.root, dryRun: true }, writerDeps);
259
+ if (plan.mergePlanned) addFile(SETTINGS_FILE, plan.projectSettings.data, mergeStopEntry(plan.projectSettings.data));
260
+ const path = join(facts.root, STATE_BLOCK_HOOK_FILE_REL);
261
+ if (plan.placement === 'create') writes.push({ path, bytes: plan.bundleContent });
262
+ if (plan.placement === 'replace') writes.push({ path, bytes: plan.bundleContent, replaces: readFileSync(path) });
249
263
  } else if (MCP_VARIANTS.includes(variant)) {
250
264
  const result = writeMcp({ cwd: facts.root, dryRun: true, replace: variant === 'mcp-channel.differing' }, writerDeps);
251
265
  const { plan, registration } = result;
@@ -298,7 +312,10 @@ export const checkApply = (variant, facts, deps = {}) => {
298
312
  if (!scope.admitsPath(path, directory)) refused.push({ path: display(path), key: display(path),
299
313
  reason: `beyond the declared scope of ${variant}` });
300
314
  };
301
- for (const write of plan.writes) pathLine(write.path, false, `writes: ${display(write.path)} sha256:${sha(write.bytes)}`);
315
+ for (const write of plan.writes) {
316
+ const replaces = write.replaces === undefined ? '' : ` — replaces sha256:${sha(write.replaces)}`;
317
+ pathLine(write.path, false, `writes: ${display(write.path)} sha256:${sha(write.bytes)}${replaces}`);
318
+ }
302
319
  for (const path of plan.directories) pathLine(path, true, `writes: ${display(path)}/ created`);
303
320
  lines.push(...plan.temporaries);
304
321
  if (refused.length) {
@@ -165,8 +165,8 @@ const CATALOG = [
165
165
  key: 'state-block-guard',
166
166
  invocation: invocationOf('state-block-guard'),
167
167
  group: 'Configure',
168
- kind: READ_ONLY,
169
- oneLine: 'Warn when a turn ends on a defective closing state block — a "nothing needed from you" answer or a promise of work that never started (Claude Code Stop hook; detection, never prevention; you paste the wiring).',
168
+ kind: WRITER,
169
+ oneLine: 'Warn when a turn ends on a defective closing state block — a "nothing needed from you" answer or a promise of work that never started (Claude Code Stop hook; detection, never prevention; its writer places and wires it on your chat yes).',
170
170
  },
171
171
  {
172
172
  key: 'bridge-settings',
@@ -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) {