The annoyance
We set a PostToolUse hook to format whatever Claude just wrote, using the documented command ${CLAUDE_PROJECT_DIR}/.claude/hooks/check-style.sh so the script is found from the project root. That works while Claude stays in the session-start checkout, and then Claude enters a worktree, edits a file there, and the formatter either misses the file, writes into the main checkout, or fails because the path is wrong. It looks like a race, but it is the documented split between two paths.
What Anthropic actually says
From the official worktrees page, under “Hook paths don't follow the worktree”:
“After Claude enters a worktree, Claude Code keeps ${CLAUDE_PROJECT_DIR} in your hooks where it was and passes the worktree path to them a different way: ${CLAUDE_PROJECT_DIR} stays put: it still points at the project root where the session started, so a hook command such as ${CLAUDE_PROJECT_DIR}/.claude/hooks/check-style.sh still runs the script in the main checkout. cwd follows Claude: the cwd field in the hook's input JSON is the worktree root, and it moves again when Claude runs cd. Read it when a hook needs the worktree path.”
The same contract sits on the hooks reference, under “Reference scripts by path”: the command is resolved from the session-start project root, not from the tree Claude is editing. Anthropic has also said this is intended. On GitHub issue 87890 they treat CLAUDE_PROJECT_DIR as the stable project root and tell us to read the worktree with jq -r .cwd.
Two failures follow from the same split. First, a formatter or test that changes directory into CLAUDE_PROJECT_DIR before it runs will operate on the main checkout while Claude is writing in the worktree. Second, a hook file that we only created inside the worktree is missing from the main checkout, so ${CLAUDE_PROJECT_DIR}/.claude/hooks/... is No such file, which is the failure reported in issue 27343.
The gate
Keep the script in the main checkout, where CLAUDE_PROJECT_DIR can find it, and do the work in .cwd.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-style.sh",
"args": []
}
]
}
]
}
}#!/bin/bash
# Script path: $CLAUDE_PROJECT_DIR (main checkout, stable).
# Work path: .cwd from stdin JSON (worktree after EnterWorktree / isolation: worktree).
INPUT=$(cat)
CWD=$(echo "$INPUT" | jq -r '.cwd')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ]; then
exit 0
fi
# Format the file Claude just edited, in the tree Claude is in.
(cd "$CWD" && npx prettier --write -- "$FILE_PATH")Then: chmod +x .claude/hooks/check-style.sh
Do not cd "$CLAUDE_PROJECT_DIR" before lint, test, or format. That is the line that sends the work to the wrong tree.
Prove it
Print both paths from a hook so we can see them diverge after EnterWorktree.
#!/bin/bash
# Add as a PostToolUse or SessionStart hook, then EnterWorktree.
INPUT=$(cat)
CWD=$(echo "$INPUT" | jq -r '.cwd')
echo "cwd=$CWD" >&2
echo "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" >&2
# They match in the main checkout. They diverge after EnterWorktree.
# then in-session: /hooksIf they match, Claude is still in the session-start checkout; if they differ, every hook that changes directory into CLAUDE_PROJECT_DIR is now working in the wrong tree. Run /hooks in the session as well, so we know Claude Code actually loaded the handler, which is the same check we use on the exit-code gate.
Why it fails
We treat CLAUDE_PROJECT_DIR as “where Claude is”, because the name invites that reading, but the contract is the opposite: it is where the session started, and it stays put so the hook script itself can be found after Claude moves. The tree Claude is editing is the cwd field in the hook JSON, and it moves again when Claude runs cd. Once we keep those two paths apart, formatters and policy hooks stop hitting the main checkout.
This is the same class of mistake as the other notes in this series: the path looks correct, and the documented field is a different one.
Sources
From idea to operation
Make the next AI decision concrete.
NavAIgate helps leadership teams identify high-value AI opportunities, prove them safely and turn the winners into working systems.
