@sabaiway/agent-workflow-kit 14.31.0 → 14.32.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,44 @@ 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.32.0 — the git-hook items get a writer: the enforcement scripts and the commit guard are applied on the chat yes, and the project's own installer decides the hook (AD-184)
8
+
9
+ **Two `/agent-workflow-kit upgrade` items that used to hand the user a shell line are now harness items the agent
10
+ applies on the chat yes, after the danger check: `enforcement` (the bundled scripts and their pre-commit hook) and
11
+ `commit-guard` (the guard line in that hook).** Before, `enforcement` printed `HAND-APPLY: mkdir -p <root>/scripts &&
12
+ cp <kit>/references/scripts/*.mjs <root>/scripts/ && node <root>/scripts/install-git-hooks.mjs`, and `commit-guard` was
13
+ the user's own step, its skips saying "merge the guard line by hand" or pointing at "bootstrap steps 8–9".
14
+
15
+ - **`tools/git-hooks.mjs`, the writer.** `node <kit>/tools/git-hooks.mjs --enforcement --cwd <root>` (or
16
+ `--commit-guard`) previews; `--apply` writes. For `enforcement`, where no runnable enforcement script is deployed
17
+ yet, it copies the 25 bundled `references/scripts/*.mjs` (their tests included) into `scripts/` create-only — a file
18
+ already there is kept, nothing is written through a link — then runs the project's `scripts/install-git-hooks.mjs`;
19
+ once one is deployed, and for `commit-guard`, it copies nothing and runs that installer (`commit-guard` with
20
+ `--commit-guard <kit>/tools/commit-guard.mjs`). The installer is unchanged and decides the hook, as before. When the
21
+ installer exits non-zero, the writer removes the files this run created (naming any it finds it cannot) and exits 1
22
+ after the installer's output; when a copy finds a file standing at its path, that file is kept, the earlier copies
23
+ are removed, nothing runs, and it exits 1. Exits: 0 the preview, `--help` or a completed apply, 1 the apply
24
+ did not complete, 2 usage, 3 refused by the dry run (no installer where nothing places one, another node kind at
25
+ `scripts/`, a copy path or the installer, or a path it cannot read).
26
+ - **The danger check covers the two.** Its result lists `writes: scripts/<name> sha256:<hex>` for each script the kit
27
+ creates (`writes: scripts/ created` when the folder is new) and a new line form, `runs: node
28
+ scripts/install-git-hooks.mjs sha256:<hex>` (for `commit-guard`, `--commit-guard <kit>/tools/commit-guard.mjs` before
29
+ the digest), naming the installer it will run and that installer's bytes. The yes is bound to both, so an installer
30
+ edited after the yes stops the apply. The check reads no settings key, runs no git and names no hook: what the
31
+ installer writes is shown in its own output at the apply. `init` previews and applies the two on its y.
32
+ - **One reader of the pre-commit hook.** Over a pre-commit hook carrying no installer marker, neither item is offered;
33
+ where either would otherwise be offered, its skip names your own step: moving the hook aside (`enforcement`) or
34
+ adding the guard line to it (`commit-guard`). Where `commit-guard` finds no installer, its skip names the
35
+ `enforcement` item when that item is offered in the same run, and your own step otherwise.
36
+ - **Bootstrap steps 8-9 take the same route.** Step 8 runs the `enforcement` check line, asks once on its result and
37
+ applies it; step 9 no longer runs the installer or asks you to merge a hook by hand, and in visible mode wires the
38
+ `package.json` scripts only when step 8's apply exited 0. The `--commit-guard` arm is the advisor's `commit-guard`
39
+ item, applied the same way.
40
+ - **Shipped text.** The `enforcement` note of `recommendations.md` and `upgrade.md`'s hook-refresh sentence name the
41
+ new route; the lane paragraph's `HAND-APPLY` example is now `source-size.id-squatter`; `contracts.md` wires the
42
+ `package.json` scripts only when the enforcement scripts are in place; `deploy-tail.md` leaves a non-marker hook to
43
+ the user; no apply slot of the two says `HAND-APPLY`.
44
+
7
45
  ## 14.31.0 — the upgrade's Claude Code hook items get writers: a stale gate hook is reseeded in place and the state-block Stop hook is placed and wired, each on the chat yes (AD-183)
8
46
 
9
47
  **Three `/agent-workflow-kit upgrade` items that used to hand the user a hook to fix by hand are now harness items the
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.31.0'
6
+ version: '14.32.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.31.0",
6
+ "version": "14.32.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.31.0",
3
+ "version": "14.32.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 @@ The user chooses at bootstrap whether the AI artifacts are visible in the repo o
17
17
  **explicit up-front question** (bootstrap step 2), never an assumed default. The two modes then
18
18
  diverge:
19
19
 
20
- - **visible** — artifacts are committed. When a `package.json` exists and the enforcement offer was accepted, wire the project's `package.json` scripts (`docs:check` / `docs:index` / `docs:index:check` / `docs:archive` / `docs:archive:check` / `docs:archive:issues` / `docs:archive:issues:check` / `prepare: node scripts/install-git-hooks.mjs`); add a minimal `.gitignore` (`docs/plans/`, `.claude/settings.local.json`) either way. This is the canonical model.
20
+ - **visible** — artifacts are committed. When a `package.json` exists and the enforcement scripts are in place, wire the project's `package.json` scripts (`docs:check` / `docs:index` / `docs:index:check` / `docs:archive` / `docs:archive:check` / `docs:archive:issues` / `docs:archive:issues:check` / `prepare: node scripts/install-git-hooks.mjs`); add a minimal `.gitignore` (`docs/plans/`, `.claude/settings.local.json`) either way. This is the canonical model.
21
21
  - **hidden** (in-tree) — same files on disk, but the repo "looks normal": the AI/agent footprint is git-ignored via **one managed block in the project-local `.git/info/exclude`** (resolved with `git rev-parse --git-path info/exclude` — never the machine-global `core.excludesFile`, which would affect every repo on the host; **AD-014** amends **AD-006**). The kit's `tools/hide-footprint.mjs` is the single writer: it covers `KIT_OWN_PATHS ∪ the present subset of KNOWN_FOOTPRINT` (the full footprint table below — the kit's own artifacts **and** every other AI/agent tool's files). Per path: a **tracked** file → **ASK** (an exclude does nothing for it; only `git rm --cached` un-tracks it — the tool prints that command and never runs it); an untracked path already covered by a **tracked `.gitignore`** → dropped (redundant); a **present** file whose name is generic enough to be ambiguous (`falsePositiveRisk`) → **ASK**; everything else → **hidden**. `asks` are excluded from the block unless explicitly opted in. **Verify** treats a path as hidden only when it is **untracked AND ignored by our project-local block** (or a tracked `.gitignore`) — being ignored by the global excludes does **not** count. Re-running re-derives the block wholesale (sorted/deduped) → a clean re-run is a **zero-diff** no-op. On an existing global-excludes deployment the tool **detects + reports the residual legacy machine-global block and keeps it by default** (a harmless double-ignore; the local block wins precedence); removal is the explicit `--remove-global` (it prints the removed lines as a restorable backup), which **the agent only runs after asking** — another of the user's hidden repos on the same host may rely on the same root-anchored global lines. **Do not edit `package.json`** — that is a tracked change and would leak; the pre-commit hook (always untracked in `.git/hooks/`) calls the scripts via `node scripts/<x>.mjs` directly. Windows is supported (text edit, no symlinks; CRLF preserved).
22
22
 
23
23
  **Visibility changes what git sees — never how the agent works (the load-bearing invariant).** Both modes carry the **same** working memory and run the **same** session protocol: `docs/ai` and the deployed workflow artifacts (`AGENTS.md`, the added `scripts/`) are read at session start and maintained on task completion **identically** whether visible or hidden. (Hidden mode git-ignores a *wider* footprint — other AI/agent tools' files too — but that is only a **visibility** surface; the working memory the agent actually reads and maintains is `docs/ai` plus those artifacts.) Hidden mode only makes the repo *look* normal to git; it is **never** a license to skip, defer, or down-prioritise a doc/state update. The single difference is the destination: in hidden mode those updates live on disk and never enter a commit (the footprint is excluded), so a commit there carries only the project's own tracked files while `docs/ai` is still kept current locally — **git-ignored ≠ optional-to-maintain**. An agent that reads "git-ignored / `git status` clean" and infers the working memory is optional has the model backwards. (This is why bootstrap/upgrade never wire hidden-mode docs into a commit, yet the session protocol still mandates maintaining them.)
@@ -23,9 +23,9 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
23
23
  5. **Entry-point doc.** If `AGENTS.md` / `CLAUDE.md` already exist (step-1 recon), do **not** overwrite — show the user and ask whether to merge or replace. Otherwise create `AGENTS.md` (the cross-agent standard — Codex / Cursor / Devin Desktop / Copilot read it natively) from `${CLAUDE_SKILL_DIR}/references/templates/AGENTS.md`, and symlink `CLAUDE.md -> AGENTS.md` (`ln -s AGENTS.md CLAUDE.md`) for Claude Code — single source, no duplication. For nested context, add a subdir `AGENTS.md` (+ a `CLAUDE.md` symlink beside it for Claude Code).
24
24
  6. **Deploy `docs/ai/`.** Create every `docs/ai/` file + `pages/` + the seed `adr/` store + the seed `specs/` store from `${CLAUDE_SKILL_DIR}/references/templates/` (the template loop deploys every template except `AGENTS.md`, `adr-record.md` and `SPEC_TEMPLATE.md` — the latter two are skill-home authoring references, never deployed). This deploys the HOT ADR window **`docs/ai/decisions.md`** and the seed navigator **`docs/ai/adr/log.md`** (its `adr/` subdir copies in wholesale), the spec store root **`docs/ai/specs/index.md`** (its `specs/` subdir copies in wholesale), the `.md` docs, **and** the four seeded, user-editable strict-JSON configs: **`docs/ai/orchestration.json`** (the per-project recipe defaults the `procedures` advisor reads), **`docs/ai/gates.json`** (the project's gate declaration — seeded as the no-consent EMPTY list; FILLING it is the consented `gates-init` preview, `${CLAUDE_SKILL_DIR}/references/modes/gates.md`), **`docs/ai/autonomy.json`** (the per-project autonomy policy — seeded SPARSE with the onboarding note only, so it is exactly the computed defaults until the user declares levels via `/agent-workflow-kit set-autonomy` or by hand), and **`docs/ai/vehicles.json`** (the executor vehicle's model, effort and fallback as a setting the kit's readers resolve into one posture; the placed executor's body follows it — seeded once, hand-edited, never clobbered). Keep each `.md` file's frontmatter (`type / lastUpdated / scope / staleAfter / owner / maxLines`); the `.json` seeds carry no frontmatter (the docs cap-validator globs `*.md` only, so they are inherently skipped).
25
25
  7. **Fill templates** per the table below. **Then materialize the navigator, as the last `docs/ai` step:** the entry point declares `docs/ai/index.md` always-loaded and it is a GENERATED artifact with no template, so run `node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<project>` and relay its one outcome line (`ensure-index: regenerated` / `already-current`, or a `write-refused` / `probe-failed` line naming the offending path — either is a STOP: resolve it before reporting the deploy, since the navigator the entry point declares does not exist yet). The generator runs from **this skill's own directory**, so a project with **no Node runtime** is covered too: the agent host runs it, and step 8's copy is not a precondition.
26
- 8. **Offer enforcement on every project, preview-first.** Explain in one line that the dependency-free Node scripts are available because the agent host already runs their step-7 navigator; the only skip condition is that the agent host cannot run them. Preview the source and destination list and ask; on a yes copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs` (+ `*.test.mjs`) into the project's `scripts/`. On a no, skip the hook in step 9 and say so; the docs command slots then name no project-local command — fill `env_commands.md` and the `Docs caps + index freshness` row of `AGENTS.md` with the same note: the caps + index gate run from the agent's own skill copy (the step-7 `--root=<project>` form) and the archive rotations need the scripts deployed. Never write the skill's absolute path into a deployed doc. They self-configure (project name from `package.json` or, without one, the directory basename; hierarchical/on-demand sections auto-discovered). The hook calls `node scripts/<x>.mjs` directly, so a committer without `node` on PATH gets the hook's own loud failure.
27
- 9. **Wire / hide** per visibility (see [Visibility contract](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract)). When the user accepts the enforcement offer, install the pre-commit hook: `node scripts/install-git-hooks.mjs`. If the installer reports a pre-existing non-marker hook, stop and ask the user to merge it manually rather than overwriting. Once the gate declaration is final-run-capable, the installer's optional consented `--commit-guard <kit commit-guard.mjs path>` arm binds commits to the green `run-gates --final` receipt (the Recommendations advisor surfaces the exact one-liner).
28
- - **visible** — when a `package.json` exists and the offer was accepted, wire its scripts (never create one for this); add the minimal `.gitignore` either way. **Do not run the hide tool.**
26
+ 8. **Offer enforcement on every project, preview-first.** Explain in one line that the dependency-free Node scripts are available because the agent host already runs their step-7 navigator; the only skip condition is that the agent host cannot run them. Run the `enforcement` item's check line, `node ${CLAUDE_SKILL_DIR}/tools/apply-danger-check.mjs --variant enforcement --cwd <project>`: its result is the preview — each script the kit places into the project's `scripts/` with its bytes and the installer it runs with its bytes. Ask once on that result; on a yes copy them by its apply line with that digest, run by the consent route of `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`: the copy is create-only and the project's installer installs the pre-commit hook. On a no, skip the hook and say so; a check that refuses (exit 1 or 3) or an apply that exits 6 leaves no script the kit placed — say so with its output and fill the command slots as on a no. On a no the docs command slots name no project-local command — fill `env_commands.md` and the `Docs caps + index freshness` row of `AGENTS.md` with the same note: the caps + index gate run from the agent's own skill copy (the step-7 `--root=<project>` form) and the archive rotations need the scripts deployed. Never write the skill's absolute path into a deployed doc. They self-configure (project name from `package.json` or, without one, the directory basename; hierarchical/on-demand sections auto-discovered). The hook calls `node scripts/<x>.mjs` directly, so a committer without `node` on PATH gets the hook's own loud failure.
27
+ 9. **Wire / hide** per visibility (see [Visibility contract](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract)). The pre-commit hook is the installer's decision at step 8's apply; over a pre-commit hook the installer did not write, that apply exits 6 with the installer's output naming the hook, and moving it aside is the user's own step. Once the gate declaration is final-run-capable, the advisor's `commit-guard` item binds commits to the green `run-gates --final` receipt; it is applied the same way, its check line first.
28
+ - **visible** — when a `package.json` exists and step 8's apply exited 0, wire its scripts (never create one for this); add the minimal `.gitignore` either way. **Do not run the hide tool.**
29
29
  - **hidden** — run the kit's hide writer (one managed block in the **project-local** `.git/info/exclude`, covering the kit's own artifacts **and** every known AI/agent tool's footprint — never the machine-global excludes): `node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --dry-run` **first** (changes nothing, prints the plan), then the same without `--dry-run`. Handle what it surfaces, in plain language (never the tool's internal terms):
30
30
  - A path it reports as **already committed** can't be hidden by ignoring it and is **never un-tracked silently** — show the user the printed `git rm --cached <path>`, let them decide, then opt it in with `--include=<path>`.
31
31
  - A **present file with a generic name** it flags (e.g. `GEMINI.md`) → ask before `--include=<path>`.
@@ -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` (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 `source-size.id-squatter`) 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. `source-size.id-squatter`) 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.
@@ -37,7 +37,7 @@ A variant's posture note, where its key has one, is printed by `init` from this
37
37
 
38
38
  - `source-size` — the practice's arms differ by what the declared gate actually IS. **`adopted-elsewhere`** — the gate runs a DIFFERENT real copy of this checker (a vendored deployment): the practice is running, the advisor simply cannot resolve it as its own sibling, and the realpath anchor deliberately does not widen (it is what stops a lookalike from certifying). So the convergence is the ack writer's preview (`--lane source-size-copy` → `sourceSizeCopyAck`), never `--adopt`, which would mint the record and then be refused by the fill on the id already declared. The fingerprint binds the declared claims AS AUTHORED, sorted — not their resolved paths, which are machine-specific and would churn a committed ack between machines — so a second external copy re-fires it. **`id-squatter`** — a gate carries the `source-size` id and is not this checker under any reading: the id claims the practice while nothing measures module size, so it is **HAND-APPLY** (rename the id, or repoint the cmd at this checker's `--check` form). **`unminted`** stays what it was for both copies: a gate certain to refuse on every run, attention, and NO acknowledgement silences it — only the rendered verb differs (with the gate already declared elsewhere the missing half is the record alone, so the line is `--write-baseline`). Risk profile: `docs/ai/gates.json` is a PRIVILEGED file, so every arm here either records an acknowledgement or hands you an edit — the kit declares nothing on its own.
39
39
  - `gate-hook` — the base arm is the ordinary opt-in wiring offer on the harness lane: the agent applies it on the chat yes after the check; `init` previews and applies it on the terminal yes as the route the user may take instead. The **`marker-stale`** arm is not: the placed hook validates your declaration through its OWN baked copy and goes dark on any key it does not know, so a declaration carrying the `lcovProducer` key under a hook that predates it silently turns auto-approval OFF, and every gate prompts again with no error anywhere. The condition is the key's PRESENCE, not its value — an older hook rejects a key it does not know whatever that key says, so `"lcovProducer": false` darkens it exactly as `true` does. The arm is deliberately marker-scoped — a stale hook is otherwise harmless — and it is a harness item: its writer is `gate-hook --apply --reseed`, which replaces the placed hook with the bundled bytes in place (and wires the entry only while it is unwired), so the agent applies it on the chat yes after the check, whose result names the replaced file with the old and the new digest; `init` applies it on the terminal y as the route the user may take instead. When the read-lane is also enabled, this arm and `read-lane.stale` would report the same file with the same recovery, so exactly one renders — **this one**, because its cause is the true one: a hook that postdates the read-lane and merely predates the marker key reads `lanes.json` perfectly well, and the read-lane arm's wording would be false over it. Risk profile: one placed hook file replaced by the bundled runtime; the declaration is never touched.
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.
40
+ - `enforcement` — copies the bundled `references/scripts/*.mjs` runnable scripts and their tests, then runs the project's own hook installer, which installs the pre-commit hook. The lane is harness: the agent applies the item on the chat yes after the check, whose result shows each script the kit places with its bytes and the installer it runs with its bytes; `init` applies it on the batch terminal yes as the route the user may take instead. The copy is create-only: an existing file stands and nothing is written through a link, and the offer renders only when `scripts/` is absent or a plain directory contains no bundled basename. 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: an install it refuses removes the scripts this apply copied, and the item is offered again with the installer's output shown; over a pre-commit hook the installer did not write the item is not offered — its skip names the user's own step. Risk profile: local tooling and a Git hook are added on the chat yes after the check, or on init's y.
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
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.
@@ -56,6 +56,6 @@ A first session may meet ordinary prompts until the autonomy render lands; a cha
56
56
  - **Harness-managed sandbox** (an IDE/session-imposed sandbox that ignores the settings keys) — a NARROWEST-SCOPE ladder: (1) prefer wrapper/command-SCOPED sandbox rules where the harness offers them (only the review wrappers gain the egress + state-dir writes); (2) a SESSION-scoped allowance (the manifest hosts + the resolved state dirs for the whole session) is an INFORMED WIDENING — it carries the same blast-radius class the `--bridge-tier` keys carry where a host honours them (every sandboxed command in the session gains that egress and write access to those state dirs), acceptable only as the maintainer's deliberate choice for a review-heavy session. (3) Else the per-run consented bypass. The kit writes no shape for this sandbox — the harness's own controls (its scoped rules, a session allowance, its consent at the point of action) govern there; an IDE-managed session sandbox was live-observed (2026-07-11/12) ignoring the settings security keys in BOTH scopes, and whether a session's sandbox honours them is not knowable from the advisor.
57
57
  - Either way the recipe is **observed-minimal, honestly incomplete** — a blocked host names itself at run time; the manifests record what was observed.
58
58
 
59
- **Invariants:** read-only (never writes, never commits, never runs a subscription CLI) · `--cwd` required · present-even-when-empty · verdict-first from frozen templates · probe failures degrade to stated skip lines · chat apply one-liners are cwd-independent (absolute tool paths + a pinned `--cwd`; the ONE exception is the `set-autonomy` item, a conversational skill invocation explicitly labeled *run IN the target project*) · the bare `sandbox-provision` item is the user's own step, pinned via a `cd <root> &&` prefix — the doctor reads its cwd · harness items carry a check and an apply bound to its digest; user items, including `family-freshness`, carry the user's own step, and no-writer variants keep their text · the used bridges' `sandbox.filesystem.allowWrite` and `sandbox.network.allowedDomains` are seeded only by the consented `velocity-profile --bridge-tier`; the worktrees grant keeps its hand-apply step · the `worktrees-dir` item converges on a declared `allowWrite` entry covering its probed dir, or on its own `worktreesDirAck` lane in `docs/ai/acks.json` (recorded by its apply one-liner, never by the `recipe:` line).
59
+ **Invariants:** read-only (never writes, never commits, never runs a subscription CLI) · `--cwd` required · present-even-when-empty · verdict-first from frozen templates · probe failures degrade to stated skip lines · chat apply one-liners are cwd-independent (absolute tool paths + a pinned `--cwd`; the ONE exception is the `set-autonomy` item, a conversational skill invocation explicitly labeled *run IN the target project*) · the bare `sandbox-provision` item is the user's own step, pinned via a `cd <root> &&` prefix — the doctor reads its cwd · harness items carry a check and an apply bound to its digest; user items, including `family-freshness`, carry the user's own step · the used bridges' `sandbox.filesystem.allowWrite` and `sandbox.network.allowedDomains` are seeded only by the consented `velocity-profile --bridge-tier`; the worktrees grant keeps its hand-apply step · the `worktrees-dir` item converges on a declared `allowWrite` entry covering its probed dir, or on its own `worktreesDirAck` lane in `docs/ai/acks.json` (recorded by its apply one-liner, never by the `recipe:` line).
60
60
 
61
61
  **Exit codes:** `0` report rendered (items or the empty state); `1` error (e.g. `--cwd` is not a directory); `2` usage.
@@ -43,7 +43,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
43
43
 
44
44
  **What the executor ensure does.** A placed `.claude/agents/executor.md` is kept byte-equal to the body derived from `docs/ai/vehicles.json`: a differing regular file is rewritten (`re-derived` — a hand edit is replaced, never preserved), and an absent one is `not-placed` (the agents mode places it on consent; this ensure never does).
45
45
 
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.
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 (the `enforcement` check line and apply, by the consent route, copy nothing and re-run the installer, which refuses 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
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
 
@@ -7,7 +7,7 @@ The non-obvious traps — scan these before bootstrapping or upgrading. Each is
7
7
  - **Hidden mode must never touch `package.json`.** Editing it is a *tracked* change and leaks the whole system. Hidden mode wires nothing into `package.json`; the pre-commit hook (untracked in `.git/hooks/`) calls `node scripts/<x>.mjs` directly. After hiding, **verify `git status` shows the artifacts as ignored**.
8
8
  - **Hidden mode is project-local, and the hide tool owns the known footprint.** `tools/hide-footprint.mjs` writes **one managed block** in the **project-local `.git/info/exclude`** — never the machine-global `core.excludesFile` (which would silently affect every repo on the host; **AD-014** amends **AD-006**). It hides the kit's own artifacts **and** the known external AI/agent footprint (the `KNOWN_FOOTPRINT` table in [contracts](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract)). A **tracked** file is **never silently un-tracked** — the tool prints the `git rm --cached` it will not run. Never leak its internal marker / asks terms to the user; translate every outcome to plain language.
9
9
  - **`CLAUDE.md` is a symlink, not a copy.** `ln -s AGENTS.md CLAUDE.md` — single source, no duplication. A copy drifts; a symlink can't.
10
- - **Never overwrite an existing entry point or hook.** If `AGENTS.md` / `CLAUDE.md` already exist, or the installer reports a pre-existing non-marker git hook, **stop and ask** the user to merge vs replace — don't clobber.
10
+ - **Never overwrite an existing entry point or hook.** If `AGENTS.md` / `CLAUDE.md` already exist, **stop and ask** the user to merge vs replace — don't clobber. A non-marker hook stays; moving it aside is the user's step.
11
11
  - **Unrecognized invocations are read-only.** Only a **known** subcommand reaches its mode; the **bare** invocation bootstraps (and an existing `docs/ai/` makes it ask upgrade-vs-bootstrap first, never overwrite); **any other / ambiguous** token routes to `help` (read-only). A garbage invocation never writes. The mapping is pinned by `tools/commands.mjs` `routeInvocation` (unit-tested) — don't hand-route around it.
12
12
  - **Enforcement is offered on every project.** The dependency-free scripts are available because the agent host already runs the navigator; for the offer, the only skip condition is that the agent host cannot run them. The hook calls `node scripts/<x>.mjs`, so a committer without `node` on PATH gets the hook's own loud failure. **A skip names a PROVEN fact, never a proxy:** upgrade ensures judge Node by EVIDENCE — a root `package.json` OR any kit-seeded `scripts/*.mjs` already deployed — and the `skipped-no-node-evidence` line lists every probe that answered absent; a reason the tool could disprove is a tool defect. **A skip line that contradicts the observed tree is raised as a finding in the report, never pasted as neutral** — report the contradiction with the proven fact and do not build on the skip.
13
13
  - **Conversational language never translates artifacts.** It governs *dialogue only*. Code, identifiers, paths, commands, log output, abbreviations, and every deployed `docs/ai/` / `AGENTS.md` file stay in their source language. See [Communication contract](${CLAUDE_SKILL_DIR}/references/contracts.md#communication-contract).
@@ -23,6 +23,7 @@ import { planFor } from './jev-skill.mjs';
23
23
  import { SKILL_FILES, claudeDirOf, skillTargets } from './jev-facts.mjs';
24
24
  import { findOnPath } from './detect-backends.mjs';
25
25
  import { consoleArgv } from './write-lanes.mjs';
26
+ import { BUNDLED_SCRIPT_NAMES, INSTALLER_REL, SCRIPTS_REL, formatPlan, planGitHooks } from './git-hooks.mjs';
26
27
  import { isDirectRun } from './direct-run.mjs';
27
28
  import { dirCovers as covers } from './declared-paths.mjs';
28
29
 
@@ -34,8 +35,9 @@ const HOOK_VARIANTS = ['gate-hook', 'read-lane.missing', ...RESEED_VARIANTS];
34
35
  const STOP = 'state-block';
35
36
  const MCP_VARIANTS = ['mcp-channel', 'mcp-channel.differing'];
36
37
  const AGENT_VARIANTS = ['agents', 'executor-vehicle'];
38
+ const GIT_VARIANTS = ['enforcement', 'commit-guard'];
37
39
  const VARIANTS = [...VELOCITY_VARIANTS, 'autonomy-render', ...HOOK_VARIANTS, STOP, ...MCP_VARIANTS, ...AGENT_VARIANTS,
38
- ...SKILL_VARIANTS];
40
+ ...SKILL_VARIANTS, ...GIT_VARIANTS];
39
41
  const HOOK_KEYS = ['hooks.PreToolUse', 'hooks.Stop'];
40
42
  const CREDENTIALS = 'sandbox.credentials.envVars';
41
43
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -197,7 +199,15 @@ export const scopeOf = (variant, facts, deps = {}) => {
197
199
  for (let at = target.root; at !== target.base && covers(target.base, at); at = dirname(at)) directories.push(at);
198
200
  filePaths.push(target.path);
199
201
  }
202
+ const scripts = join(facts.root, SCRIPTS_REL);
203
+ if (GIT_VARIANTS.includes(variant)) {
204
+ directories.splice(0, directories.length, scripts);
205
+ filePaths.push(...BUNDLED_SCRIPT_NAMES.map((name) => join(scripts, name)));
206
+ }
207
+ const runs = GIT_VARIANTS.includes(variant) ? ['node', INSTALLER_REL,
208
+ ...(variant === 'commit-guard' ? ['--commit-guard', join(facts.toolsDir, 'commit-guard.mjs')] : [])] : null;
200
209
  return {
210
+ admitsRun: (argv) => runs !== null && equal(argv, runs),
201
211
  admits: ({ path, key, change, value }) => {
202
212
  const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL : path === SETTINGS_FILE;
203
213
  const gains = variant === 'bridge-tier' || HOOK_KEYS.includes(key) || key === ENABLED_KEY || key === CREDENTIALS
@@ -221,6 +231,7 @@ export const plannedChange = (variant, facts, deps = {}) => {
221
231
  const files = [];
222
232
  const writes = [];
223
233
  const temporaries = [];
234
+ let runs = null;
224
235
  const writerDeps = writerFacts(facts, deps);
225
236
  const addFile = (path, before, after) => {
226
237
  files.push({ path, before: before ?? null, after });
@@ -280,6 +291,10 @@ export const plannedChange = (variant, facts, deps = {}) => {
280
291
  temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-tmp-*`);
281
292
  if (target.action === 'replace') temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-old-*`);
282
293
  }
294
+ } else if (GIT_VARIANTS.includes(variant)) {
295
+ const plan = (deps.planGitHooks ?? planGitHooks)({ root: facts.root, variant, toolsDir: facts.toolsDir });
296
+ for (const copy of plan.copies.filter(({ action }) => action === 'created')) writes.push({ path: copy.path, bytes: copy.bytes });
297
+ runs = { argv: plan.runs.shown, line: formatPlan(plan).at(-1) };
283
298
  } else throw new Error(`unsupported variant: ${variant}`);
284
299
  } catch (error) {
285
300
  throw error.code === 'CHECK_HALTED' ? error : halted([error.message]);
@@ -289,7 +304,7 @@ export const plannedChange = (variant, facts, deps = {}) => {
289
304
  for (const path of [...changedFiles.map(({ path }) => join(facts.root, path)), ...writes.map(({ path }) => path)]) {
290
305
  for (let parent = dirname(path); !existsSync(parent); parent = dirname(parent)) directories.add(parent);
291
306
  }
292
- return { files, writes, temporaries, directories: [...directories] };
307
+ return { files, writes, temporaries, directories: [...directories], runs };
293
308
  };
294
309
 
295
310
  export const checkApply = (variant, facts, deps = {}) => {
@@ -318,6 +333,10 @@ export const checkApply = (variant, facts, deps = {}) => {
318
333
  }
319
334
  for (const path of plan.directories) pathLine(path, true, `writes: ${display(path)}/ created`);
320
335
  lines.push(...plan.temporaries);
336
+ if (plan.runs) {
337
+ lines.push(plan.runs.line);
338
+ if (!scope.admitsRun(plan.runs.argv)) refused.push({ path: 'runs:', key: plan.runs.argv.join(' '), reason: `beyond the declared scope of ${variant}` });
339
+ }
321
340
  if (refused.length) {
322
341
  for (const entry of refused) lines.push(`refused: ${entry.path} ${entry.key} — ${entry.reason}`);
323
342
  lines.push('nothing written');
@@ -369,7 +388,7 @@ export const main = (argv, io = {}) => {
369
388
  platform: resolved.platform, claudeDir: args['claude-dir'] };
370
389
  let result;
371
390
  try {
372
- result = checkApply(args.variant, facts, { detect: resolved.detect });
391
+ result = checkApply(args.variant, facts, { detect: resolved.detect, planGitHooks: resolved.planGitHooks });
373
392
  } catch (error) {
374
393
  for (const line of error.lines ?? [error.message]) resolved.log(line);
375
394
  return 3;
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ // git-hooks.mjs — the thin writer of the two git-hook items (`enforcement`, `commit-guard`): it copies
3
+ // the bundled scripts create-only and spawns the project's own scripts/install-git-hooks.mjs, which
4
+ // decides the hook at the write. The leaf decides nothing about the hook: the danger check binds the
5
+ // copies and the installer bytes this dry run answers, never git's hooks dir.
6
+ import { lstatSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { writeProjectFileCreateOnly } from './atomic-write.mjs';
12
+ import { NODE_EVIDENCE_SCRIPTS } from './node-evidence.mjs';
13
+ import { shellQuoteArg } from './repo-lex.mjs';
14
+ import { isDirectRun } from './direct-run.mjs';
15
+
16
+ const TOOLS = dirname(fileURLToPath(import.meta.url));
17
+ export const SCRIPTS_REL = 'scripts';
18
+ export const INSTALLER_REL = 'scripts/install-git-hooks.mjs';
19
+ const bundleDir = (toolsDir) => join(toolsDir, '..', 'references', 'scripts');
20
+ export const BUNDLED_SCRIPT_NAMES = Object.freeze(readdirSync(bundleDir(TOOLS)).filter((name) => name.endsWith('.mjs')).sort());
21
+
22
+ const EXIT_OK = 0;
23
+ const EXIT_INCOMPLETE = 1;
24
+ const EXIT_USAGE = 2;
25
+ const EXIT_REFUSED = 3;
26
+ const USAGE = 'usage: git-hooks (--enforcement | --commit-guard) [--cwd <root>] [--apply]';
27
+ const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
28
+ const usageError = (message) => Object.assign(new Error(message), { exitCode: EXIT_USAGE });
29
+ const refusal = (message) => Object.assign(new Error(message), { exitCode: EXIT_REFUSED });
30
+
31
+ const nodeAt = (path, lstat) => {
32
+ try {
33
+ return lstat(path);
34
+ } catch (error) {
35
+ if (error?.code === 'ENOENT') return null;
36
+ throw error;
37
+ }
38
+ };
39
+ const isPlainDir = (node) => node !== null && node.isDirectory() && !node.isSymbolicLink();
40
+ const isFile = (node) => node !== null && node.isFile() && !node.isSymbolicLink();
41
+
42
+ // The dry run: what the apply copies and the installer it runs, with the bytes of each.
43
+ export const planGitHooks = ({ root, variant, toolsDir = TOOLS, lstat = lstatSync, readFile = readFileSync }) => {
44
+ const scripts = join(root, SCRIPTS_REL);
45
+ const scriptsNode = nodeAt(scripts, lstat);
46
+ if (scriptsNode !== null && !isPlainDir(scriptsNode)) throw refusal(`${scripts} is not a plain directory`);
47
+ const kinds = new Map();
48
+ const names = variant === 'enforcement' ? BUNDLED_SCRIPT_NAMES : [INSTALLER_REL.slice(SCRIPTS_REL.length + 1)];
49
+ for (const name of scriptsNode === null ? [] : names) {
50
+ const path = join(scripts, name);
51
+ const node = nodeAt(path, lstat);
52
+ if (node !== null && !isFile(node)) throw refusal(`${path} is not a regular file`);
53
+ kinds.set(name, node === null ? 'absent' : 'file');
54
+ }
55
+ const installer = join(root, INSTALLER_REL);
56
+ const deployed = NODE_EVIDENCE_SCRIPTS.some((name) => kinds.get(name) === 'file');
57
+ const copies = variant === 'enforcement' && !deployed ? BUNDLED_SCRIPT_NAMES.map((name) => {
58
+ if (kinds.get(name) === 'file') return { name, path: join(scripts, name), action: 'kept' };
59
+ return { name, path: join(scripts, name), action: 'created', bytes: readFile(join(bundleDir(toolsDir), name)) };
60
+ }) : [];
61
+ const placed = copies.find(({ path, action }) => path === installer && action === 'created');
62
+ if (!placed && !isFile(nodeAt(installer, lstat))) throw refusal(`${installer} is absent — nothing here places the hook installer`);
63
+ const guard = variant === 'commit-guard' ? ['--commit-guard', join(toolsDir, 'commit-guard.mjs')] : [];
64
+ return {
65
+ root,
66
+ variant,
67
+ scriptsCreated: scriptsNode === null,
68
+ copies,
69
+ runs: { argv: ['node', installer, ...guard], shown: ['node', INSTALLER_REL, ...guard], bytes: placed ? placed.bytes : readFile(installer) },
70
+ };
71
+ };
72
+
73
+ export const formatPlan = (plan) => [
74
+ ...plan.copies.map(({ name, action, bytes }) => `copy: ${SCRIPTS_REL}/${name} ${action}${action === 'created' ? ` sha256:${sha(bytes)}` : ''}`),
75
+ `runs: ${plan.runs.shown.map(shellQuoteArg).join(' ')} sha256:${sha(plan.runs.bytes)}`,
76
+ ];
77
+
78
+ // Removes exactly the files this run created, then scripts/ when this run created it and it is empty;
79
+ // every path it cannot remove is answered for the caller to name.
80
+ const rollBack = (plan, created, io) => {
81
+ const scripts = join(plan.root, SCRIPTS_REL);
82
+ const left = [];
83
+ const dirNow = () => nodeAt(scripts, io.lstat);
84
+ for (const path of created) {
85
+ const dir = dirNow();
86
+ const node = nodeAt(path, io.lstat);
87
+ if (!isPlainDir(dir) || dir.ino !== created.dirIno || !isFile(node)) {
88
+ left.push(path);
89
+ continue;
90
+ }
91
+ try {
92
+ io.unlink(path);
93
+ } catch {
94
+ left.push(path);
95
+ }
96
+ }
97
+ if (plan.scriptsCreated && created.dirIno !== undefined) {
98
+ const dir = dirNow();
99
+ let empty = false;
100
+ try {
101
+ empty = isPlainDir(dir) && dir.ino === created.dirIno && io.readdir(scripts).length === 0;
102
+ } catch {
103
+ empty = false;
104
+ }
105
+ if (empty) {
106
+ try {
107
+ io.rmdir(scripts);
108
+ } catch {
109
+ left.push(scripts);
110
+ }
111
+ } else if (!isPlainDir(dir) || dir.ino !== created.dirIno) left.push(scripts);
112
+ }
113
+ return left;
114
+ };
115
+
116
+ export const applyGitHooks = (plan, io = {}) => {
117
+ const resolved = { lstat: lstatSync, unlink: unlinkSync, rmdir: rmdirSync, readdir: readdirSync, env: process.env,
118
+ log: console.log, error: console.error, writeFile: writeProjectFileCreateOnly,
119
+ spawn: (argv, options) => spawnSync(argv[0], argv.slice(1), options), ...io };
120
+ const created = [];
121
+ const leftovers = [];
122
+ const fail = (lines) => {
123
+ for (const line of lines) resolved.error(line);
124
+ for (const path of [...rollBack(plan, created, resolved), ...leftovers]) resolved.error(`not removed: ${path}`);
125
+ return EXIT_INCOMPLETE;
126
+ };
127
+ const scripts = join(plan.root, SCRIPTS_REL);
128
+ const noteDir = () => {
129
+ const dir = nodeAt(scripts, resolved.lstat);
130
+ if (created.dirIno === undefined && isPlainDir(dir)) created.dirIno = dir.ino;
131
+ };
132
+ for (const copy of plan.copies.filter(({ action }) => action === 'created')) {
133
+ let result;
134
+ try {
135
+ result = resolved.writeFile(plan.root, `${SCRIPTS_REL}/${copy.name}`, copy.bytes);
136
+ } catch (error) {
137
+ noteDir();
138
+ return fail([error?.message ?? String(error)]);
139
+ }
140
+ noteDir();
141
+ if (result.tmpLeftBehind) leftovers.push(result.tmpLeftBehind);
142
+ if (!result.created) return fail([`${copy.path} appeared after the check — the file found there stands`]);
143
+ created.push(copy.path);
144
+ }
145
+ const child = resolved.spawn(plan.runs.argv, { cwd: plan.root, env: resolved.env, stdio: 'inherit' });
146
+ if (child.status !== 0) return fail([`installer exited ${child.status ?? child.signal ?? child.error?.code}`]);
147
+ for (const path of leftovers) resolved.error(`not removed: ${path}`);
148
+ return EXIT_OK;
149
+ };
150
+
151
+ export const parseArgs = (argv) => {
152
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) return { help: true };
153
+ const args = { apply: false, cwd: undefined, variants: [] };
154
+ for (let index = 0; index < argv.length; index += 1) {
155
+ const flag = argv[index];
156
+ if (flag === '--apply') args.apply = true;
157
+ else if (flag === '--enforcement' || flag === '--commit-guard') args.variants.push(flag.slice(2));
158
+ else if (flag === '--cwd') {
159
+ const value = argv[index + 1];
160
+ if (value === undefined || value.startsWith('-')) throw usageError('--cwd takes a value');
161
+ args.cwd = value;
162
+ index += 1;
163
+ } else throw usageError(`unknown argument: ${flag}`);
164
+ }
165
+ if (args.variants.length !== 1) throw usageError('pass exactly one of --enforcement and --commit-guard');
166
+ return { help: false, apply: args.apply, cwd: args.cwd, variant: args.variants[0] };
167
+ };
168
+
169
+ export const main = (argv, io = {}) => {
170
+ const resolved = { log: console.log, error: console.error, cwd: process.cwd(), toolsDir: TOOLS, ...io };
171
+ let args;
172
+ try {
173
+ args = parseArgs(argv);
174
+ if (args.help) {
175
+ resolved.log(USAGE);
176
+ return EXIT_OK;
177
+ }
178
+ args.root = resolve(resolved.cwd, args.cwd ?? '.');
179
+ if (!statSync(args.root, { throwIfNoEntry: false })?.isDirectory()) throw usageError(`--cwd is not a directory: ${args.root}`);
180
+ } catch (error) {
181
+ resolved.error(error.message);
182
+ resolved.error(USAGE);
183
+ return EXIT_USAGE;
184
+ }
185
+ let plan;
186
+ try {
187
+ plan = planGitHooks({ root: args.root, variant: args.variant, toolsDir: resolved.toolsDir });
188
+ } catch (error) {
189
+ resolved.error(error.message);
190
+ return EXIT_REFUSED;
191
+ }
192
+ if (!args.apply) {
193
+ for (const line of formatPlan(plan)) resolved.log(line);
194
+ return EXIT_OK;
195
+ }
196
+ return applyGitHooks(plan, resolved);
197
+ };
198
+
199
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -46,7 +46,7 @@ import {
46
46
  } from './velocity-profile.mjs';
47
47
  import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
48
48
  import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
49
- import { laneOf, applySlot } from './write-lanes.mjs';
49
+ import { laneOf, applySlot, consoleArgv } from './write-lanes.mjs';
50
50
  import { STATE_BLOCK_HOOK_COMMAND, STATE_BLOCK_HOOK_RUNTIME, isStopHookWired } from './state-block-hook.mjs';
51
51
  import { detectBackends } from './detect-backends.mjs';
52
52
  import { isDirectRun } from './direct-run.mjs';
@@ -120,8 +120,10 @@ const ABSENT_CODE = 'ENOENT';
120
120
  const ENFORCEMENT_KIT_ROOT = resolve(HERE, '..');
121
121
  const ENFORCEMENT_BUNDLE_ROOT = join(ENFORCEMENT_KIT_ROOT, 'references', 'scripts');
122
122
  const ENFORCEMENT_WHAT = 'the enforcement scripts are not deployed beside docs/ai (no Node evidence) \u2014 the offer copies them and installs the pre-commit hook';
123
- const ENFORCEMENT_APPLY_TEMPLATE = 'HAND-APPLY: mkdir -p {root}/scripts && cp {kit}/references/scripts/*.mjs {root}/scripts/ && node {root}/scripts/install-git-hooks.mjs';
124
- const COMMIT_GUARD_INSTALLER_REMEDY = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed \u2014 deploy the enforcement scripts (bootstrap steps 8\u20139) and install the hook, then re-run';
123
+ const ENFORCEMENT_UNMANAGED_HOOK = 'an UNMANAGED pre-commit hook exists \u2014 the installer never overwrites it, so the kit places no script here; with that hook moved aside (your own step) the enforcement item is offered';
124
+ const COMMIT_GUARD_INSTALLER_VIA_ITEM = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed \u2014 apply the enforcement item first, then re-run';
125
+ const COMMIT_GUARD_INSTALLER_OWN_STEP = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed here \u2014 deploying the enforcement scripts into this project is your own step, then re-run';
126
+ const COMMIT_GUARD_UNMANAGED_HOOK = 'an UNMANAGED pre-commit hook exists \u2014 the installer never overwrites it; adding the guard line to it is your own step';
125
127
  // ── the section contract tokens (doc-parity-bound in upgrade.md + the mode doc) ────────────────
126
128
  export const RECOMMENDATIONS_SECTION_HEADER = '## Recommendations (agent-workflow)';
127
129
  export const RECOMMENDATIONS_EMPTY_LINE = 'no recommendations — flow optimal.';
@@ -883,7 +885,7 @@ const probeSourceSize = ({ root, deps, add, skip }) => {
883
885
  // The D10 consumer surface: once the declaration is FINAL-capable (the canonical core checks
884
886
  // present, the checker LAST — the run-gates helper is the one home of that rule), offer the
885
887
  // consented commit-guard install — for a MANAGED guardless pre-commit hook AND for an absent one
886
- // alike; an UNMANAGED hook is a loud skip (manual merge, never an overwrite offer); a
888
+ // alike; an UNMANAGED hook is a loud skip naming the user's own step (never an overwrite offer); a
887
889
  // non-final-capable declaration gets NO offer (an armed guard over a matrix that cannot mint a
888
890
  // receipt would block every commit).
889
891
  const INSTALLER_MARKER_RE = /:install-git-hooks\.mjs$/m;
@@ -892,6 +894,23 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
892
894
  // the kit's OWN resolved guard path. A commented-out line or a lookalike path (fake-commit-guard)
893
895
  // must never read as armed (that would suppress the offer and leave commits unguarded).
894
896
  const canonicalGuardLineRe = () => new RegExp(`^node "${escapeRe(toolPath('commit-guard.mjs'))}" --check$`, 'm');
897
+ // The one reader of git's pre-commit: whose hook it is, and whether the canonical guard line arms it.
898
+ export const readPreCommitState = (root, deps = {}) => {
899
+ const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
900
+ if (hooksPath === null) return { state: 'no-hooks-dir', armed: false };
901
+ const path = join(hooksPath, 'pre-commit');
902
+ const hook = (() => {
903
+ try {
904
+ if (readsAbsent(deps, path)) return null;
905
+ return String((deps.readFile ?? readFileSync)(path, 'utf8'));
906
+ } catch {
907
+ return null;
908
+ }
909
+ })();
910
+ if (hook === null) return { state: 'absent', armed: false };
911
+ return { state: INSTALLER_MARKER_RE.test(hook) ? 'managed' : 'unmanaged', armed: canonicalGuardLineRe().test(hook) };
912
+ };
913
+ const formsApply = (variant, root) => consoleArgv(variant, { toolsDir: HERE, root }).apply.map(q).join(' ');
895
914
  const probeCommitGuard = ({ root, deps, add, skip }) => {
896
915
  try {
897
916
  const read = deps.readFile ?? readFileSync;
@@ -907,25 +926,16 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
907
926
  if (!isFinalCapableDeclaration(gates, root)) return; // no receipt to guard yet — no offer
908
927
  const installer = join(root, 'scripts', 'install-git-hooks.mjs');
909
928
  if (!(deps.exists ?? existsSync)(installer)) {
910
- skip('commit-guard', new Error(COMMIT_GUARD_INSTALLER_REMEDY));
929
+ skip('commit-guard', new Error(enforcementAdmits(root, deps) ? COMMIT_GUARD_INSTALLER_VIA_ITEM : COMMIT_GUARD_INSTALLER_OWN_STEP));
911
930
  return;
912
931
  }
913
- const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
914
- if (hooksPath === null) return; // not a git tree — nothing to hook
915
- const hook = (() => {
916
- try {
917
- if (readsAbsent(deps, join(hooksPath, 'pre-commit'))) return null;
918
- return String(read(join(hooksPath, 'pre-commit'), 'utf8'));
919
- } catch {
920
- return null;
921
- }
922
- })();
923
- if (hook !== null && canonicalGuardLineRe().test(hook)) return; // armed — converged (managed or not)
924
- if (hook !== null && !INSTALLER_MARKER_RE.test(hook)) {
925
- skip('commit-guard', new Error('an UNMANAGED pre-commit hook exists — merge the guard line by hand (the installer refuses to overwrite it)'));
932
+ const { state, armed } = readPreCommitState(root, deps);
933
+ if (armed || state === 'no-hooks-dir') return; // armed (managed or not), or not a git tree — converged
934
+ if (state === 'unmanaged') {
935
+ skip('commit-guard', new Error(COMMIT_GUARD_UNMANAGED_HOOK));
926
936
  return;
927
937
  }
928
- add('commit-guard', fillTemplate(WHATS['commit-guard'], {}), `node ${q(installer)} --commit-guard ${q(toolPath('commit-guard.mjs'))}`);
938
+ add('commit-guard', fillTemplate(WHATS['commit-guard'], {}), formsApply('commit-guard', root));
929
939
  } catch (err) {
930
940
  skip('commit-guard', err);
931
941
  }
@@ -940,31 +950,46 @@ const readEnforcementNode = (path, lstat) => {
940
950
  }
941
951
  };
942
952
 
953
+ // The enforcement offer's one admission: true when the item renders, false when nothing renders, and
954
+ // a throw names the skip.
955
+ const enforcementOffer = (root, deps) => {
956
+ const lstat = deps.lstat ?? lstatSync;
957
+ const docsAi = join(root, 'docs', 'ai');
958
+ const docsAiNode = readEnforcementNode(docsAi, lstat);
959
+ if (docsAiNode === null) return false;
960
+ if (!docsAiNode.isDirectory()) throw new Error(`${docsAi} is not a plain directory`);
961
+ const evidence = probeNodeEvidence(root, lstat);
962
+ if (evidence.state === NODE_EVIDENCE.PACKAGE_JSON || evidence.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS) return false;
963
+ if (evidence.state === NODE_EVIDENCE.UNREADABLE) throw new Error(`${root}: ${evidence.error}`);
964
+ if (evidence.state !== NODE_EVIDENCE.NONE) throw new Error(`unknown Node-evidence state: ${evidence.state}`);
965
+ if (evidence.wrongKind.length > 0) throw new Error(join(root, evidence.wrongKind[0]));
966
+ const readDir = deps.readdir ?? readdirSync;
967
+ const bundleRoot = deps.scriptsBundleRoot ?? ENFORCEMENT_BUNDLE_ROOT;
968
+ const bundleBasenames = readDir(bundleRoot).filter((name) => name.endsWith('.mjs')).sort();
969
+ if (NODE_EVIDENCE_SCRIPTS.some((name) => !bundleBasenames.includes(name))) throw new Error(`${bundleRoot}: a runnable enforcement script is missing`);
970
+ const scripts = join(root, 'scripts');
971
+ const scriptsNode = readEnforcementNode(scripts, lstat);
972
+ if (scriptsNode !== null && !scriptsNode.isDirectory()) throw new Error(`${scripts} is not a plain directory`);
973
+ for (const basename of scriptsNode === null ? [] : bundleBasenames) {
974
+ const destination = join(scripts, basename);
975
+ if (readEnforcementNode(destination, lstat) === null) continue;
976
+ throw new Error(`${destination} already exists; the enforcement copy would clobber it`);
977
+ }
978
+ if (readPreCommitState(root, deps).state === 'unmanaged') throw new Error(ENFORCEMENT_UNMANAGED_HOOK);
979
+ return true;
980
+ };
981
+
982
+ export const enforcementAdmits = (root, deps = {}) => {
983
+ try {
984
+ return enforcementOffer(root, deps);
985
+ } catch {
986
+ return false;
987
+ }
988
+ };
989
+
943
990
  export const probeEnforcement = ({ root, deps, add, skip }) => {
944
991
  try {
945
- const lstat = deps.lstat ?? lstatSync;
946
- const docsAi = join(root, 'docs', 'ai');
947
- const docsAiNode = readEnforcementNode(docsAi, lstat);
948
- if (docsAiNode === null) return;
949
- if (!docsAiNode.isDirectory()) throw new Error(`${docsAi} is not a plain directory`);
950
- const evidence = probeNodeEvidence(root, lstat);
951
- if (evidence.state === NODE_EVIDENCE.PACKAGE_JSON || evidence.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS) return;
952
- if (evidence.state === NODE_EVIDENCE.UNREADABLE) throw new Error(`${root}: ${evidence.error}`);
953
- if (evidence.state !== NODE_EVIDENCE.NONE) throw new Error(`unknown Node-evidence state: ${evidence.state}`);
954
- if (evidence.wrongKind.length > 0) throw new Error(join(root, evidence.wrongKind[0]));
955
- const readDir = deps.readdir ?? readdirSync;
956
- const bundleRoot = deps.scriptsBundleRoot ?? ENFORCEMENT_BUNDLE_ROOT;
957
- const bundleBasenames = readDir(bundleRoot).filter((name) => name.endsWith('.mjs')).sort();
958
- if (NODE_EVIDENCE_SCRIPTS.some((name) => !bundleBasenames.includes(name))) throw new Error(`${bundleRoot}: a runnable enforcement script is missing`);
959
- const scripts = join(root, 'scripts');
960
- const scriptsNode = readEnforcementNode(scripts, lstat);
961
- if (scriptsNode !== null && !scriptsNode.isDirectory()) throw new Error(`${scripts} is not a plain directory`);
962
- for (const basename of scriptsNode === null ? [] : bundleBasenames) {
963
- const destination = join(scripts, basename);
964
- if (readEnforcementNode(destination, lstat) === null) continue;
965
- throw new Error(`${destination} already exists; the enforcement copy would clobber it`);
966
- }
967
- add('enforcement', ENFORCEMENT_WHAT, fillTemplate(ENFORCEMENT_APPLY_TEMPLATE, { root: q(root), kit: q(ENFORCEMENT_KIT_ROOT) }));
992
+ if (enforcementOffer(root, deps)) add('enforcement', ENFORCEMENT_WHAT, formsApply('enforcement', root));
968
993
  } catch (err) {
969
994
  skip('enforcement', err);
970
995
  }
@@ -2,6 +2,7 @@ import { join, resolve } from 'node:path';
2
2
  import { dirCovers } from './declared-paths.mjs';
3
3
  import { claudeDirOf, quoteArg, skillTargets } from './jev-facts.mjs';
4
4
  import { targetsFor } from './jev-connect.mjs';
5
+ import { BUNDLED_SCRIPT_NAMES, INSTALLER_REL, SCRIPTS_REL } from './git-hooks.mjs';
5
6
 
6
7
  export const LANE_CHAT = 'chat';
7
8
  export const LANE_HARNESS = 'harness';
@@ -27,8 +28,8 @@ export const VARIANT_LANES = Object.freeze({
27
28
  'source-size.id-squatter': LANE_CHAT,
28
29
  'gate-hook': LANE_HARNESS,
29
30
  'gate-hook.marker-stale': LANE_HARNESS,
30
- 'commit-guard': LANE_USER,
31
- enforcement: LANE_USER,
31
+ 'commit-guard': LANE_HARNESS,
32
+ enforcement: LANE_HARNESS,
32
33
  'read-lane': LANE_CHAT,
33
34
  'read-lane.stale': LANE_HARNESS,
34
35
  'read-lane.missing': LANE_HARNESS,
@@ -97,6 +98,10 @@ const getStartupPaths = ({ env, home, platform }) => targetsFor({
97
98
  platform,
98
99
  }).filter(({ path }) => path !== null).map(({ path }) => path);
99
100
 
101
+ const preCommitOf = (facts) => join(facts.hooksDir ?? join(facts.root, '.git', 'hooks'), 'pre-commit');
102
+ const getEnforcementPaths = (facts) => [...BUNDLED_SCRIPT_NAMES.map((name) => join(facts.root, SCRIPTS_REL, name)), preCommitOf(facts)];
103
+ const getGuardPaths = (facts) => [preCommitOf(facts), join(facts.root, INSTALLER_REL)];
104
+
100
105
  const formatCommand = (argv) => argv.map(quoteArg).join(' ');
101
106
  const TERMINAL_STEP = 'run in a terminal of your own: ';
102
107
 
@@ -148,8 +153,10 @@ const FORMS = Object.freeze({
148
153
  userStep: (facts) => 'your own step — the doctor has no install it can run for you here (the reason is this item\'s text) — '
149
154
  + `${TERMINAL_STEP}cd ${quoteArg(facts.root)} && ${formatCommand(['node', join(facts.toolsDir, 'autonomy-doctor.mjs')])}`,
150
155
  }),
151
- 'commit-guard': null,
152
- enforcement: null,
156
+ enforcement: createForm('git-hooks.mjs',
157
+ ['--enforcement', '--cwd', '<root>'], ['--apply', '--enforcement', '--cwd', '<root>'], getEnforcementPaths),
158
+ 'commit-guard': createForm('git-hooks.mjs',
159
+ ['--commit-guard', '--cwd', '<root>'], ['--apply', '--commit-guard', '--cwd', '<root>'], getGuardPaths),
153
160
  });
154
161
 
155
162
  const resolveArgument = (arg, facts) => {