Skip to content

Add post-tool and session-end hooks for Codex/Hermes/OpenCode#230

Open
Gradata wants to merge 1 commit into
mainfrom
gra-1163-post-session-hooks
Open

Add post-tool and session-end hooks for Codex/Hermes/OpenCode#230
Gradata wants to merge 1 commit into
mainfrom
gra-1163-post-session-hooks

Conversation

@Gradata
Copy link
Copy Markdown
Owner

@Gradata Gradata commented May 27, 2026

Closes GRA-1163.\n\n## Summary\n- Add shared hook command helpers for post_tool correction capture and session_end close.\n- Install/uninstall post_tool + session_end hooks for Codex, Hermes, and OpenCode adapters.\n- Add tests covering install/uninstall behavior for the new hooks.\n\n## Verification\n- pytest tests/test_hook_adapters.py (14 passed)

Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 27, 2026

Review Change Stack

📝 Walkthrough
  • Added shared hook command helpers (hook_command_for_module, post_tool_hook_command, session_end_hook_command) in _base.py for building hook command strings
  • New public API: post_tool_hook_command() and session_end_hook_command() for capturing post-tool corrections and session-end events
  • Updated Codex adapter to install/uninstall post_tool and session_end hooks alongside existing pre_tool hooks via TOML table management
  • Updated Hermes adapter to support Hermes-native event keys (post_tool_call, on_session_end) with migration from legacy Claude-Code keys
  • Updated OpenCode adapter to manage postTool and sessionEnd hook entries alongside preTool in JSON/YAML configs
  • Refactored hook installation logic across adapters to track which hooks were actually added and return "already_present" only when no new hooks were installed
  • Expanded uninstall logic to remove signature-matching hooks across all three hook types instead of pre-tool only
  • Added comprehensive test coverage for install/uninstall behavior of new post-tool and session-end hooks (14 tests passing)

Walkthrough

This PR refactors the hook execution infrastructure to support post-tool and session-end phases alongside the existing pre-tool phase. A new generic hook_command_for_module() builder is introduced, and Codex, Hermes, and OpenCode adapters are updated in parallel to install and uninstall hooks across all three phases, with Hermes also handling legacy event-key migration.

Changes

Three-Phase Hook Installation Across All Adapters

Layer / File(s) Summary
Hook command infrastructure
Gradata/src/gradata/hooks/adapters/_base.py
Generic hook_command_for_module(brain_dir, module) builder constructs BRAIN_DIR=... python -m <module> commands. Existing hook_command() now delegates to it, and new post_tool_hook_command() and session_end_hook_command() are exported for auto-correct and session-close hooks.
Codex adapter three-phase hooks
Gradata/src/gradata/hooks/adapters/codex.py
Codex install/uninstall now covers pre_tool, post_tool, and session_end TOML hook tables. Helper _hook_table_has_signature() detects per-table signature presence. Install conditionally appends missing blocks and returns "already_present" only when all three are present. Uninstall removes hooks from any of the three tables.
Hermes adapter with legacy migration
Gradata/src/gradata/hooks/adapters/hermes.py
Hermes install iterates through current keys (pre_tool_call, post_tool_call, on_session_end) and legacy Claude-Code keys (pre_tool_use, post_tool_use, session_end), migrating any legacy entries into current keys. Returns "already_present" based on actual insertions, not signature presence alone. Uninstall expanded to remove matches across all current and legacy event-name keys.
OpenCode adapter three-phase hooks
Gradata/src/gradata/hooks/adapters/opencode.py
OpenCode install expanded to iterate over preTool, postTool, and sessionEnd hook types with per-type signature deduplication. Uninstall completely rewritten to explicitly remove all three types, validate config structure, clean empty lists, and count removals for return status.
Test coverage for three-phase adapters
Gradata/tests/test_hook_adapters.py
Codex tests verify post_tool and session_end hooks are present in config. Parameterized tests for Hermes and OpenCode confirm hook-module strings appear under adapter-specific keys. Uninstall tests validate removal of both auto_correct and session_close modules and absence of agent-specific prefix.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Gradata/gradata#193: Hermes adapter event-key migration and uninstall expansion across legacy Claude-Code and Hermes-native event-name keys.
  • Gradata/gradata#215: Adapter uninstall refactoring and hook command helpers for post-tool and session-end phases.

Suggested labels

feature

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding post-tool and session-end hooks for three specific adapter implementations (Codex, Hermes, OpenCode).
Description check ✅ Passed The description is directly related to the changeset, providing a clear summary of changes, listing the three adapters affected, and including verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gra-1163-post-session-hooks

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):
┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

�[1m Loading rules from local config...�[0m
[00.21][ERROR]: Error: exception Glob.Lexer.Syntax_error("malformed glob pattern: missing ']'")
Raised at Glob__Lexer.syntax_error in file "libs/glob/Lexer.mll", line 8, characters 2-26
Called from Glob__Lexer.__ocaml_lex_token_rec in file "libs/glob/Lexer.mll", line 29, characters 26-53
Cal


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added the feature label May 27, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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/_base.py`:
- Around line 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.

In `@Gradata/src/gradata/hooks/adapters/hermes.py`:
- Around line 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.
🪄 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: 4f364644-089b-4897-914c-1ac13562952d

📥 Commits

Reviewing files that changed from the base of the PR and between a197bff and 623e1b2.

📒 Files selected for processing (5)
  • Gradata/src/gradata/hooks/adapters/_base.py
  • Gradata/src/gradata/hooks/adapters/codex.py
  • Gradata/src/gradata/hooks/adapters/hermes.py
  • Gradata/src/gradata/hooks/adapters/opencode.py
  • Gradata/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 macos-latest / py3.12
  • GitHub Check: pytest ubuntu-latest / py3.12
  • GitHub Check: pytest macos-latest / py3.11
  • GitHub Check: pytest windows-latest / py3.12
  • GitHub Check: pytest (py3.11)
  • GitHub Check: pytest (py3.12)
  • GitHub Check: pytest windows-latest / py3.11
  • GitHub Check: pytest ubuntu-latest / py3.11
🧰 Additional context used
📓 Path-based instructions (2)
Gradata/tests/**/*.py

📄 CodeRabbit inference engine (Gradata/AGENTS.md)

Gradata/tests/**/*.py: Set BRAIN_DIR environment variable via tmp_path in conftest.py for test isolation — ensure _paths.py module cache refreshes when calling Brain.init() directly inside tests
Add unit tests in tests/test_*.py for every CI push without LLM calls (deterministic); mark integration tests with @pytest.mark.integration and 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: Prefer sentence-transformers for local embeddings, google-genai for Gemini embeddings, cryptography for AES-GCM encrypted system.db, bm25s for BM25 rule ranking, and mem0ai for external memory adapters — guard all optional dependency imports with try / except ImportError at 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 bare except: pass — use typed exceptions or at minimum logger.warning(...) with exc_info=True to avoid silent failure in a memory product
Never import from out-of-scope sibling directories ../Sprites/ or ../Hausgem/ within gradata/* 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 inside gradata/*
Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes

Files:

  • Gradata/src/gradata/hooks/adapters/_base.py
  • Gradata/src/gradata/hooks/adapters/opencode.py
  • Gradata/src/gradata/hooks/adapters/hermes.py
  • Gradata/src/gradata/hooks/adapters/codex.py
🔇 Additional comments (4)
Gradata/tests/test_hook_adapters.py (1)

55-68: LGTM!

Also applies to: 70-105, 107-123

Gradata/src/gradata/hooks/adapters/codex.py (1)

74-91: LGTM!

Also applies to: 100-123, 128-188

Gradata/src/gradata/hooks/adapters/hermes.py (1)

245-295: LGTM!

Gradata/src/gradata/hooks/adapters/opencode.py (1)

49-78: LGTM!

Also applies to: 83-120

Comment on lines +132 to 136
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}"
)
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.

Comment on lines +166 to 181
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))
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant