fix(install): add PostToolUse/Stop/PreCompact/UserPromptSubmit hooks to Claude Code adapter#243
fix(install): add PostToolUse/Stop/PreCompact/UserPromptSubmit hooks to Claude Code adapter#243Gradata wants to merge 1 commit into
Conversation
…to Claude Code adapter GRA-1233 — 'gradata install --agent claude-code' was installing ONLY PreToolUse, missing the PostToolUse auto_correct, Stop session_close, PreCompact snapshot, and UserPromptSubmit context_inject hooks. Core value proposition broken at install. Added to _base.py: auto_correct_command, session_close_command, pre_compact_command, context_inject_command helpers. Updated claude_code.py install() to write all 5 lifecycle hooks (with idempotency checks per-lifecycle). Updated uninstall() to clean all 5 instead of just PreToolUse. Test: test_hook_adapters.py test_claude_code_install_writes_pre_compact_entry validates PreCompact entry. Full adapter contract test covers all agents.
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.
📝 WalkthroughWalkthroughThis PR extends the hook adapter system to install Claude Code hooks across five lifecycle phases—PreToolUse, PostToolUse, Stop, PreCompact, and UserPromptSubmit—instead of only PreToolUse. It adds shared command-building helpers and refactors install/uninstall logic to wire and remove hooks from all phases, with test coverage validating the new behavior. ChangesMulti-phase hook wiring foundation and implementation
🎯 2 (Simple) | ⏱️ ~12 minutes 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 (1)
Gradata/src/gradata/hooks/adapters/_base.py (1)
132-164: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winConsolidate the five near-identical command builders.
hook_command,auto_correct_command,session_close_command,pre_compact_command, andcontext_inject_commanddiffer only in the trailing module name. A single parametrized helper removes the copy-paste and keeps quoting/BRAIN_DIRlogic in one place as more lifecycle phases are added.♻️ Proposed consolidation
+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 _module_command(brain_dir, "inject_brain_rules") + + +def auto_correct_command(brain_dir: Path) -> str: + return _module_command(brain_dir, "auto_correct") + + +def session_close_command(brain_dir: Path) -> str: + return _module_command(brain_dir, "session_close") + + +def pre_compact_command(brain_dir: Path) -> str: + return _module_command(brain_dir, "pre_compact") + + +def context_inject_command(brain_dir: Path) -> str: + 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, Consolidate the five near-identical builders by adding a single helper (e.g., build_hook_command or command_for_module) that accepts the module name and brain_dir and returns the quoted command using shlex.quote(str(brain_dir)), shlex.quote(sys.executable) and the "-m gradata.hooks.<module>" suffix; then refactor hook_command, auto_correct_command, session_close_command, pre_compact_command, and context_inject_command to each call that helper with their respective module string (e.g., "inject_brain_rules", "auto_correct", "session_close", "pre_compact", "context_inject") so all BRAIN_DIR and quoting logic is centralized.
🤖 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 hook's matcher only matches "Edit" or
"Write" so MultiEdit never triggers; update the matcher used when building
post_tool (variable post_tool in claude_code.py) to include "MultiEdit" (e.g.,
change "matcher": "Edit|Write" to include MultiEdit) so the hook fires for
EDIT_TOOLS entries like "MultiEdit" handled by extract_correction(); ensure the
auto_correct_command(brain_dir) hook entry (id: sig) remains unchanged.
In `@Gradata/tests/test_hook_adapters.py`:
- Around line 92-112: The test test_claude_code_install_writes_pre_compact_entry
currently only asserts the PreCompact hook; extend it to also assert that a
PostToolUse entry is written and contains the expected matcher/command to cover
the auto_correct wiring: after loading settings =
json.loads(config_path.read_text(...)) check settings["hooks"]["PostToolUse"]
exists, inspect its entries for the expected matcher (e.g., "MultiEdit" / the
matcher name used in claude_code.py) and that one of its commands contains the
PostToolUse-related module path (e.g., "gradata.hooks.post_tool_use" or the
exact command string produced by get_adapter("claude-code").install), mirroring
the existing pattern used for PreCompact to lock in the multi-phase contract.
---
Outside diff comments:
In `@Gradata/src/gradata/hooks/adapters/_base.py`:
- Around line 132-164: Consolidate the five near-identical builders by adding a
single helper (e.g., build_hook_command or command_for_module) that accepts the
module name and brain_dir and returns the quoted command using
shlex.quote(str(brain_dir)), shlex.quote(sys.executable) and the "-m
gradata.hooks.<module>" suffix; then refactor hook_command,
auto_correct_command, session_close_command, pre_compact_command, and
context_inject_command to each call that helper with their respective module
string (e.g., "inject_brain_rules", "auto_correct", "session_close",
"pre_compact", "context_inject") so all BRAIN_DIR and quoting logic is
centralized.
🪄 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: 24fbaf65-efa6-4d1c-8081-9cbc845c6a88
📒 Files selected for processing (3)
Gradata/src/gradata/hooks/adapters/_base.pyGradata/src/gradata/hooks/adapters/claude_code.pyGradata/tests/test_hook_adapters.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 ubuntu-latest / py3.11
- GitHub Check: pytest ubuntu-latest / py3.12
- GitHub Check: pytest macos-latest / py3.11
- GitHub Check: pytest windows-latest / py3.12
- GitHub Check: pytest macos-latest / py3.12
- GitHub Check: pytest windows-latest / py3.11
- GitHub Check: pytest (py3.12)
- GitHub Check: pytest (py3.11)
🧰 Additional context used
📓 Path-based instructions (2)
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_hook_adapters.py
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/_base.pyGradata/src/gradata/hooks/adapters/claude_code.py
🔇 Additional comments (2)
Gradata/src/gradata/hooks/adapters/claude_code.py (1)
160-196: LGTM!Gradata/tests/test_hook_adapters.py (1)
47-58: LGTM!Also applies to: 61-76
| 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 hooks matcher regex tool name matching exact or substring Edit MultiEdit
💡 Result:
In Claude Code, the matcher field in hook configurations determines which tool names trigger a specific hook [1][2]. Its behavior is determined by the content of the string provided [1]: 1. Exact Match or Pipe-Separated List: If the matcher string contains only letters, digits, underscores (_), or pipe characters (|), it is treated as an exact match or a list of specific tools [1][2]. For example, "Edit|MultiEdit|Write" will match any of those three tools exactly [3][4]. 2. Regular Expression: If the matcher string contains any character other than those listed above (such as a dot, caret, or asterisk), it is evaluated as a JavaScript regular expression [1][2]. For instance, "^Notebook" matches any tool name starting with "Notebook", and "mcp." matches any MCP tool [1][2]. When using tools like Edit and MultiEdit in hooks, you can group them using the pipe operator ("Edit|MultiEdit") to apply the same hook logic to both [3][4]. If you omit the matcher or set it to "" or "", the hook will fire for every occurrence of the event [1][2]. It is important to note that the matcher runs against the tool_name field provided in the JSON input to the hook [1][2]. Because non-regex matchers require exact matches, using patterns like "mcpmy_server" will fail to match "mcpmy_servertoolname"; in such cases, you must use a regex pattern like "mcpmy_server.*" to ensure a match [5].
Citations:
- 1: https://code.claude.com/docs/en/hooks.md
- 2: https://code.claude.com/docs/en/hooks
- 3: https://docs.gitbutler.com/features/ai-integration/claude-code-hooks
- 4: https://news.ycombinator.com/item?id=44429225
- 5: Hook matcher documentation doesn't clarify regex syntax — leads to silent failures with MCP tools anthropics/claude-code#44446
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show relevant sections of the adapter
sed -n '1,220p' Gradata/src/gradata/hooks/adapters/claude_code.py | nl -ba | sed -n '1,220p' > /tmp/claude_code_head.txt
python3 - <<'PY'
import itertools, pathlib
path = pathlib.Path("Gradata/src/gradata/hooks/adapters/claude_code.py")
lines = path.read_text().splitlines()
def dump(start,end):
for i in range(start-1,end):
if 0<=i<len(lines):
print(f"{i+1:4d}: {lines[i]}")
dump(1,140)
print("\n---\n")
dump(80,130)
print("\n---\n")
dump(20,60)
PYRepository: Gradata/gradata
Length of output: 102
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import pathlib
path = pathlib.Path("Gradata/src/gradata/hooks/adapters/claude_code.py")
lines = path.read_text().splitlines()
def dump(start, end):
for i in range(start-1, end):
if 0 <= i < len(lines):
print(f"{i+1:4d}: {lines[i]}")
print("== dump 1-140 ==")
dump(1, 140)
print("\n== dump 80-130 ==")
dump(80, 130)
print("\n== dump 20-60 ==")
dump(20, 60)
PYRepository: Gradata/gradata
Length of output: 9335
PostToolUse matcher drops MultiEdit edits
In Gradata/src/gradata/hooks/adapters/claude_code.py (lines 91-103), the PostToolUse hook uses "matcher": "Edit|Write", so it triggers only for tool_name exactly Edit or Write. The adapter explicitly supports MultiEdit (EDIT_TOOLS includes "MultiEdit" and extract_correction() handles it), but the hook never fires for MultiEdit.
🐛 Proposed fix
- "matcher": "Edit|Write",
+ "matcher": "Edit|MultiEdit|Write",🤖 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 hook's matcher only matches "Edit" or "Write" so MultiEdit never
triggers; update the matcher used when building post_tool (variable post_tool in
claude_code.py) to include "MultiEdit" (e.g., change "matcher": "Edit|Write" to
include MultiEdit) so the hook fires for EDIT_TOOLS entries like "MultiEdit"
handled by extract_correction(); ensure the auto_correct_command(brain_dir) hook
entry (id: sig) remains unchanged.
| def test_claude_code_install_writes_pre_compact_entry(tmp_path: Path) -> None: | ||
| brain_dir = tmp_path / "brain" | ||
| brain_dir.mkdir() | ||
| config_path = tmp_path / ".claude" / "settings.json" | ||
|
|
||
| adapter = get_adapter("claude-code") | ||
| first = adapter.install(brain_dir, config_path) | ||
| second = adapter.install(brain_dir, config_path) | ||
|
|
||
| assert first.action == "added" | ||
| assert second.action == "already_present" | ||
| settings = json.loads(config_path.read_text(encoding="utf-8")) | ||
| pre_compact = settings["hooks"]["PreCompact"] | ||
| commands = [ | ||
| hook.get("command", "") | ||
| for entry in pre_compact | ||
| for hook in entry.get("hooks", []) | ||
| ] | ||
| assert len(pre_compact) == 1 | ||
| assert any("BRAIN_DIR=" in command for command in commands) | ||
| assert any("gradata.hooks.pre_compact" in command for command in commands) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Extend coverage to the PostToolUse phase.
The test only asserts the PreCompact entry, yet the PR's core fix is wiring PostToolUse/auto_correct. Asserting the PostToolUse matcher and command would lock in the multi-phase contract and would have surfaced the MultiEdit matcher gap flagged in claude_code.py.
💚 Suggested additional assertions
assert any("BRAIN_DIR=" in command for command in commands)
assert any("gradata.hooks.pre_compact" in command for command in commands)
+
+ post_tool = settings["hooks"]["PostToolUse"]
+ assert len(post_tool) == 1
+ assert "MultiEdit" in post_tool[0]["matcher"]
+ post_commands = [
+ hook.get("command", "")
+ for entry in post_tool
+ for hook in entry.get("hooks", [])
+ ]
+ assert any("gradata.hooks.auto_correct" in command for command in post_commands)🤖 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_hook_adapters.py` around lines 92 - 112, The test
test_claude_code_install_writes_pre_compact_entry currently only asserts the
PreCompact hook; extend it to also assert that a PostToolUse entry is written
and contains the expected matcher/command to cover the auto_correct wiring:
after loading settings = json.loads(config_path.read_text(...)) check
settings["hooks"]["PostToolUse"] exists, inspect its entries for the expected
matcher (e.g., "MultiEdit" / the matcher name used in claude_code.py) and that
one of its commands contains the PostToolUse-related module path (e.g.,
"gradata.hooks.post_tool_use" or the exact command string produced by
get_adapter("claude-code").install), mirroring the existing pattern used for
PreCompact to lock in the multi-phase contract.
Closes GRA-1233
Problem
gradata install --agent claude-codeonly installed PreToolUse hooks. PostToolUse auto_correct was missing — no correction capture on install.Fix
auto_correct_command,session_close_command,pre_compact_command,context_inject_commandto_base.pyclaude_code.py install()to write all 5 lifecycle hooks with per-lifecycle idempotencyuninstall()to clean all 5 instead of just PreToolUseTest
test_hook_adapters.py— 10/10 pass, includingtest_claude_code_install_writes_pre_compact_entry