Skip to content

fix: resolve hookify rules from the project root#32931

Open
Atman36 wants to merge 1 commit intoanthropics:mainfrom
Atman36:fix/hookify-project-root
Open

fix: resolve hookify rules from the project root#32931
Atman36 wants to merge 1 commit intoanthropics:mainfrom
Atman36:fix/hookify-project-root

Conversation

@Atman36
Copy link
Copy Markdown

@Atman36 Atman36 commented Mar 10, 2026

What

Resolve hookify rule discovery from the project root instead of assuming the current working directory is the repo root.

Add a unit test that covers loading rules both with CLAUDE_PROJECT_DIR set and when the process starts from a nested directory.

Why

Hookify rule files live in the project's .claude/ directory, but the loader currently looks for .claude/hookify.*.local.md relative to the current working directory.

That causes hooks to silently stop finding configured rules when Claude Code is launched or resumed from a subdirectory.

How to verify

python3 -m unittest plugins.hookify.tests.test_config_loader
python3 -m py_compile plugins/hookify/core/config_loader.py plugins/hookify/tests/test_config_loader.py

Notes

No workflow or plugin manifest changes. Scope is limited to rule discovery and regression coverage.

@johnhaley81
Copy link
Copy Markdown

Adding a real-world report from a team I work with, in case it helps prioritize review.

The incident

We have a block-unsafe-type-casts hookify rule that blocks Edit/Write operations introducing as SomeType / as unknown as X TypeScript casts outside a small allowlist of boundary files. It is our primary guard against a class of bugs we've been stamping out for months, and it has been enabled and working for a long time. The regex is correct and the rule has caught plenty of violations in regular sessions.

Last week a bug fix landed in production that introduced exactly this banned pattern — a const callRpc = supabase.rpc as (...) => ... detached-method reference. The cast compiled, passed lint (because our ESLint ban only targets a hand-maintained allowlist of branded types, which is the broader lesson — but that's our problem to solve), and at runtime the SDK method ran with this === undefined, throwing Cannot read properties of undefined (reading 'rest') in ~50ms before any network request. Every call for ~8 hours failed silently with zero observable server-side evidence. Users saw an infinite loading spinner on a feature they rely on.

The thing that made me write this comment is that the hookify rule existed, was enabled, had the correct regex, and would have blocked the exact edit that shipped the bug. It just wasn't loaded. When I investigated after the fact I found this PR, and it was immediately obvious what had happened.

Why the rule didn't fire

The session that reviewed and merged the fix was started inside a git worktree at <project>/.claude/worktrees/<feature>/. That's our standard workflow — it's also what the superpowers:using-git-worktrees skill that ships in the official plugin catalog teaches, so I suspect we're not the only team hitting this.

From that cwd, glob.glob('.claude/hookify.*.local.md') resolves to <project>/.claude/worktrees/<feature>/.claude/hookify.*.local.md, which does not exist. load_rules() returns []. No log line, no warning, no error — rules are just silently gone, and every Edit tool call is allowed through.

Reproduction (about 60 seconds)

mkdir -p /tmp/repro/.claude /tmp/repro/sub/dir
cat > /tmp/repro/.claude/hookify.demo.local.md <<'EOF'
---
name: demo
enabled: true
event: file
conditions:
  - field: new_text
    operator: contains
    pattern: banned_marker
---
blocked
EOF

cd /tmp/repro/sub/dir && python3 -c "
import sys
sys.path.insert(0, '<path-to-hookify-plugin>')
from core.config_loader import load_rules
print('rules loaded:', len(load_rules()))
"
# current main:  rules loaded: 0
# this PR:       rules loaded: 1

On the fix itself

The approach — $CLAUDE_PROJECT_DIR first, then walk up from cwd, then a cwd fallback — is what I independently landed on when I patched our local plugin cache after investigating. The walk-up matters because Claude Code doesn't always set $CLAUDE_PROJECT_DIR (for example when a session is resumed from a nested shell), and walking up mirrors how every other project-rooted tool in the ecosystem resolves the root: ESLint, Prettier, git itself, .editorconfig, etc. The two unit tests in plugins/hookify/tests/test_config_loader.py cover exactly the two cases that matter: $CLAUDE_PROJECT_DIR set + cwd elsewhere, and $CLAUDE_PROJECT_DIR unset + nested cwd.

One small suggestion if there's appetite for it: printing a single stderr line like hookify: loaded 0 rules (no .claude/ found from /path/to/cwd) when the walk-up finds nothing would make the "rules silently disabled" failure mode impossible to reproduce going forward. Totally out of scope for this PR — just noting it as the thing that would have let me catch the bug in the first review session instead of after the fact.

There are two sibling PRs for nearby bugs that are worth looking at together with this one: #23972 (Python 3.8 compat + a narrower $CLAUDE_PROJECT_DIR-only version of the same fix, open since 2026-02-07) and #29095 (a different but related issue about finding global rules under ~/.claude/, open since 2026-02-26). This PR cleanly supersedes #23972's cwd-independence changes. #29095 is orthogonal and could be merged separately if the global-rules behavior is wanted.

Happy to add more data, run against a specific branch, or help with review if it would move this along. Thanks for looking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants