Recorded while porting the two upstream drift fixes for guard-main-checkout-bash.sh (#5459 + #5712, one PR). Filed unassigned as an observation. Dedup performed first: no existing card covers CI coverage of the hook self-tests.
The gap
The hooks under .claude/hooks/ ship their own hermetic self-test matrices — guard-main-checkout-bash.selftest.sh (100 cases after the port), guard-shared-stash.selftest.sh, and the guard-main-checkout.sh pair. Each is deliberately cheap: no install, no build, no network, needs only jq and git, exit 0 = all cases hold. The headers tell you to run them after touching the hook.
Nothing runs them automatically. Measured on origin/main this round:
grep -rn 'selftest' .github/workflows/ -> no matches (objectui)
grep -rn 'selftest' .github/workflows/ -> no matches (objectstack)
So the check is entirely dependent on the author remembering, in both repos.
Why it matters
These hooks are the enforcement behind the rule CLAUDE.md and AGENTS.md state as binding — worktree-first, and never git stash. The failure mode is asymmetric and both halves are bad:
- a fail-open regression silently stops guarding the shared checkout, and nothing surfaces it — the guard keeps exiting 0, which is what it does when it allows;
- a fail-closed regression (a false block) trains an operator onto
OS_ALLOW_MAIN_EDITS=1, which switches the guard off for the whole command.
Neither shows up in a PR. A hook is not imported by any package, so no unit test, type check, or lint touches it — eslint.config.js is scoped to **/*.{ts,tsx} throughout and shell files are outside its population, and there is no shellcheck anywhere in either repo (grep -rln shellcheck .github/ package.json scripts/ returns nothing).
This is the same shape as the drift the two cards above are about: a correctness claim that lives only in a comment and in the next reader's diligence.
Suggested shape
A job that runs each .claude/hooks/*.selftest.sh on PRs that touch .claude/hooks/**. It needs a checkout, jq and git and nothing else — no install, no build — so it belongs with the cheap gates (control-bytes.yml is the closest existing model, and its header already explains why such a gate carries no paths filter on the trigger but decides inside the job).
Worth deciding at triage whether it should be one job per repo or part of the identity gate #5459 and #5712 both raise as a follow-up candidate — the two are complementary, not alternatives: that one would catch the two copies drifting apart, this one catches either copy breaking on its own.
Governed surface (.claude/**): draft PR, human merge.
Recorded while porting the two upstream drift fixes for
guard-main-checkout-bash.sh(#5459 + #5712, one PR). Filed unassigned as an observation. Dedup performed first: no existing card covers CI coverage of the hook self-tests.The gap
The hooks under
.claude/hooks/ship their own hermetic self-test matrices —guard-main-checkout-bash.selftest.sh(100 cases after the port),guard-shared-stash.selftest.sh, and theguard-main-checkout.shpair. Each is deliberately cheap: no install, no build, no network, needs onlyjqandgit, exit 0 = all cases hold. The headers tell you to run them after touching the hook.Nothing runs them automatically. Measured on
origin/mainthis round:So the check is entirely dependent on the author remembering, in both repos.
Why it matters
These hooks are the enforcement behind the rule CLAUDE.md and AGENTS.md state as binding — worktree-first, and never
git stash. The failure mode is asymmetric and both halves are bad:OS_ALLOW_MAIN_EDITS=1, which switches the guard off for the whole command.Neither shows up in a PR. A hook is not imported by any package, so no unit test, type check, or lint touches it —
eslint.config.jsis scoped to**/*.{ts,tsx}throughout and shell files are outside its population, and there is noshellcheckanywhere in either repo (grep -rln shellcheck .github/ package.json scripts/returns nothing).This is the same shape as the drift the two cards above are about: a correctness claim that lives only in a comment and in the next reader's diligence.
Suggested shape
A job that runs each
.claude/hooks/*.selftest.shon PRs that touch.claude/hooks/**. It needs a checkout,jqandgitand nothing else — no install, no build — so it belongs with the cheap gates (control-bytes.ymlis the closest existing model, and its header already explains why such a gate carries nopathsfilter on the trigger but decides inside the job).Worth deciding at triage whether it should be one job per repo or part of the identity gate #5459 and #5712 both raise as a follow-up candidate — the two are complementary, not alternatives: that one would catch the two copies drifting apart, this one catches either copy breaking on its own.
Governed surface (
.claude/**): draft PR, human merge.