The branch guard denied a correct commit with "Cannot commit or push directly to 'main'" while nothing was on main. Its third resolution path was supposed to prevent exactly that. Across 1763 recorded sessions that path had never executed once, so the two paths in front of it looked like redundancy while being the entire mechanism.
1. git -C <dir> explicit, highest trust works 2. a `cd` in this command explicit, same string works 3. shell_cwd, session state the shell's ambient cwd never ran
CwdChanged is registered in both hooks.json and
entries/lifecycle.ts, and is present in the installed bundle.
Writer and reader both compute
getStateFilePath(repoSlugFromCwd(projectDir), sessionId), character for character.
input.new_cwd || '' followed by a silent return has exactly that shape, so it was the
strongest suspect. The shipped 2.1.226 binary settles it: it documents the event as "Input to
command is JSON with old_cwd and new_cwd" and builds
{hook_event_name:"CwdChanged", old_cwd:e, new_cwd:t}. The field names are right.
Fed a well-formed synthetic payload, the installed hook writes shell_cwd correctly on
the first try. Everything about it is correct. The event does not arrive.
grep -rl shell_cwd ~/.claude/state/orchestkit/ no files (1763 session dirs) grep -c cwd-changed ~/.claude/logs/ork/hooks.log 1 ^ my own synthetic probe a real persistent `cd` into a worktree: state file mtime did not move
Claude Code does not fire
CwdChanged for a cd inside a Bash tool call. Nothing else carries the shell's
cwd either: hook processes are spawned with cwd set to the project dir, which is why a bare
git push from a feature-branch worktree got judged against the primary tree.
permission-feedback.log, 2026-08-10
16:14:20 deny Blocked on protected branch: main
16:16:51 deny Blocked on protected branch: main
16:19:51 deny Blocked on protected branch: main
... 8 denials between 16:14 and 16:21, every one on correct work
The command string is the only place this information
exists, so lib/shell-cwd.ts derives a command's effect on the shell's cwd and the existing
heartbeat publisher persists it. That hook already owns the state write, so tracking adds no I/O, and
none at all for a command that neither cds nor touches git.
| command | effect | why |
|---|---|---|
cd /a && git push | set /a | top-level, persists |
cd a && cd b | set <base>/a/b | relative targets compound, like bash |
sh -c 'cd /a && ...' | unchanged | the parent shell does not move |
(cd /a && ...) | unchanged | scoped to the subshell |
cd /nonexistent | unchanged | that cd failed |
pushd / cd - / bare cd | drop the record | unmodelable, so fail closed |
REFUTED "cd X && git add -A && git commit is denied because path 2 misses" locateGitSegment carries cdTarget ACROSS segments. Measured: it resolves, and always did. Only the BARE form was ever broken. REFUTED "un-anchor the -C match" It runs against the already-sliced git segment, so ^ is correct. REAL `gh pr create --base dev` in the remediation text. Now just `gh pr create`: this hook ships to other repos, so naming ANY base can only be wrong somewhere.
git-validator-worktree.test.ts has asserted "commit from a feature-branch worktree
(shell_cwd) is not blocked" since #2363, green throughout, because it calls its own local
writeShellCwdState() helper. It validated the READER against input no writer ever
produced: half a contract, reported as the whole one.
The new contract test never writes state itself. It drives the real publisher for every case, and each assertion carries the negative control that fails without the fix.
| step | result |
|---|---|
| bare commit, no tracked cwd | DENY (Cannot commit or push to 'main') |
| after cd into the worktree | shell_cwd on disk |
| same bare commit, and bare push | not blocking |
| after cd back to the primary tree | DENY returns |
30 new tests. Full hook suite 308 files and 7601 tests green.
Found on the way, filed as #3415. run-hook.mjs gives stdin 100ms and
then runs the hook with an empty payload, silently succeeding. A python3-generated probe
loses that race, which made this working fix look broken for several minutes and sent me hunting a bug
in my own code. It matters beyond probes: under load, a late payload means a hook silently does nothing
and reports success, with no stderr line and nothing in the log to distinguish it from a real no-op.
Fourth instance of one theme today, after #3391, #3410 and this: ork keeps holding a belief about
its own environment that nothing ever checked against the environment.