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


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.

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
62 changes: 62 additions & 0 deletions Gradata/tests/snapshots/install_claude_code_settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.auto_correct",
"id": "gradata:claude-code:__BRAIN_DIR__",
"type": "command"
}
],
"matcher": "Edit|Write"
}
],
"PreCompact": [
{
"hooks": [
{
"command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.pre_compact",
"id": "gradata:claude-code:__BRAIN_DIR__",
"type": "command"
}
],
"matcher": "manual|auto"
}
],
"PreToolUse": [
{
"hooks": [
{
"command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.inject_brain_rules",
"id": "gradata:claude-code:__BRAIN_DIR__",
"type": "command"
}
],
"matcher": "*"
}
],
"Stop": [
{
"hooks": [
{
"command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.session_close",
"id": "gradata:claude-code:__BRAIN_DIR__",
"type": "command"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.context_inject",
"id": "gradata:claude-code:__BRAIN_DIR__",
"type": "command"
}
]
}
]
}
}
Loading