test: snapshot Claude Code install hooks#239
Conversation
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
📝 Walkthrough
WalkthroughThis PR extends Claude Code hook installation to manage five distinct lifecycle phases ( ChangesClaude Code Multi-Lifecycle Hook Wiring
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 OpenGrep (1.22.0)OpenGrep fatal error (exit code 2): �[32m✔�[39m �[1mOpengrep OSS�[0m �[1m Loading rules from local config...�[0m Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
Gradata/src/gradata/hooks/adapters/claude_code.py (1)
148-153: 🧹 Nitpick | 🔵 Trivial | 💤 Low valueStale
uninstall()docstring. It still describes single-lifecycle behavior ("drop the signature-matching PreToolUse entry", "User-owned PreToolUse entries"), but the function now sweeps all five lifecycles.📝 Suggested wording
- """Reverse ``install()``: drop the signature-matching PreToolUse entry. + """Reverse ``install()``: drop signature-matching entries across all + managed lifecycles (PreToolUse, PostToolUse, Stop, PreCompact, UserPromptSubmit). Idempotent — calling on an already-clean config returns ``already_present`` (semantically: 'already in the desired absent state'). Empty containers - are pruned. User-owned PreToolUse entries (without our signature) are - preserved verbatim. + are pruned. User-owned entries (without our signature) are preserved verbatim. """🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Gradata/src/gradata/hooks/adapters/claude_code.py` around lines 148 - 153, Update the stale uninstall() docstring to reflect current behavior: state that uninstall() now sweeps all five lifecycles (not just a single lifecycle) and removes signature-matching PreToolUse entries across those lifecycle containers; keep that it is idempotent (returns the "already_present"/absent-state when nothing to remove), note that empty lifecycle containers are pruned, and clarify that PreToolUse entries without our signature (user-owned) are preserved unchanged; reference the function name uninstall() and the PreToolUse concept in the text.Gradata/src/gradata/hooks/adapters/_base.py (1)
132-164: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winCollapse the five near-identical command builders into one parameterized helper.
hook_command,auto_correct_command,session_close_command,pre_compact_command, andcontext_inject_commanddiffer only in the trailing module name. A single private builder removes the copy/paste and keeps theBRAIN_DIR=.../shlex.quotewiring in one place.♻️ Proposed refactor
+def _module_command(brain_dir: Path, module: str) -> str: + return ( + f"BRAIN_DIR={shlex.quote(str(brain_dir))} " + f"{shlex.quote(sys.executable)} -m gradata.hooks.{module}" + ) + + def hook_command(brain_dir: Path) -> str: - return ( - f"BRAIN_DIR={shlex.quote(str(brain_dir))} " - f"{shlex.quote(sys.executable)} -m gradata.hooks.inject_brain_rules" - ) + return _module_command(brain_dir, "inject_brain_rules") def auto_correct_command(brain_dir: Path) -> str: - return ( - f"BRAIN_DIR={shlex.quote(str(brain_dir))} " - f"{shlex.quote(sys.executable)} -m gradata.hooks.auto_correct" - ) + return _module_command(brain_dir, "auto_correct") def session_close_command(brain_dir: Path) -> str: - return ( - f"BRAIN_DIR={shlex.quote(str(brain_dir))} " - f"{shlex.quote(sys.executable)} -m gradata.hooks.session_close" - ) + return _module_command(brain_dir, "session_close") def pre_compact_command(brain_dir: Path) -> str: - return ( - f"BRAIN_DIR={shlex.quote(str(brain_dir))} " - f"{shlex.quote(sys.executable)} -m gradata.hooks.pre_compact" - ) + return _module_command(brain_dir, "pre_compact") def context_inject_command(brain_dir: Path) -> str: - return ( - f"BRAIN_DIR={shlex.quote(str(brain_dir))} " - f"{shlex.quote(sys.executable)} -m gradata.hooks.context_inject" - ) + return _module_command(brain_dir, "context_inject")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Gradata/src/gradata/hooks/adapters/_base.py` around lines 132 - 164, The five near-identical builders (hook_command, auto_correct_command, session_close_command, pre_compact_command, context_inject_command) should be collapsed into a single private helper (e.g. _make_command(module_name: str, brain_dir: Path) or _command_for(module: str, brain_dir: Path)) that constructs the string using shlex.quote(str(brain_dir)) and shlex.quote(sys.executable) -m gradata.hooks.{module}; then have each public function simply call that helper with their respective module name to preserve existing function names/API. Ensure the helper lives in the same module and that all existing function names are retained as thin wrappers calling the helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Gradata/src/gradata/hooks/adapters/claude_code.py`:
- Around line 91-103: The PostToolUse matcher currently appended to post_tool
uses "Edit|Write" which misses MultiEdit; update the matcher string in the block
that appends to post_tool (the branch guarded by has_post_tool) to include
"MultiEdit" (e.g., "Edit|Write|MultiEdit") so Claude Code auto-correction
triggers for multi-edit operations, and regenerate/update any Claude Code
hook/settings snapshot that pins this matcher string; the change affects the
block that calls auto_correct_command(brain_dir) and uses the id sig.
In `@Gradata/tests/test_install_claude_code_snapshot.py`:
- Around line 96-104: The normalization currently hardcodes "/tmp" and fails on
non-Linux temp dirs; update the test to normalize using the actual brain/temp
directory (thread the test's tmp_path/brain value through
_assert_matches_snapshot or compute brain_dir = str(tmp_path / "brain")) and use
that value in the two re.sub calls (escape it with re.escape) instead of the
literal "/tmp"; modify the patterns that touch serialized (the two re.sub calls
operating on serialized and the hook signature replacement) to replace
occurrences of the resolved brain_dir with "__BRAIN_DIR__" so snapshots are
portable across platforms.
---
Outside diff comments:
In `@Gradata/src/gradata/hooks/adapters/_base.py`:
- Around line 132-164: The five near-identical builders (hook_command,
auto_correct_command, session_close_command, pre_compact_command,
context_inject_command) should be collapsed into a single private helper (e.g.
_make_command(module_name: str, brain_dir: Path) or _command_for(module: str,
brain_dir: Path)) that constructs the string using shlex.quote(str(brain_dir))
and shlex.quote(sys.executable) -m gradata.hooks.{module}; then have each public
function simply call that helper with their respective module name to preserve
existing function names/API. Ensure the helper lives in the same module and that
all existing function names are retained as thin wrappers calling the helper.
In `@Gradata/src/gradata/hooks/adapters/claude_code.py`:
- Around line 148-153: Update the stale uninstall() docstring to reflect current
behavior: state that uninstall() now sweeps all five lifecycles (not just a
single lifecycle) and removes signature-matching PreToolUse entries across those
lifecycle containers; keep that it is idempotent (returns the
"already_present"/absent-state when nothing to remove), note that empty
lifecycle containers are pruned, and clarify that PreToolUse entries without our
signature (user-owned) are preserved unchanged; reference the function name
uninstall() and the PreToolUse concept in the text.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: b5729f21-7514-4bf3-9555-641150e96a57
📒 Files selected for processing (4)
Gradata/src/gradata/hooks/adapters/_base.pyGradata/src/gradata/hooks/adapters/claude_code.pyGradata/tests/snapshots/install_claude_code_settings.jsonGradata/tests/test_install_claude_code_snapshot.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
- GitHub Check: pytest (py3.12)
- GitHub Check: pytest (py3.11)
- GitHub Check: pytest windows-latest / py3.12
- GitHub Check: pytest ubuntu-latest / py3.11
- GitHub Check: pytest ubuntu-latest / py3.12
- GitHub Check: pytest macos-latest / py3.11
- GitHub Check: pytest macos-latest / py3.12
- GitHub Check: pytest windows-latest / py3.11
🧰 Additional context used
📓 Path-based instructions (2)
Gradata/src/**/*.py
📄 CodeRabbit inference engine (Gradata/AGENTS.md)
Gradata/src/**/*.py: Prefersentence-transformersfor local embeddings,google-genaifor Gemini embeddings,cryptographyfor AES-GCM encrypted system.db,bm25sfor BM25 rule ranking, andmem0aifor external memory adapters — guard all optional dependency imports withtry / except ImportErrorat the call site, never at module level
Maintain strict layering: Layer 0 (Primitives: _types.py, _db.py, _events.py, _paths.py, _file_lock.py; Patterns: contrib/patterns/) must never import from Layer 1 (Enhancements: enhancements/, rules/) or Layer 2 (Public API: brain.py, cli.py, daemon.py, mcp_server.py)
Never use bareexcept: pass— use typed exceptions or at minimumlogger.warning(...)withexc_info=Trueto avoid silent failure in a memory product
Never import from out-of-scope sibling directories../Sprites/or../Hausgem/withingradata/*code — that is a layering bug
Never leak private-sibling paths into public docs/code — no references to../Sprites/,../Hausgem/, email addresses, OneDrive paths, or Sprites-specific examples from insidegradata/*
Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes
Files:
Gradata/src/gradata/hooks/adapters/claude_code.pyGradata/src/gradata/hooks/adapters/_base.py
Gradata/tests/**/*.py
📄 CodeRabbit inference engine (Gradata/AGENTS.md)
Gradata/tests/**/*.py: SetBRAIN_DIRenvironment variable viatmp_pathin conftest.py for test isolation — ensure_paths.pymodule cache refreshes when callingBrain.init()directly inside tests
Add unit tests intests/test_*.pyfor every CI push without LLM calls (deterministic); mark integration tests with@pytest.mark.integrationand skip them by default (they hit real LLM APIs)
Files:
Gradata/tests/test_install_claude_code_snapshot.py
🔇 Additional comments (3)
Gradata/tests/snapshots/install_claude_code_settings.json (1)
3-14: The"Edit|Write"matcher pinned here is downstream of thePostToolUsematcher issue flagged inclaude_code.py; update this snapshot together with that fix. Otherwise the snapshot faithfully mirrors adapter output.Gradata/tests/test_install_claude_code_snapshot.py (1)
123-147: LGTM!Gradata/src/gradata/hooks/adapters/claude_code.py (1)
78-140: ⚡ Quick winAvoid relying on an undocumented per-hook
idfield for signature matching/uninstall in Claude Code.In
Gradata/src/gradata/hooks/adapters/claude_code.py(lines 78-140), the signaturegradata:claude-code:<brain_dir>is stored only in anidkey on each hook entry. Claude Code’s published hooks/settings schema does not define a user-configurable per-hookid, so validation/normalization could ignore/drop it; that would break yourhas_*checks anduninstall()matching and can leave duplicate/orphaned hooks. Embed the signature in a field Claude Code preserves (e.g., in thecommandstring, or via a documented matcher likeif) and treatidas best-effort.
| if not has_post_tool: | ||
| post_tool.append( | ||
| { | ||
| "matcher": "Edit|Write", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": auto_correct_command(brain_dir), | ||
| "id": sig, | ||
| } | ||
| ], | ||
| } | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Claude Code PostToolUse matcher regex - does "Edit|Write" match the MultiEdit tool or must MultiEdit be listed explicitly
💡 Result:
In Claude Code, the "Edit|Write" matcher does not automatically include "MultiEdit" [1][2]. If you want your hook to trigger for MultiEdit operations, you must list it explicitly in the matcher, such as "Edit|Write|MultiEdit" [3][4][2]. While the matcher field supports regular expressions [5][6], users have frequently reported that relying on simplified patterns like "Edit|Write" can lead to unexpected behavior where only some tools are matched [1][7]. To ensure consistent execution across all relevant file-editing tools, explicitly including each desired tool name—specifically "Edit", "Write", and "MultiEdit"—is the recommended best practice [4][2].
Citations:
- 1: PostToolUse hook with matcher 'Write|Edit' only fires for Write, silently ignores Edit anthropics/claude-code#33585
- 2: https://www.facebook.com/groups/vibecodinglife/posts/1991614234760437/
- 3: https://pub.towardsai.net/the-4-hooks-every-claude-code-project-needs-665f8d610f8b
- 4: https://alirezarezvani.medium.com/the-claude-code-hooks-nobody-talks-about-my-6-month-production-report-30eb8b4d9b30
- 5: https://github.com/gotalab/claude-code-spec/blob/4fc9fa37/docs/claude-code/hooks.md
- 6: https://code.claude.com/docs/en/hooks
- 7: PostToolUse hooks not triggering for Edit operations on .cs files anthropics/claude-code#5314
Fix Claude Code PostToolUse matcher to trigger on MultiEdit
PostToolUse uses matcher "Edit|Write", and Claude Code does not treat that pattern as implicitly including MultiEdit—so auto-correction won’t run for multi-edit operations unless MultiEdit is listed explicitly.
🐛 Proposed fix
- "matcher": "Edit|Write",
+ "matcher": "Edit|MultiEdit|Write",Update any corresponding Claude Code hook/settings snapshot that pins this matcher string as well.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Gradata/src/gradata/hooks/adapters/claude_code.py` around lines 91 - 103, The
PostToolUse matcher currently appended to post_tool uses "Edit|Write" which
misses MultiEdit; update the matcher string in the block that appends to
post_tool (the branch guarded by has_post_tool) to include "MultiEdit" (e.g.,
"Edit|Write|MultiEdit") so Claude Code auto-correction triggers for multi-edit
operations, and regenerate/update any Claude Code hook/settings snapshot that
pins this matcher string; the change affects the block that calls
auto_correct_command(brain_dir) and uses the id sig.
| serialized = json.dumps(settings, indent=2, sort_keys=True) | ||
| # Normalize: BRAIN_DIR=/tmp/pytest-N/.../brain → BRAIN_DIR=__BRAIN_DIR__ | ||
| serialized = re.sub(r"BRAIN_DIR=/tmp/[^ ]+/brain", "BRAIN_DIR=__BRAIN_DIR__", serialized) | ||
| # Normalize: hook signature ID | ||
| serialized = re.sub( | ||
| r'"gradata:claude-code:/tmp/[^"]+brain"', | ||
| '"gradata:claude-code:__BRAIN_DIR__"', | ||
| serialized, | ||
| ) |
There was a problem hiding this comment.
Normalization hardcodes /tmp, so the snapshot test fails off Linux.
tmp_path lives under tempfile.gettempdir(), which is not /tmp on macOS (typically /var/folders/..., and Path.resolve() yields /private/var/...). The two re.sub patterns anchored on /tmp/ won't match there, leaving raw temp paths in the serialized output and breaking the snapshot equality assertion on developer machines. Normalize on the actual brain/temp dir instead of a literal prefix.
🛠️ More portable normalization
-def _normalized_snapshot(settings: dict) -> str:
+def _normalized_snapshot(settings: dict, brain_dir: Path | None = None) -> str:
"""Return normalized settings.json snapshot text.
Brain-directory paths are normalized to a stable ``__BRAIN_DIR__``
placeholder so the snapshot file doesn't change on every test run
(tmp_paths are random per pytest invocation).
"""
import re
serialized = json.dumps(settings, indent=2, sort_keys=True)
- # Normalize: BRAIN_DIR=/tmp/pytest-N/.../brain → BRAIN_DIR=__BRAIN_DIR__
- serialized = re.sub(r"BRAIN_DIR=/tmp/[^ ]+/brain", "BRAIN_DIR=__BRAIN_DIR__", serialized)
- # Normalize: hook signature ID
- serialized = re.sub(
- r'"gradata:claude-code:/tmp/[^"]+brain"',
- '"gradata:claude-code:__BRAIN_DIR__"',
- serialized,
- )
+ if brain_dir is not None:
+ for p in {str(brain_dir), brain_dir.resolve().as_posix()}:
+ serialized = serialized.replace(p, "__BRAIN_DIR__")
+ else:
+ # Fallback: strip any absolute temp path ending in /brain.
+ serialized = re.sub(r"(/[^\s\"]+)?/brain\b", "__BRAIN_DIR__", serialized)
return serialized + "\n"Thread brain through _assert_matches_snapshot accordingly.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Gradata/tests/test_install_claude_code_snapshot.py` around lines 96 - 104,
The normalization currently hardcodes "/tmp" and fails on non-Linux temp dirs;
update the test to normalize using the actual brain/temp directory (thread the
test's tmp_path/brain value through _assert_matches_snapshot or compute
brain_dir = str(tmp_path / "brain")) and use that value in the two re.sub calls
(escape it with re.escape) instead of the literal "/tmp"; modify the patterns
that touch serialized (the two re.sub calls operating on serialized and the hook
signature replacement) to replace occurrences of the resolved brain_dir with
"__BRAIN_DIR__" so snapshots are portable across platforms.
Closes Paperclip GRA-1211.\n\nAdds a snapshot test for
gradata install --agent claude-codehook coverage and updates the Claude Code adapter install/uninstall wiring so SDK install covers the lifecycles previously split across SDK/plugin behavior.\n\nVerification:\n-python3 -m pytest tests/test_install_claude_code_snapshot.py tests/test_hook_adapters.py -v→ 12 passed\n\nNotes:\n- Claude Code uses theStophook lifecycle for session-end behavior; the snapshot pinsStop→gradata.hooks.session_close.