fix: isolate claude hook installs + dedupe on reinstall (GRA-1233)#269
fix: isolate claude hook installs + dedupe on reinstall (GRA-1233)#269Gradata wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
📝 Walkthrough
WalkthroughThis PR detects and removes Claude hook entries that reference missing ChangesHook brain directory detection and removal workflow
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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): �[32m✔�[39m �[1mOpengrep OSS�[0m �[1m Loading rules from local config...�[0m Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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/claude_code.py`:
- Around line 101-113: The function _has_exact_installed_hook_set currently can
miss duplicate installed hooks inside a single event group; update the loop so
you explicitly detect duplicates by ensuring module_entries contains exactly one
matching entry (len(module_entries) == 1) and that the single entry exactly
equals the expected signature (compare str(module_entries[0]) ==
f"{sig}:{event}:{module}" rather than using substring containment); if
len(module_entries) != 1 or the equality check fails, return False. Use the
existing HOOKS and _is_gradata_module_hook helpers to build module_entries but
rely on explicit count+exact-string-check to catch duplicates.
In `@Gradata/src/gradata/hooks/stale_hook_check.py`:
- Around line 260-262: The code currently calls path.parent.mkdir(...) then
path.write_text(json.dumps(settings,...)) in stale_hook_check.py which can
produce a truncated settings.json if interrupted; replace the direct write with
the project's atomic JSON write helper (e.g., use the atomic-write helper
function used elsewhere) to write the serialized settings atomically: ensure the
directory is created as before, then call the helper (passing the target Path
and the settings dict/serialized JSON) instead of path.write_text; keep the same
formatting (indent and sort_keys) and return removed as before.
- Around line 210-216: The _remove_missing_hook_brain_dirs function can mutate
real settings when GRADATA_HOOK_ROOT or GRADATA_HOOK_ROOT_POST are set; add the
same isolation guard used earlier (see the check around line 166) to
short-circuit and return an empty list if either environment overrides are
present, before reading or modifying settings. Specifically, in
_remove_missing_hook_brain_dirs (and using the same helpers _settings_path and
_read_settings), check for GRADATA_HOOK_ROOT/GRADATA_HOOK_ROOT_POST and
immediately return [] to preserve hook-root isolation for test/dev runs.
🪄 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: f8490051-7ad3-4cb2-b53e-96678102d254
📒 Files selected for processing (5)
Gradata/src/gradata/_doctor.pyGradata/src/gradata/hooks/adapters/claude_code.pyGradata/src/gradata/hooks/stale_hook_check.pyGradata/tests/test_doctor_hooks.pyGradata/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 ubuntu-latest / py3.12
- GitHub Check: pytest ubuntu-latest / py3.11
- GitHub Check: pytest windows-latest / py3.11
- GitHub Check: pytest macos-latest / py3.11
- GitHub Check: pytest windows-latest / py3.12
- GitHub Check: pytest macos-latest / py3.12
- GitHub Check: pytest (py3.12)
- GitHub Check: pytest (py3.11)
🧰 Additional context used
📓 Path-based instructions (2)
Gradata/src/**/*.py
📄 CodeRabbit inference engine (Gradata/AGENTS.md)
Gradata/src/**/*.py: Prefersentence-transformersfor local embeddings,google-genaifor Gemini embeddings,cryptographyfor AES-GCM encrypted system.db,bm25sfor BM25 rule ranking, andmem0aifor external memory adapters — guard all optional dependency imports withtry / except ImportErrorat 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 bareexcept: pass— use typed exceptions or at minimumlogger.warning(...)withexc_info=Trueto avoid silent failure in a memory product
Never import from out-of-scope sibling directories../Sprites/or../Hausgem/withingradata/*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 insidegradata/*
Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes
Files:
Gradata/src/gradata/_doctor.pyGradata/src/gradata/hooks/adapters/claude_code.pyGradata/src/gradata/hooks/stale_hook_check.py
Gradata/tests/**/*.py
📄 CodeRabbit inference engine (Gradata/AGENTS.md)
Gradata/tests/**/*.py: SetBRAIN_DIRenvironment variable viatmp_pathin conftest.py for test isolation — ensure_paths.pymodule cache refreshes when callingBrain.init()directly inside tests
Add unit tests intests/test_*.pyfor every CI push without LLM calls (deterministic); mark integration tests with@pytest.mark.integrationand skip them by default (they hit real LLM APIs)
Files:
Gradata/tests/test_hook_adapters.pyGradata/tests/test_doctor_hooks.py
🧠 Learnings (3)
📓 Common learnings
Learnt from: Gradata
Repo: Gradata/gradata PR: 0
File: :0-0
Timestamp: 2026-04-17T17:18:07.439Z
Learning: In PR `#102` (gradata/gradata), Round 2 addressed: cli.py env-first brain resolution (GRADATA_BRAIN > --brain-dir > cwd), _tenant.py corrupt .tenant_id overwrite, _env_int default clamping to minimum, and _events.py tenant-scoped fallback SELECT for dedup. All ruff and 99 tests green after these fixes.
📚 Learning: 2026-05-01T15:50:32.772Z
Learnt from: CR
Repo: Gradata/gradata PR: 0
File: Gradata/AGENTS.md:0-0
Timestamp: 2026-05-01T15:50:32.772Z
Learning: Applies to 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
Applied to files:
Gradata/tests/test_hook_adapters.pyGradata/tests/test_doctor_hooks.pyGradata/src/gradata/hooks/stale_hook_check.py
📚 Learning: 2026-05-01T15:50:32.772Z
Learnt from: CR
Repo: Gradata/gradata PR: 0
File: Gradata/AGENTS.md:0-0
Timestamp: 2026-05-01T15:50:32.772Z
Learning: Use `from gradata import Brain` as the public entry point — `brain.correct()` is THE entry point for the headline product promise
Applied to files:
Gradata/src/gradata/hooks/stale_hook_check.py
🔇 Additional comments (5)
Gradata/src/gradata/hooks/stale_hook_check.py (1)
166-207: LGTM!Also applies to: 347-349
Gradata/src/gradata/hooks/adapters/claude_code.py (1)
67-99: LGTM!Also applies to: 117-143
Gradata/src/gradata/_doctor.py (1)
236-261: LGTM!Also applies to: 525-526
Gradata/tests/test_hook_adapters.py (1)
3-3: LGTM!Also applies to: 135-215
Gradata/tests/test_doctor_hooks.py (1)
1-60: LGTM!
| def _has_exact_installed_hook_set(hooks: dict, sig: str) -> bool: | ||
| """Return true only when every Gradata module hook is the desired one.""" | ||
| for event, _matcher, module in HOOKS: | ||
| entries = hooks.get(event) | ||
| if not isinstance(entries, list): | ||
| return False | ||
| module_entries = [ | ||
| entry for entry in entries if _is_gradata_module_hook(entry, event, module) | ||
| ] | ||
| if len(module_entries) != 1: | ||
| return False | ||
| if f"{sig}:{event}:{module}" not in str(module_entries[0]): | ||
| return False |
There was a problem hiding this comment.
_has_exact_installed_hook_set() can miss duplicates inside a single group.
Line 107 builds module_entries from top-level event entries. If one group contains two gradata.hooks.<module> commands, it still counts as one entry and Line 110 passes, so install() returns already_present and skips dedupe.
Suggested fix
def _has_exact_installed_hook_set(hooks: dict, sig: str) -> bool:
"""Return true only when every Gradata module hook is the desired one."""
for event, _matcher, module in HOOKS:
entries = hooks.get(event)
if not isinstance(entries, list):
return False
- module_entries = [
- entry for entry in entries if _is_gradata_module_hook(entry, event, module)
- ]
- if len(module_entries) != 1:
+ module_hooks: list[object] = []
+ for entry in entries:
+ if isinstance(entry, dict) and isinstance(entry.get("hooks"), list):
+ module_hooks.extend(
+ hook
+ for hook in entry["hooks"]
+ if _is_gradata_module_hook(hook, event, module)
+ )
+ elif _is_gradata_module_hook(entry, event, module):
+ module_hooks.append(entry)
+ if len(module_hooks) != 1:
return False
- if f"{sig}:{event}:{module}" not in str(module_entries[0]):
+ if f"{sig}:{event}:{module}" not in str(module_hooks[0]):
return False
return True🤖 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 101 - 113,
The function _has_exact_installed_hook_set currently can miss duplicate
installed hooks inside a single event group; update the loop so you explicitly
detect duplicates by ensuring module_entries contains exactly one matching entry
(len(module_entries) == 1) and that the single entry exactly equals the expected
signature (compare str(module_entries[0]) == f"{sig}:{event}:{module}" rather
than using substring containment); if len(module_entries) != 1 or the equality
check fails, return False. Use the existing HOOKS and _is_gradata_module_hook
helpers to build module_entries but rely on explicit count+exact-string-check to
catch duplicates.
| def _remove_missing_hook_brain_dirs(settings_path: Path | None = None) -> list[Path]: | ||
| """Remove Gradata hook commands whose BRAIN_DIR targets no longer exist.""" | ||
| path = settings_path or _settings_path() | ||
| settings = _read_settings(path) | ||
| if not settings: | ||
| return [] | ||
| hooks = settings.get("hooks") |
There was a problem hiding this comment.
Preserve hook-root isolation in the removal path.
Line 210 starts a destructive cleanup path, but unlike Line 166 it does not short-circuit when GRADATA_HOOK_ROOT/GRADATA_HOOK_ROOT_POST are set. Since doctor calls this remover by default, isolated test/dev runs can still mutate a real Claude settings file.
Suggested fix
def _remove_missing_hook_brain_dirs(settings_path: Path | None = None) -> list[Path]:
"""Remove Gradata hook commands whose BRAIN_DIR targets no longer exist."""
+ if settings_path is None and (
+ env_str("GRADATA_HOOK_ROOT") or env_str("GRADATA_HOOK_ROOT_POST")
+ ):
+ return []
path = settings_path or _settings_path()🤖 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/stale_hook_check.py` around lines 210 - 216, The
_remove_missing_hook_brain_dirs function can mutate real settings when
GRADATA_HOOK_ROOT or GRADATA_HOOK_ROOT_POST are set; add the same isolation
guard used earlier (see the check around line 166) to short-circuit and return
an empty list if either environment overrides are present, before reading or
modifying settings. Specifically, in _remove_missing_hook_brain_dirs (and using
the same helpers _settings_path and _read_settings), check for
GRADATA_HOOK_ROOT/GRADATA_HOOK_ROOT_POST and immediately return [] to preserve
hook-root isolation for test/dev runs.
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| path.write_text(json.dumps(settings, indent=2, sort_keys=True) + "\n", encoding="utf-8") | ||
| return removed |
There was a problem hiding this comment.
Use the atomic JSON write helper for settings persistence.
Line 261 writes settings.json directly; a mid-write interruption can leave a truncated file.
As per coding guidelines, “Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes.”
🤖 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/stale_hook_check.py` around lines 260 - 262, The
code currently calls path.parent.mkdir(...) then
path.write_text(json.dumps(settings,...)) in stale_hook_check.py which can
produce a truncated settings.json if interrupted; replace the direct write with
the project's atomic JSON write helper (e.g., use the atomic-write helper
function used elsewhere) to write the serialized settings atomically: ensure the
directory is created as before, then call the helper (passing the target Path
and the settings dict/serialized JSON) instead of path.write_text; keep the same
formatting (indent and sort_keys) and return removed as before.
Source: Coding guidelines
Fixes GRA-1233:
gradata install --agent claude-codenow installs all 5 hooks including PostToolUse auto_correct, session_close, pre_compact, and context_inject — no longer PreToolUse-only.Ref: GRA-1233 (02508378-fdad-40ad-96a1-23e99872f78f)