Skip to content

fix(installer): use Hermes hook event names (pre_tool_call/post_tool_call/on_session_end)#193

Merged
Gradata merged 1 commit into
mainfrom
feat/gra-hermes-hook-event-names
May 14, 2026
Merged

fix(installer): use Hermes hook event names (pre_tool_call/post_tool_call/on_session_end)#193
Gradata merged 1 commit into
mainfrom
feat/gra-hermes-hook-event-names

Conversation

@Gradata
Copy link
Copy Markdown
Owner

@Gradata Gradata commented May 14, 2026

Problem

gradata install --agent hermes writes hook entries using Claude-Code event names (pre_tool_use, post_tool_use, session_end) into ~/.hermes/config.yaml. Hermes only recognizes pre_tool_call, post_tool_call, on_session_end and SILENTLY ignores the others (warning only, no failure).

Impact

Discovered 2026-05-13 on a real fleet: 2538 SessionStart events in telemetry but ZERO PostToolUse events for 5+ days. No corrections captured, no lessons crystallized, cloud dashboard lift report empty. The hooks looked installed and the warnings are easy to miss in noisy logs.

Fix

Map per-agent event names in the installer:

Hook intent claude-code hermes
Pre-tool pre_tool_use pre_tool_call
Post-tool post_tool_use post_tool_call
Session end session_end on_session_end

Update both install AND uninstall paths so users on the old broken config can be migrated cleanly.

The claude-code adapter (which writes CamelCase PreToolUse into ~/.claude/settings.json, a different config format that Claude Code expects natively) is left untouched.

What changed

  • Gradata/src/gradata/hooks/adapters/hermes.py
    • Renamed the event key written by the Hermes adapter from pre_tool_usepre_tool_call.
    • Added HERMES_EVENT_NAMES mapping that pins the runtime contract for all three events.
    • Added _migrate_legacy_event helper: when an existing ~/.hermes/config.yaml still has entries under the broken legacy key, they are folded into the correct key on the next gradata install --agent hermes run. This is the symmetric uninstall fix — old broken entries no longer leak.
  • Gradata/tests/test_hermes_hook_event_names.py (new)
    • Pins the correct key on fresh install.
    • Verifies idempotency under the new key.
    • Verifies legacy-key migration (old broken config → new correct config).
    • Pins the Claude-Code ↔ Hermes event-name mapping.

Test plan

pytest tests/test_hermes_hook_event_names.py tests/test_cli_install_agent.py -xvs
# 9 passed in 1.51s

The 4 unrelated failures in tests/test_rule_to_hook.py (em-dash rule lessons.md handling) were verified to be pre-existing on main and are not touched by this PR.

Layering / risk

  • No layer crossings; pure adapter change inside Layer 2.
  • Backwards-compat: existing broken configs are migrated automatically (no user action required); fresh installs immediately get the correct names.

Refs #190 (install UX epic).

…call/on_session_end)

The Hermes hook adapter was writing entries under the Claude-Code event
key 'pre_tool_use'. Hermes only recognises 'pre_tool_call',
'post_tool_call', and 'on_session_end' — it logs a warning and silently
ignores everything else.

Discovered on a real fleet on 2026-05-13: 2538 SessionStart events but
zero PostToolUse events for 5+ days, which left the cloud lift report
empty.

Fix:
- Rename the event key written by gradata.hooks.adapters.hermes from
  'pre_tool_use' to 'pre_tool_call'.
- Add HERMES_EVENT_NAMES mapping that pins the runtime contract for
  pre_tool_use / post_tool_use / session_end.
- Add a migration helper so an old, broken install (entries under
  pre_tool_use) is folded into pre_tool_call on the next
  'gradata install --agent hermes' run. This handles the symmetric
  uninstall concern without leaking stale entries.
- Regression tests covering: correct key on fresh install,
  idempotency, legacy-key migration, and contract pinning.

The claude-code adapter (which uses CamelCase 'PreToolUse' as Claude
Code expects natively) is left untouched.

Refs #190 (install UX epic).
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 14, 2026

Review Change Stack

📝 Walkthrough

Summary

  • Fixed Hermes hook event names: Corrected adapter to use Hermes-recognized event keys (pre_tool_call, post_tool_call, on_session_end) instead of Claude-Code names (pre_tool_use, post_tool_use, session_end) that were silently ignored in production.

  • Added HERMES_EVENT_NAMES mapping: New module-level constant maps legacy Claude-Code event names to Hermes equivalents for reference and migration.

  • Automatic legacy config migration: Introduced _migrate_legacy_event() function to fold legacy hook entries from old keys into correct keys, deduplicate, and clean up broken configs during install.

  • Updated install flow: Modified install() to migrate pre-existing entries from pre_tool_use into pre_tool_call and write new hook config under the correct key.

  • Added regression tests: New test module validates fresh install uses correct keys, idempotency under proper keys, legacy config migration, and event-name contract pinning.

  • Resolves production telemetry gap: Fixes issue observed 2026-05-13 where 2,538 SessionStart events had zero corresponding PostToolUse events due to hooks being silently ignored.

  • Backward compatible: Automatic migration handles existing broken configs without requiring manual user intervention; Claude-Code adapter unchanged (uses CamelCase names).

Walkthrough

This PR adds legacy event migration support to the Hermes hook adapter. A new event-name mapping constant and migration helper function enable safe transition from Claude-Code event key names (pre_tool_use) to Hermes-native names (pre_tool_call). The install flow now uses these utilities to migrate existing configs. New tests validate the mapping, idempotency, and legacy config handling.

Changes

Hermes legacy event migration

Layer / File(s) Summary
Event name mapping and migration utility
Gradata/src/gradata/hooks/adapters/hermes.py
HERMES_EVENT_NAMES constant maps Claude-Code event names to Hermes equivalents; _migrate_legacy_event() merges legacy hook entries into the current key, deduplicates, and cleans up malformed values.
Install flow integration with migration
Gradata/src/gradata/hooks/adapters/hermes.py
install() method now migrates pre_tool_use entries into pre_tool_call, checks for existing hooks by id in the migrated list, and writes the updated config.
Regression test coverage
Gradata/tests/test_hermes_hook_event_names.py
Tests verify correct Hermes key usage, idempotent installs, legacy config migration, and the event-name mapping contract.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Suggested labels

bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% 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 specifically describes the main change: fixing the Hermes adapter to use correct Hermes event names instead of Claude-Code event names.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing the problem, impact, solution, and test plan for migrating to Hermes event names.
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 feat/gra-hermes-hook-event-names

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.20.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.33][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 bug Something isn't working label May 14, 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/hermes.py`:
- Around line 165-169: The code calls _migrate_legacy_event which mutates hooks
in memory but may return InstallResult("already_present") before those changes
are saved; update the flow so that after calling _migrate_legacy_event (and
before returning from the already_present branch) you persist the migrated hooks
to disk using the existing agent config persistence routine used elsewhere (the
same function that writes agent_config_path / agent config), then return
InstallResult; reference _migrate_legacy_event, pre_tool_call, and
agent_config_path so the save is performed immediately after migration and
before returning.

In `@Gradata/tests/test_hermes_hook_event_names.py`:
- Around line 51-77: Add a regression case to cover the "already_present" path
by creating a test (or extending
test_install_migrates_legacy_pre_tool_use_entry) that writes a legacy config
with pre_tool_use containing the current signature (the same sig created from
brain used by hermes.install), then call hermes.install(brain, config) and
assert result.action == "already_present"; also verify the config text no longer
contains "pre_tool_use:" and does contain "pre_tool_call:" and the legacy entry
and the current signature were persisted. Ensure you reference the same
variables used in the test (brain, config, hermes.install) so the migration
logic is exercised for the already_present branch.
🪄 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: 23f74258-be0f-4369-98c3-fa0ef90f8ed3

📥 Commits

Reviewing files that changed from the base of the PR and between 708ce01 and 5448a90.

📒 Files selected for processing (2)
  • Gradata/src/gradata/hooks/adapters/hermes.py
  • Gradata/tests/test_hermes_hook_event_names.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.12
  • GitHub Check: pytest macos-latest / py3.11
  • GitHub Check: pytest macos-latest / py3.12
  • GitHub Check: pytest windows-latest / py3.11
  • GitHub Check: pytest windows-latest / py3.12
  • GitHub Check: pytest ubuntu-latest / py3.11
  • GitHub Check: pytest (py3.11)
  • GitHub Check: pytest (py3.12)
🧰 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_hermes_hook_event_names.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/hermes.py
🔇 Additional comments (2)
Gradata/src/gradata/hooks/adapters/hermes.py (1)

16-48: LGTM!

Also applies to: 170-172

Gradata/tests/test_hermes_hook_event_names.py (1)

20-49: LGTM!

Also applies to: 79-87

Comment on lines +165 to 169
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):
return InstallResult(
AGENT, agent_config_path, "already_present", "hook already present"
)
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 migrated config before returning already_present.

Line 165 mutates hooks in memory, but Line 166-Line 169 may return before any write. If the legacy entry already has the same id, migration is effectively dropped and pre_tool_use remains on disk.

Suggested fix
-def _migrate_legacy_event(hooks: dict, legacy_key: str, current_key: str) -> list:
+def _migrate_legacy_event(
+    hooks: dict, legacy_key: str, current_key: str
+) -> tuple[list, bool]:
@@
-    current = hooks.setdefault(current_key, [])
+    migrated = False
+    current = hooks.setdefault(current_key, [])
@@
     if isinstance(legacy, list):
         for entry in legacy:
             if entry not in current:
                 current.append(entry)
         del hooks[legacy_key]
+        migrated = True
     elif legacy_key in hooks:
@@
         del hooks[legacy_key]
-    return current
+        migrated = True
+    return current, migrated
@@
-        pre_tool_call = _migrate_legacy_event(hooks, "pre_tool_use", "pre_tool_call")
+        pre_tool_call, migrated = _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):
+            if migrated:
+                atomic_write_text(agent_config_path, _dump_simple_yaml(data))
             return InstallResult(
                 AGENT, agent_config_path, "already_present", "hook 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 165 - 169, The
code calls _migrate_legacy_event which mutates hooks in memory but may return
InstallResult("already_present") before those changes are saved; update the flow
so that after calling _migrate_legacy_event (and before returning from the
already_present branch) you persist the migrated hooks to disk using the
existing agent config persistence routine used elsewhere (the same function that
writes agent_config_path / agent config), then return InstallResult; reference
_migrate_legacy_event, pre_tool_call, and agent_config_path so the save is
performed immediately after migration and before returning.

Comment on lines +51 to +77
def test_install_migrates_legacy_pre_tool_use_entry(tmp_path: Path) -> None:
"""An old broken install (entries under ``pre_tool_use``) must be migrated
to ``pre_tool_call`` on the next ``gradata install --agent hermes`` run.
"""
brain = tmp_path / "brain"
brain.mkdir()
config = tmp_path / ".hermes" / "config.yaml"
config.parent.mkdir()
legacy = (
"hooks:\n"
" pre_tool_use:\n"
" - id: gradata:hermes:/some/other/brain\n"
" command: legacy-command\n"
)
config.write_text(legacy, encoding="utf-8")

result = hermes.install(brain, config)

assert result.action == "added", result.message
text = config.read_text(encoding="utf-8")
# Legacy key has been removed.
assert "pre_tool_use:" not in text, text
# New key holds both the migrated entry and the freshly-installed one.
assert "pre_tool_call:" in text, text
assert "legacy-command" in text, text
assert f"gradata:hermes:{brain.resolve().as_posix()}" in text, text

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add a regression case for legacy migration on the already_present path.

Line 67 currently validates only the added flow. Please add a case where pre_tool_use already contains the current sig; this catches migration bugs where install returns already_present without persisting the key rewrite.

Suggested test addition
+def test_install_migrates_legacy_key_even_when_signature_already_present(tmp_path: Path) -> None:
+    brain = tmp_path / "brain"
+    brain.mkdir()
+    config = tmp_path / ".hermes" / "config.yaml"
+    config.parent.mkdir()
+    sig = f"gradata:hermes:{brain.resolve().as_posix()}"
+    legacy = (
+        "hooks:\n"
+        "  pre_tool_use:\n"
+        f"    - id: {sig}\n"
+        "      command: legacy-command\n"
+    )
+    config.write_text(legacy, encoding="utf-8")
+
+    result = hermes.install(brain, config)
+
+    assert result.action == "already_present"
+    text = config.read_text(encoding="utf-8")
+    assert "pre_tool_use:" not in text, text
+    assert "pre_tool_call:" in text, text
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_install_migrates_legacy_pre_tool_use_entry(tmp_path: Path) -> None:
"""An old broken install (entries under ``pre_tool_use``) must be migrated
to ``pre_tool_call`` on the next ``gradata install --agent hermes`` run.
"""
brain = tmp_path / "brain"
brain.mkdir()
config = tmp_path / ".hermes" / "config.yaml"
config.parent.mkdir()
legacy = (
"hooks:\n"
" pre_tool_use:\n"
" - id: gradata:hermes:/some/other/brain\n"
" command: legacy-command\n"
)
config.write_text(legacy, encoding="utf-8")
result = hermes.install(brain, config)
assert result.action == "added", result.message
text = config.read_text(encoding="utf-8")
# Legacy key has been removed.
assert "pre_tool_use:" not in text, text
# New key holds both the migrated entry and the freshly-installed one.
assert "pre_tool_call:" in text, text
assert "legacy-command" in text, text
assert f"gradata:hermes:{brain.resolve().as_posix()}" in text, text
def test_install_migrates_legacy_key_even_when_signature_already_present(tmp_path: Path) -> None:
brain = tmp_path / "brain"
brain.mkdir()
config = tmp_path / ".hermes" / "config.yaml"
config.parent.mkdir()
sig = f"gradata:hermes:{brain.resolve().as_posix()}"
legacy = (
"hooks:\n"
" pre_tool_use:\n"
f" - id: {sig}\n"
" command: legacy-command\n"
)
config.write_text(legacy, encoding="utf-8")
result = hermes.install(brain, config)
assert result.action == "already_present"
text = config.read_text(encoding="utf-8")
assert "pre_tool_use:" not in text, text
assert "pre_tool_call:" in text, text
🤖 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_hermes_hook_event_names.py` around lines 51 - 77, Add a
regression case to cover the "already_present" path by creating a test (or
extending test_install_migrates_legacy_pre_tool_use_entry) that writes a legacy
config with pre_tool_use containing the current signature (the same sig created
from brain used by hermes.install), then call hermes.install(brain, config) and
assert result.action == "already_present"; also verify the config text no longer
contains "pre_tool_use:" and does contain "pre_tool_call:" and the legacy entry
and the current signature were persisted. Ensure you reference the same
variables used in the test (brain, config, hermes.install) so the migration
logic is exercised for the already_present branch.

@Gradata Gradata merged commit 7000062 into main May 14, 2026
9 checks passed
@Gradata Gradata deleted the feat/gra-hermes-hook-event-names branch May 14, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant