### Mode: commit-guard

<!-- opt-in-capability: commit-guard -->

The **read-only pre-commit guard** (strip-the-kit D10) — the last line of the loop: a commit is permitted only against the LATEST completed `run-gates --final` receipt that binds EXACTLY the current tree. It re-runs NO gate or test subprocess — the heavy D3 verification lives in the final run; the guard recomputes the tree fingerprint (read-only git plumbing) and compares file contents.

Run `node ${CLAUDE_SKILL_DIR}/tools/commit-guard.mjs --check [--cwd <dir>]` — it refuses, each with a named recovery, on:

1. **an INDEX that lags the verified working tree** — the gates and the fingerprint both describe the WORKING tree, while `git commit` builds the commit from the INDEX alone, and the fingerprint domain is identical whether a hunk sits staged or unstaged (staging a lone tracked modification does not even move it). So this arm runs FIRST, before the fingerprint is computed: it refuses when the working tree holds anything the index does not — tracked paths differing between index and worktree, or reviewable untracked-not-ignored paths (the SAME never-committable stat filter the fingerprint applies; ignored paths never refuse). An index entry carrying **skip-worktree** or **assume-unchanged** is invisible to `git diff`, so those entries are compared DIRECTLY against the worktree (type, symlink target, executable bit where `core.fileMode` says so, and the blob oid through git's own clean filters) — a de-materialised skip-worktree path is an ordinary sparse checkout and never refuses, while a missing assume-unchanged path does. The submodule probe forces `--ignore-submodules=none`, so `diff.ignoreSubmodules` / `submodule.<name>.ignore` cannot erase a dirty submodule from the comparison. Offending paths are named in THREE categories — plain lagging paths, paths held back by an index bit, and submodules — because their recoveries differ; ONE shared cap spans all of them, each category reserves a slot so no clause is ever nameless, the remainder is stated once, and every path is rendered as one escaped line. Recovery is ONE ordered sequence, and `git add -A` alone is NOT it: a bit-carrying entry must have its `skip-worktree` / `assume-unchanged` bit cleared FIRST (scoped to the paths the refusal names — never to everything `git ls-files -v` reports, which includes de-materialised sparse paths whose deletions would then be staged), then `git add -A`, then re-run `run-gates --final` at its RESOLVED path beside this tool, then commit the WHOLE tree. The listed paths are capped, so the loop is the completion signal: re-run the guard until it names none. A tracked **submodule** the index cannot prove current is named separately with its own recovery — commit or clean inside the submodule and stage the gitlink — because a root-level `git add -A` cannot capture a submodule's internal worktree changes. A submodule whose gitlink itself carries one of those index bits is **not probed at all**: it lags by construction. That is a deliberate REDUCTION — three consecutive review rounds each found a new way for a nested probe to answer "clean" wrongly (inherited superproject `GIT_*`, status config blindness, the submodule's OWN flagged entries, a symlink standing in for the directory) — and it stays a CONVERGING refusal, since clearing the bit is a recovery the guard prints and then falls silent on. An UNflagged submodule is judged by the ordinary probe exactly as before. **Fail-closed:** an undecidable git probe refuses with its own named cause, never a silent pass;
2. **a CONTENT-FREE fingerprint over a DIRTY index** — the payload carries no bytes while the index really carries a change, which means staged content the fingerprint domain cannot see: a submodule gitlink hidden from `git diff` by `submodule.<name>.ignore` or `diff.ignoreSubmodules`. No receipt can describe what that commit will carry, so the guard refuses and names the CONFIGURATION as the recovery — clear the ignore setting (or set it to `none`) until `git diff --cached --no-ext-diff` shows the change, then re-run `run-gates --final`. Re-staging is not the recovery: the change is already staged;
3. no completed final record for the CURRENT fingerprint (the tree moved after the final run — any edit re-stales it);
4. a RED latest attempt (a dead green never revives — the latest attempt at a fingerprint is authoritative);
5. fingerprint before ≠ after on the receipt (the tree moved UNDER the final run);
6. a LATER `final-start` whose attempt never completed (interrupted run / failed receipt append — an attempt of unknown outcome never lets an earlier green stand);
7. declaration content drift (the current `docs/ai/gates.json` {id, cmd} array no longer matches the receipt's recorded one);
8. evidence-hash drift (the store's canonical red-proof/degrade serializations moved under the receipt) or lcov drift (the consumed file's sha moved or vanished);
9. unsatisfied review obligations — the SAME normative decision `review-state --check` computes (configured recipe backends, ship-class-only on the latest normal receipt, veto, the explicit degrade escape), recomputed over a SANITIZED env: the guard resolves FIXED git-dir paths for its own reads and ignores `AW_REVIEW_RECEIPTS`/`AW_CORE_EVIDENCE` (producer test seams are never guard inputs — a forged out-of-repo store never satisfies).

**The empty-commit lane is a PASS that attests NOTHING — and it consults no receipt.** A content-free fingerprint over a CLEAN index means the index contributes no tree-content delta, so the commit changes no tree content: `git commit --allow-empty`, a message- or signature-only `--amend`, an empty merge. (What such a commit DOES change — its message, its parents, its signature — was never in the guard's domain.) The guard passes and says exactly that, because its whole claim is about the bytes a commit carries and here there are none. The receipt arms are SKIPPED rather than satisfied: the content-free fingerprint is the one value every clean moment shares, so a receipt found there cannot be correlated to THIS moment or base — letting it decide would make the outcome depend on which stray moment the store recorded last, the same reason `flow-check`'s `#65` rung steps over a content-free RED. The flow arm still runs (the commit still moves HEAD, and the chain bookkeeping is about that), with its own fingerprint-keyed correlations dropped through the explicit `treeCarriesBytes: false` the guard declares — the checker never derives that fact, because a routine clean-tree check still wants those rungs.

**Wiring:** this repo's dogfood rides `scripts/install-git-hooks.mjs`; a consumer install is a consented surface (init/recommendations) — the hook INSTALLER resolves the installed kit location at install time and writes the RESOLVED invocation into the hook it places (no runtime guessing). The final-run ordering that keeps the guard green is D13: stage everything FIRST → run the reviews on the staged tree → `run-gates --final` → commit immediately (any index/worktree mutation after the final run re-stales the receipt).

**The deliberate partial commit is BLOCKED — stated, accepted.** Refusal 1 means staging a subset on purpose no longer commits: a pathspec commit (`git commit --only <path>`, `git commit <path>`) hands the hook a temporary index carrying less than the verified tree, and that is exactly the capture blindness this arm closes. No opt-out flag exists, deliberately — the guard's whole claim is that the committed bytes ARE the verified bytes, and a flag that suspends it would suspend the claim. An intentional partial commit stays `--no-verify` territory.

**Boundary — what «the commit captures the tree» means for submodules.** The guard proves the SUPERPROJECT commit captures the SUPERPROJECT working tree. A superproject commit stores a submodule as a gitlink OID and never captures its file content — that content is the submodule's own commit boundary. The dirty-submodule refusal is therefore a best-effort COURTESY beyond the guard's boundary, and its completeness is bounded by what the submodule itself reports: a file held behind a `skip-worktree` / `assume-unchanged` bit in the SUBMODULE's own index is invisible to the submodule's status and so to this refusal. Deepening the nested probe was tried and abandoned — three consecutive review rounds each produced a new way for it to answer wrongly — so the honest contract is the boundary, not a completeness claim. Tracked as its own class.

**Stated residual — the fingerprint can still be blinded by config.** Refusal 1 makes the COMMIT capture the whole current working tree. It does not make the RECEIPT unforgeable: `computeFingerprintPayload` still runs its diffs without `--ignore-submodules=none`, so under `diff.ignoreSubmodules=all` a submodule can be changed and its gitlink staged AFTER a green `--final` while the fingerprint stays put, and the stale receipt is reused. That is a receipt-collision defect, not an under-capture one — no commit ships less than the working tree because of it — and closing it means moving the node payload and both bash twins together (the AD-044 lockstep). Tracked as its own class. **Narrowed:** the case where the hidden gitlink is the ONLY staged change is now closed by refusal 2 — there the payload is empty and the index is not, which is decidable without touching the payload definition. A hidden gitlink riding ALONGSIDE other visible changes still produces an ordinary fingerprint and still reuses its receipt; that remainder is what the lockstep owns.

**Human residual (stated, accepted):** `git commit --no-verify` bypasses any pre-commit hook — a self-discipline mechanism, not a security boundary.

**Invariants:** read-only · re-runs nothing · fixed git-dir reads (env overrides ignored) · exit 0 pass / 1 refused (reason + recovery named) / 2 usage.
