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
16 changes: 14 additions & 2 deletions Gradata/src/gradata/hooks/adapters/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,25 @@ def hook_signature(agent: str, brain_dir: Path) -> str:
return f"gradata:{agent}:{brain_dir.resolve().as_posix()}"


def hook_command(brain_dir: Path) -> str:
def hook_command_for_module(brain_dir: Path, module: str) -> str:
return (
f"BRAIN_DIR={shlex.quote(str(brain_dir))} "
f"{shlex.quote(sys.executable)} -m gradata.hooks.inject_brain_rules"
f"{shlex.quote(sys.executable)} -m {module}"
)
Comment on lines +132 to 136
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

Serialize BRAIN_DIR as an absolute path.

hook_signature() already canonicalizes brain_dir, but the hook command stores it verbatim. If install is run with a relative brain dir, the generated hook later resolves against the agent's runtime CWD, so post-tool/session-end can target the wrong brain directory.

Suggested fix
 def hook_command_for_module(brain_dir: Path, module: str) -> str:
+    resolved_brain_dir = brain_dir.resolve()
     return (
-        f"BRAIN_DIR={shlex.quote(str(brain_dir))} "
+        f"BRAIN_DIR={shlex.quote(str(resolved_brain_dir))} "
         f"{shlex.quote(sys.executable)} -m {module}"
     )
🤖 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 - 136, The
hook_command_for_module function currently embeds brain_dir verbatim which can
be relative; change it to serialize an absolute/canonical path (e.g., call
brain_dir.resolve() or convert to an absolute string before quoting) so the
generated BRAIN_DIR is always an absolute path; update hook_command_for_module
to use the resolved/absolute Path when building the f-string passed to
shlex.quote(sys.executable) and BRAIN_DIR.



def hook_command(brain_dir: Path) -> str:
return hook_command_for_module(brain_dir, "gradata.hooks.inject_brain_rules")


def post_tool_hook_command(brain_dir: Path) -> str:
return hook_command_for_module(brain_dir, "gradata.hooks.auto_correct")


def session_end_hook_command(brain_dir: Path) -> str:
return hook_command_for_module(brain_dir, "gradata.hooks.session_close")


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

Expand Down
66 changes: 51 additions & 15 deletions Gradata/src/gradata/hooks/adapters/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
WRITE_TOOL_ALIASES,
InstallResult,
_normalize_tool_name,
contains_signature,
extract_from_edit_args,
extract_from_write_args,
failure,
hook_command,
hook_signature,
post_tool_hook_command,
session_end_hook_command,
)

AGENT = "codex"
Expand Down Expand Up @@ -70,32 +71,65 @@ def _toml_string(value: str) -> str:
return json.dumps(value)


def _hook_table_has_signature(text: str, table_name: str, signature: str) -> bool:
"""Return True if a specific TOML hook table already contains signature."""
current: list[str] = []
in_table = False

def flush() -> bool:
return in_table and any(signature in line for line in current)

for line in text.splitlines(keepends=True):
stripped = line.lstrip()
if stripped.startswith("[[") or stripped.startswith("["):
if flush():
return True
current = [line]
in_table = stripped.startswith(f"[[hooks.{table_name}]]")
elif in_table:
current.append(line)
return flush()


def install(brain_dir: Path, agent_config_path: Path) -> InstallResult:
try:
sig = hook_signature(AGENT, brain_dir)
if contains_signature(agent_config_path, sig):
return InstallResult(
AGENT, agent_config_path, "already_present", "hook already present"
)
existing = (
agent_config_path.read_text(encoding="utf-8") if agent_config_path.exists() else ""
)
block = (
"\n[[hooks.pre_tool]]\n"
f"id = {_toml_string(sig)}\n"
f"command = {_toml_string(hook_command(brain_dir))}\n"
blocks: list[str] = []
for table_name, command in (
("pre_tool", hook_command(brain_dir)),
("post_tool", post_tool_hook_command(brain_dir)),
("session_end", session_end_hook_command(brain_dir)),
):
if _hook_table_has_signature(existing, table_name, sig):
continue
blocks.append(
f"\n[[hooks.{table_name}]]\n"
f"id = {_toml_string(sig)}\n"
f"command = {_toml_string(command)}\n"
)
if not blocks:
return InstallResult(
AGENT, agent_config_path, "already_present", "hooks already present"
)
atomic_write_text(agent_config_path, existing.rstrip() + "".join(blocks))
return InstallResult(
AGENT,
agent_config_path,
"added",
"installed pre_tool, post_tool, and session_end hooks",
)
atomic_write_text(agent_config_path, existing.rstrip() + block)
return InstallResult(AGENT, agent_config_path, "added", "installed pre_tool hook")
except Exception as exc:
return failure(AGENT, agent_config_path, exc)


def uninstall(brain_dir: Path, agent_config_path: Path) -> InstallResult:
"""Reverse install: drop the [[hooks.pre_tool]] block carrying our signature.
"""Reverse install: drop hook blocks carrying our signature.

Operates on the raw TOML text — walks line-by-line, identifies the
[[hooks.pre_tool]] table that contains our signature, and removes that
hook tables that contain our signature, and removes those
table + its keys. Preserves all other tables verbatim.
"""
try:
Expand Down Expand Up @@ -131,7 +165,9 @@ def flush(buf: list[str], is_hook: bool) -> None:
# Flush the previous table
flush(current_table, current_is_hook)
current_table = [line]
current_is_hook = stripped.startswith("[[hooks.pre_tool]]")
current_is_hook = stripped.startswith(
("[[hooks.pre_tool]]", "[[hooks.post_tool]]", "[[hooks.session_end]]")
)
else:
if current_table:
current_table.append(line)
Expand All @@ -148,7 +184,7 @@ def flush(buf: list[str], is_hook: bool) -> None:
new_text = "".join(out_lines).rstrip() + "\n"
atomic_write_text(agent_config_path, new_text)
return InstallResult(
AGENT, agent_config_path, "removed", f"removed {removed} [[hooks.pre_tool]] block"
AGENT, agent_config_path, "removed", f"removed {removed} hook block"
)
except Exception as exc:
return failure(AGENT, agent_config_path, exc)
45 changes: 31 additions & 14 deletions Gradata/src/gradata/hooks/adapters/hermes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
WRITE_TOOL_ALIASES,
InstallResult,
_normalize_tool_name,
contains_signature,
extract_from_edit_args,
extract_from_write_args,
failure,
hook_command,
hook_signature,
post_tool_hook_command,
session_end_hook_command,
)

AGENT = "hermes"
Expand Down Expand Up @@ -149,11 +150,6 @@ def _format_scalar(value) -> str:
def install(brain_dir: Path, agent_config_path: Path) -> InstallResult:
try:
sig = hook_signature(AGENT, brain_dir)
if contains_signature(agent_config_path, sig):
return InstallResult(
AGENT, agent_config_path, "already_present", "hook already present"
)

existing = (
agent_config_path.read_text(encoding="utf-8") if agent_config_path.exists() else ""
)
Expand All @@ -167,14 +163,28 @@ def install(brain_dir: Path, agent_config_path: Path) -> InstallResult:
# Claude-Code names (pre_tool_use / post_tool_use / session_end) here
# results in Hermes silently ignoring the entries (warning only). See
# Gradata/gradata#190 for the install-UX epic.
pre_tool_call = _migrate_legacy_event(hooks, "pre_tool_use", "pre_tool_call")
if any(isinstance(entry, dict) and entry.get("id") == sig for entry in pre_tool_call):
added: list[str] = []
for current_key, legacy_key, command in (
("pre_tool_call", "pre_tool_use", hook_command(brain_dir)),
("post_tool_call", "post_tool_use", post_tool_hook_command(brain_dir)),
("on_session_end", "session_end", session_end_hook_command(brain_dir)),
):
entries = _migrate_legacy_event(hooks, legacy_key, current_key)
if any(isinstance(entry, dict) and entry.get("id") == sig for entry in entries):
continue
entries.append({"id": sig, "command": command})
added.append(current_key)
if not added:
return InstallResult(
AGENT, agent_config_path, "already_present", "hook already present"
AGENT, agent_config_path, "already_present", "hooks already present"
)
pre_tool_call.append({"id": sig, "command": hook_command(brain_dir)})
atomic_write_text(agent_config_path, _dump_simple_yaml(data))
Comment on lines +166 to 181
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

Persist legacy-key migrations even when no new hook is appended.

_migrate_legacy_event() mutates hooks before the added check. If the file only contains legacy pre_tool_use / post_tool_use / session_end entries, this path returns "already_present" without writing the migrated config, so Hermes keeps ignoring the old keys.

Suggested fix
-        added: list[str] = []
+        added: list[str] = []
+        migrated = False
         for current_key, legacy_key, command in (
             ("pre_tool_call", "pre_tool_use", hook_command(brain_dir)),
             ("post_tool_call", "post_tool_use", post_tool_hook_command(brain_dir)),
             ("on_session_end", "session_end", session_end_hook_command(brain_dir)),
         ):
+            had_legacy = legacy_key in hooks
             entries = _migrate_legacy_event(hooks, legacy_key, current_key)
+            migrated = migrated or had_legacy
             if any(isinstance(entry, dict) and entry.get("id") == sig for entry in entries):
                 continue
             entries.append({"id": sig, "command": command})
             added.append(current_key)
-        if not added:
+        if not added and not migrated:
             return InstallResult(
                 AGENT, agent_config_path, "already_present", "hooks already present"
             )
🤖 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/hermes.py` around lines 166 - 181, The
migration currently skips writing the config when no new hook is appended
because it only checks `added`; update the logic around the loop that calls
`_migrate_legacy_event(hooks, legacy_key, current_key)` to detect whether any
legacy->current migrations occurred (e.g., set a boolean flag like `migrated =
True` when `_migrate_legacy_event` moves entries or compare pre/post state of
`hooks`), and if either `added` is non-empty or `migrated` is true, call
`atomic_write_text(agent_config_path, _dump_simple_yaml(data))` and return the
appropriate InstallResult; this ensures migrations performed by
`_migrate_legacy_event` are persisted even when no new hook (`sig`) was
appended.

return InstallResult(AGENT, agent_config_path, "added", "installed pre_tool_call hook")
return InstallResult(
AGENT,
agent_config_path,
"added",
"installed pre_tool_call, post_tool_call, and on_session_end hooks",
)
except Exception as exc:
return failure(AGENT, agent_config_path, exc)

Expand Down Expand Up @@ -232,7 +242,7 @@ def extract_correction(


def uninstall(brain_dir: Path, agent_config_path: Path) -> InstallResult:
"""Reverse install: drop signature-matching entries from hooks.pre_tool_call.
"""Reverse install: drop signature-matching hook entries.

Hermes uses YAML, so we can't reuse the generic JSON helper.
Idempotent. Preserves user-owned entries and other hook events.
Expand All @@ -253,8 +263,15 @@ def uninstall(brain_dir: Path, agent_config_path: Path) -> InstallResult:
return InstallResult(AGENT, agent_config_path, "already_present", "no hooks block")

removed = 0
# Check both current and legacy event names.
for key in ("pre_tool_call", "pre_tool_use"):
# Check current and legacy event names.
for key in (
"pre_tool_call",
"post_tool_call",
"on_session_end",
"pre_tool_use",
"post_tool_use",
"session_end",
):
entries = hooks.get(key)
if not isinstance(entries, list):
continue
Expand Down
76 changes: 61 additions & 15 deletions Gradata/src/gradata/hooks/adapters/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
failure,
hook_command,
hook_signature,
post_tool_hook_command,
read_json,
session_end_hook_command,
write_json,
)

Expand Down Expand Up @@ -49,26 +51,70 @@ def install(brain_dir: Path, agent_config_path: Path) -> InstallResult:
sig = hook_signature(AGENT, brain_dir)
data = read_json(agent_config_path)
hooks = data.setdefault("hooks", {})
pre_tool = hooks.setdefault("preTool", [])
if any(sig in str(item) for item in pre_tool):
added: list[str] = []
for key, command in (
("preTool", hook_command(brain_dir)),
("postTool", post_tool_hook_command(brain_dir)),
("sessionEnd", session_end_hook_command(brain_dir)),
):
entries = hooks.setdefault(key, [])
if not isinstance(entries, list):
entries = []
hooks[key] = entries
if any(sig in str(item) for item in entries):
continue
entries.append({"id": sig, "command": command})
added.append(key)
if not added:
return InstallResult(
AGENT, agent_config_path, "already_present", "hook already present"
AGENT, agent_config_path, "already_present", "hooks already present"
)
pre_tool.append({"id": sig, "command": hook_command(brain_dir)})
write_json(agent_config_path, data)
return InstallResult(AGENT, agent_config_path, "added", "installed preTool hook")
return InstallResult(
AGENT,
agent_config_path,
"added",
"installed preTool, postTool, and sessionEnd hooks",
)
except Exception as exc:
return failure(AGENT, agent_config_path, exc)


def uninstall(brain_dir: Path, agent_config_path: Path) -> InstallResult:
"""Reverse install: drop signature-matching entries from hooks.preTool."""
from gradata.hooks.adapters._base import uninstall_from_list_in_dict

return uninstall_from_list_in_dict(
agent=AGENT,
brain_dir=brain_dir,
agent_config_path=agent_config_path,
outer_key="hooks",
inner_key="preTool",
)
"""Reverse install: drop signature-matching entries from OpenCode hooks."""
try:
if not agent_config_path.is_file():
return InstallResult(
AGENT, agent_config_path, "already_present", "config file does not exist"
)
sig = hook_signature(AGENT, brain_dir)
data = read_json(agent_config_path)
hooks = data.get("hooks")
if not isinstance(hooks, dict):
return InstallResult(AGENT, agent_config_path, "already_present", "no hooks block")

removed = 0
for key in ("preTool", "postTool", "sessionEnd"):
entries = hooks.get(key)
if not isinstance(entries, list):
continue
kept = []
for entry in entries:
if sig in str(entry):
removed += 1
continue
kept.append(entry)
if kept:
hooks[key] = kept
else:
hooks.pop(key, None)

if removed == 0:
return InstallResult(AGENT, agent_config_path, "already_present", "hook not present")

if not hooks:
data.pop("hooks", None)
write_json(agent_config_path, data)
return InstallResult(AGENT, agent_config_path, "removed", f"removed {removed} hook entry")
except Exception as exc:
return failure(AGENT, agent_config_path, exc)
Loading
Loading