Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Gradata/src/gradata/hooks/adapters/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,34 @@ def hook_command(brain_dir: Path) -> str:
)


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"
)


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"
)


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"
)


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"
)


def mcp_command(brain_dir: Path) -> list[str]:
return [sys.executable, "-m", "gradata.mcp_server", "--brain-dir", str(brain_dir)]

Expand Down
131 changes: 100 additions & 31 deletions Gradata/src/gradata/hooks/adapters/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
WRITE_TOOL_ALIASES,
InstallResult,
_normalize_tool_name,
auto_correct_command,
context_inject_command,
extract_from_edit_args,
extract_from_write_args,
failure,
hook_command,
hook_signature,
pre_compact_command,
session_close_command,
read_json,
write_json,
)
Expand Down Expand Up @@ -58,24 +62,84 @@ def install(brain_dir: Path, agent_config_path: Path) -> InstallResult:
data = read_json(agent_config_path)
hooks = data.setdefault("hooks", {})
pre_tool = hooks.setdefault("PreToolUse", [])
if any(sig in str(item) for item in pre_tool):
post_tool = hooks.setdefault("PostToolUse", [])
stop = hooks.setdefault("Stop", [])
pre_compact = hooks.setdefault("PreCompact", [])
user_prompt = hooks.setdefault("UserPromptSubmit", [])
has_pre_tool = any(sig in str(item) for item in pre_tool)
has_post_tool = any(sig in str(item) for item in post_tool)
has_stop = any(sig in str(item) for item in stop)
has_pre_compact = any(sig in str(item) for item in pre_compact)
has_user_prompt = any(sig in str(item) for item in user_prompt)
if has_pre_tool and has_post_tool and has_stop and has_pre_compact and has_user_prompt:
return InstallResult(
AGENT, agent_config_path, "already_present", "hook already present"
)
pre_tool.append(
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": hook_command(brain_dir),
"id": sig,
}
],
}
)
if not has_pre_tool:
pre_tool.append(
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": hook_command(brain_dir),
"id": sig,
}
],
}
)
if not has_post_tool:
post_tool.append(
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": auto_correct_command(brain_dir),
"id": sig,
}
],
}
)
Comment on lines +91 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 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:


🏁 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)
PY

Repository: 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)
PY

Repository: 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.

if not has_stop:
stop.append(
{
"hooks": [
{
"type": "command",
"command": session_close_command(brain_dir),
"id": sig,
}
],
}
)
if not has_pre_compact:
pre_compact.append(
{
"matcher": "manual|auto",
"hooks": [
{
"type": "command",
"command": pre_compact_command(brain_dir),
"id": sig,
}
],
}
)
if not has_user_prompt:
user_prompt.append(
{
"hooks": [
{
"type": "command",
"command": context_inject_command(brain_dir),
"id": sig,
}
],
}
)
write_json(agent_config_path, data)
return InstallResult(AGENT, agent_config_path, "added", "installed PreToolUse hook")
return InstallResult(AGENT, agent_config_path, "added", "installed Claude Code hooks")
except Exception as exc:
return failure(AGENT, agent_config_path, exc)

Expand All @@ -98,27 +162,32 @@ def uninstall(brain_dir: Path, agent_config_path: Path) -> InstallResult:
hooks = data.get("hooks")
if not isinstance(hooks, dict):
return InstallResult(AGENT, agent_config_path, "already_present", "no hooks block")
pre_tool = hooks.get("PreToolUse")
if not isinstance(pre_tool, list):
return InstallResult(AGENT, agent_config_path, "already_present", "no PreToolUse")

removed = 0
kept: list = []
for entry in pre_tool:
entry_str = str(entry)
if sig in entry_str:
# Either the entry's `hooks[].id` carries our sig, or the
# whole entry was ours. Drop it.
removed += 1
for lifecycle in (
"PreToolUse",
"PostToolUse",
"Stop",
"PreCompact",
"UserPromptSubmit",
):
entries = hooks.get(lifecycle)
if not isinstance(entries, list):
continue
kept.append(entry)
kept: list = []
for entry in entries:
entry_str = str(entry)
if sig in entry_str:
# Either the entry's `hooks[].id` carries our sig, or the
# whole entry was ours. Drop it.
removed += 1
continue
kept.append(entry)
if kept:
hooks[lifecycle] = kept
else:
hooks.pop(lifecycle, None)
if removed == 0:
return InstallResult(AGENT, agent_config_path, "already_present", "hook not present")

if kept:
hooks["PreToolUse"] = kept
else:
hooks.pop("PreToolUse", None)
if not hooks:
data.pop("hooks", None)
write_json(agent_config_path, data)
Expand Down
49 changes: 48 additions & 1 deletion Gradata/tests/test_hook_adapters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
import os
import tomllib
from pathlib import Path
Expand Down Expand Up @@ -43,13 +44,36 @@ def test_codex_adapter_writes_valid_toml_with_quoted_brain_path(tmp_path: Path)

assert result.action == "added"
parsed = tomllib.loads(config_path.read_text(encoding="utf-8"))
hook = parsed["hooks"]["pre_tool"][0]
hooks = parsed["hooks"]
hook = hooks["pre_tool"][0]
assert set(hooks) >= {"pre_tool", "post_tool", "session_end"}
assert hook["id"].startswith("gradata:codex:")
# Round-trip: the brain dir must appear in the hook id (which is
# build-from-brain-dir before any shell-escaping).
# Adapter normalizes to POSIX path for cross-platform stable signature,
# so compare against as_posix() not raw str() (Windows uses backslashes).
assert brain_dir.as_posix() in hook["id"]
assert "gradata.hooks.inject_brain_rules" in hooks["pre_tool"][0]["command"]
assert "gradata.hooks.auto_correct" in hooks["post_tool"][0]["command"]
assert "gradata.hooks.session_close" in hooks["session_end"][0]["command"]


def test_opencode_adapter_writes_pre_post_and_session_hooks(tmp_path: Path) -> None:
brain_dir = tmp_path / "brain"
brain_dir.mkdir()
config_path = tmp_path / ".config" / "opencode" / "config.json"

adapter = get_adapter("opencode")
first = adapter.install(brain_dir, config_path)
second = adapter.install(brain_dir, config_path)

assert first.action == "added"
assert second.action == "already_present"
hooks = json.loads(config_path.read_text(encoding="utf-8"))["hooks"]
assert set(hooks) >= {"preTool", "postTool", "sessionEnd"}
assert "gradata.hooks.inject_brain_rules" in hooks["preTool"][0]["command"]
assert "gradata.hooks.auto_correct" in hooks["postTool"][0]["command"]
assert "gradata.hooks.session_close" in hooks["sessionEnd"][0]["command"]


def test_adapter_install_does_not_touch_real_user_config(tmp_path: Path) -> None:
Expand All @@ -63,3 +87,26 @@ def test_adapter_install_does_not_touch_real_user_config(tmp_path: Path) -> None
assert result.action == "added"
after = real_config.read_text(encoding="utf-8") if real_config.exists() else None
assert after == before


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)
Comment on lines +92 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Loading