#3411 · playground · 2026-08-10

Three ways to resolve a directory. One had never run.

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.

The resolver

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

Four hypotheses, three refuted before the fourth landed

Not the dead-hook class (#959)

CwdChanged is registered in both hooks.json and entries/lifecycle.ts, and is present in the installed bundle.

Not a state-path mismatch

Writer and reader both compute getStateFilePath(repoSlugFromCwd(projectDir), sessionId), character for character.

Not a field-name mismatch (#3372)

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.

Not our handler either, which is the positive control

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.

The measurement that ended it

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.

What it cost, from the guard's own log

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 fix, and where it refuses to guess

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.

commandeffectwhy
cd /a && git pushset /atop-level, persists
cd a && cd bset <base>/a/brelative targets compound, like bash
sh -c 'cd /a && ...'unchangedthe parent shell does not move
(cd /a && ...)unchangedscoped to the subshell
cd /nonexistentunchangedthat cd failed
pushd / cd - / bare cddrop the recordunmodelable, so fail closed

Two claims in my own issue that measurement refuted

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.

Why it survived two months of green tests

The test wrote the state the real system never wrote

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.

Verified against a built bundle, not only vitest

stepresult
bare commit, no tracked cwdDENY (Cannot commit or push to 'main')
after cd into the worktreeshell_cwd on disk
same bare commit, and bare pushnot blocking
after cd back to the primary treeDENY 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.