${CLAUDE_PROJECT_DIR} resolves against a stale, drifted cwd in exec-form hooks after a mid-session cd

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 0 comments · opened Jul 26, 2026

${CLAUDE_PROJECT_DIR} resolves against a stale, drifted cwd in exec-form hooks after a mid-session cd

Environment

  • Claude Code: 2.1.220
  • OS: macOS (Darwin), arm64
  • Node: v24.17.0
  • Hook form: "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/<name>.mjs"] (exec form — no shell involved, confirmed via docs: hooks.md)

Summary

${CLAUDE_PROJECT_DIR} is documented to let hook scripts be referenced "regardless of the working directory when the hook runs" (hooks.md). In practice, in the main session, once the Bash tool's cwd drifts away from the project root via a cd (which persists across Bash calls by design — tools-reference.md), every hook fired by a subsequent tool call resolves ${CLAUDE_PROJECT_DIR} against that drifted directory instead of the actual project root, breaking module resolution for any hook registered in exec form (args array, no shell — so no ${VAR:-fallback} bash trick is possible inside the hook definition itself).

Repro

  1. In a repo with a .claude/settings.json PreToolUse hook registered in exec form, e.g.:

``json
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/my-hook.mjs"] }
]
}
``

  1. In the main session, run a Bash command that cds into a subdirectory of the project and stays there, e.g.:

``
cd some/subdirectory && <any command>
``

  1. Trigger an Edit/Write (or whatever the hook's matcher covers) in the same session, without an intervening cd back to the project root.

Expected: the hook resolves ${CLAUDE_PROJECT_DIR}/.claude/hooks/my-hook.mjs to the real project root, as documented.

Actual: the hook fails with Cannot find module '<project-root>/some/subdirectory/.claude/hooks/my-hook.mjs'${CLAUDE_PROJECT_DIR} resolved against the drifted cwd, producing a nonexistent nested path.

Confirmed via a real session transcript: a legitimate cd into a tooling subdirectory (to run that subdirectory's own test suite) was immediately followed by 11 hook failures on subsequent tool calls, all with the drifted subdirectory baked into the failing path. A later cd back to the project root resolved the issue for subsequent calls.

Why this is surprising given the docs

The docs frame ${CLAUDE_PROJECT_DIR} as stable/independent of runtime cwd, but the only related documented behavior (Bash cwd carry-over in the main session) is never connected to hook path resolution. Nothing in the hooks docs suggests ${CLAUDE_PROJECT_DIR} is sensitive to Bash-tool cwd drift.

Workaround found

Setting CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR=1 (documented in tools-reference.md) disables the cwd carry-over in the main session, which removes the trigger for this specific variant. This is a behavior trade-off (loses cd persistence across separate Bash calls), not a fix to ${CLAUDE_PROJECT_DIR}'s resolution itself, and doesn't address other possible triggers (e.g. worktree-flag-related, see below).

Possibly related (not confirmed identical — filing separately since none is an exact match)

  • #50960 — cwd drift mid-session causing relative (no placeholder) hook commands to resolve wrong. Closed, not planned.
  • #33815 — $CLAUDE_PROJECT_DIR empty/unset in hooks and Bash. Closed, not planned.
  • #27343 — $CLAUDE_PROJECT_DIR points at the main repo instead of the worktree under claude -w. Closed, not planned.
  • #36360 — hook MODULE_NOT_FOUND in a worktree because a referenced helper doesn't exist there. Closed, duplicate.
  • #22343 — ExitPlanMode hook runs with cwd ~ instead of the project. Closed, duplicate.
  • #6023 — $CLAUDE_PROJECT_DIR "not recognized" with hooks enabled. Closed, duplicate.

This report differs from all of the above in that (a) the hook definition already uses the documented ${CLAUDE_PROJECT_DIR} placeholder correctly (not a relative path), (b) the trigger is a plain in-session cd, not a worktree flag, and (c) the value isn't empty/unset — it resolves to a real (but wrong) directory. Given the number of adjacent-but-not-identical closed reports in this area, it seems like ${CLAUDE_PROJECT_DIR}'s resolution during a session may benefit from being anchored once at session start (or re-derived via the git toplevel) rather than tracking live session/tool cwd — happy to provide the full transcript privately if useful for reproduction.

View original on GitHub ↗