Found while porting the worktree-recipe repair (#6208 / PR #6449); out of scope for that card, filed rather than fixed.
The gap
This repo has three PreToolUse guards. Two carry hermetic self-test matrices that .github/workflows/hook-selftests.yml runs on every PR touching .claude/hooks/**:
| guard | self-test | cases (measured 2026-08-26) |
|---|
.claude/hooks/guard-main-checkout-bash.sh | guard-main-checkout-bash.selftest.sh | 121 passed, 0 failed |
.claude/hooks/guard-shared-stash.sh | guard-shared-stash.selftest.sh | 41 passed, 0 failed |
.claude/hooks/guard-main-checkout.sh | none | — |
$ ls .claude/hooks/
guard-main-checkout-bash.selftest.sh
guard-main-checkout-bash.sh
guard-main-checkout.sh
guard-shared-stash.selftest.sh
guard-shared-stash.sh
$ grep -n 'run:' .github/workflows/hook-selftests.yml
125: run: .claude/hooks/guard-main-checkout-bash.selftest.sh
129: run: .claude/hooks/guard-shared-stash.selftest.sh
Nothing in CI exercises guard-main-checkout.sh at all.
Why this one is the awkward omission
guard-main-checkout.sh is the original guard — the Edit/Write/NotebookEdit one. It is the hook both CLAUDE.md and AGENTS.md §9 name by path as the mechanism that enforces worktree-first. The Bash-side guard exists to cover the same rule expressed as a shell command, and guard-main-checkout-bash.sh's own header describes itself as the second line ("Same rule as guard-main-checkout.sh — that hook is registered on Edit|Write|NotebookEdit only, so the identical edit expressed as a shell command used to slip through in silence").
So the covered guard is the follow-on and the uncovered one is the primary. #5754 added the standing CI caller and wired it to the two self-tests that existed; nothing was left recording that the third guard had none.
What the absence actually permits
Both a false-negative and a false-positive path are unguarded:
- fail-open — a parsing or path-resolution change that stops the guard classifying a shared checkout means edits land on the shared tree in silence, which is the whole failure this repo built worktree-first to stop.
- fail-closed — an over-broad match blocks edits inside legitimate linked worktrees, which reads to an agent as a broken environment.
Neither has a test that would go red. Concretely: PR #6449 edited the recipe text this guard prints (guard-main-checkout.sh:57) and no gate in this repo could have noticed a mistake in it — the whole scripts/__tests__ tree stayed green at 2318 passed, correctly, because nothing there reads the file.
Not asserting the shape of the fix
The sibling self-test is a ready template (guard-main-checkout-bash.selftest.sh builds a throwaway repo plus a linked worktree and drives the hook through a case matrix, with a jq-less fallback lane). Whether the right move is a third matrix, folding the Edit-side cases into the existing one, or something narrower is for whoever picks this up. Note hook-selftests.yml's header states a per-file case-count-recipe convention that a new self-test would need to follow, and that its recorded counts (100 / 32) are already behind the tree's (121 / 41).
Filed unassigned; no domain:* or type label set.
Generated by Claude Code
Found while porting the worktree-recipe repair (#6208 / PR #6449); out of scope for that card, filed rather than fixed.
The gap
This repo has three PreToolUse guards. Two carry hermetic self-test matrices that
.github/workflows/hook-selftests.ymlruns on every PR touching.claude/hooks/**:.claude/hooks/guard-main-checkout-bash.shguard-main-checkout-bash.selftest.sh.claude/hooks/guard-shared-stash.shguard-shared-stash.selftest.sh.claude/hooks/guard-main-checkout.shNothing in CI exercises
guard-main-checkout.shat all.Why this one is the awkward omission
guard-main-checkout.shis the original guard — theEdit/Write/NotebookEditone. It is the hook bothCLAUDE.mdandAGENTS.md§9 name by path as the mechanism that enforces worktree-first. The Bash-side guard exists to cover the same rule expressed as a shell command, andguard-main-checkout-bash.sh's own header describes itself as the second line ("Same rule as guard-main-checkout.sh — that hook is registered on Edit|Write|NotebookEdit only, so the identical edit expressed as a shell command used to slip through in silence").So the covered guard is the follow-on and the uncovered one is the primary. #5754 added the standing CI caller and wired it to the two self-tests that existed; nothing was left recording that the third guard had none.
What the absence actually permits
Both a false-negative and a false-positive path are unguarded:
Neither has a test that would go red. Concretely: PR #6449 edited the recipe text this guard prints (
guard-main-checkout.sh:57) and no gate in this repo could have noticed a mistake in it — the wholescripts/__tests__tree stayed green at 2318 passed, correctly, because nothing there reads the file.Not asserting the shape of the fix
The sibling self-test is a ready template (
guard-main-checkout-bash.selftest.shbuilds a throwaway repo plus a linked worktree and drives the hook through a case matrix, with a jq-less fallback lane). Whether the right move is a third matrix, folding the Edit-side cases into the existing one, or something narrower is for whoever picks this up. Notehook-selftests.yml's header states a per-file case-count-recipe convention that a new self-test would need to follow, and that its recorded counts (100 / 32) are already behind the tree's (121 / 41).Filed unassigned; no
domain:*or type label set.Generated by Claude Code