Troubleshoot hooks
Before you start
The hook system fires callbacks at Claude Code lifecycle events. It is built around three collaborating classes: HookRegistry (registers and dispatches hooks), HookExecutor / HookExecutorSync (runs a single HookDefinition), and HookConfig (loads rules from YAML and supplies them to the registry). Most problems fall into one of three categories: a hook never fires, a hook fires but produces unexpected output, or a hook fires but raises an error.
Symptom table
| If you observe | Check |
|---|---|
| Hook never fires | Call registry.get_matching_hooks(event, context) — if the list is empty, the HookMatcher condition is not satisfied or the hook was never registered for that HookEvent. |
| Hook fires for the wrong events | Inspect registry.get_stats() — compare the event counts against what you expect, then review the HookMatcher.matches() logic for the affected rule. |
| Hook fires but returns unexpected output | Call registry.get_execution_log(event_filter=<your_event>) and examine the result dicts returned by HookExecutor.execute(). |
| Hook registered via YAML never loads | Confirm HookConfig.from_yaml(yaml_path) succeeds and that you subsequently called registry.load_config(config). |
| Python handler is never called | Verify the handler name is a key in the python_handlers dict passed to HookExecutor(python_handlers={...}). |
| Intermittent failures | Check for mutable state outside the handler callable (globals, module-level caches). Confirm that context dicts are not mutated between fire() calls. |
fire() hangs or is very slow |
Switch to fire_sync() via HookRegistry.fire_sync() to isolate async overhead. Then check the handler for blocking I/O or unbounded loops. |
| Security-guard hook blocks a command unexpectedly | Call validate_bash_command(command) or validate_file_path(file_path) directly and inspect the returned (bool, str) tuple to see the rejection reason. |
Diagnosis steps
Work through these in order — each step costs more time than the one before it.
1. Confirm the hook is registered
hooks = registry.get_matching_hooks(event, context)
print(hooks) # empty list means nothing is registered for this event + context
If the list is empty, either registry.register(event, handler, ...) was never called, or the HookMatcher returned False for the provided context. Add a temporary matcher that always returns True to distinguish the two cases:
class AlwaysMatch:
def matches(self, context):
return True
2. Check the execution log
log = registry.get_execution_log(limit=20, event_filter=your_event)
for entry in log:
print(entry)
get_execution_log() returns the most recent executions. An empty log after fire() confirms the hook did not execute at all. Non-empty entries show the result dict from each HookExecutor.execute() call, which includes any errors the handler raised.
3. Review registry stats
stats = registry.get_stats()
print(stats)
get_stats() shows aggregate counts per event. A count of zero for an event you expected to fire points to a registration or dispatch problem rather than an executor problem.
4. Fire the hook in isolation
Strip the call down to its required arguments and fire directly:
from hooks import HookRegistry, HookConfig, HookEvent
registry = HookRegistry()
registry.register(HookEvent.YOUR_EVENT, your_handler, description="debug test")
results = registry.fire_sync(HookEvent.YOUR_EVENT, context={"key": "value"})
print(results)
Using fire_sync() removes async scheduling from the equation and gives you a synchronous result or a traceback immediately.
5. Test the executor in isolation
If fire_sync() produces the wrong result, test the executor directly:
from hooks.executor import HookExecutorSync
executor = HookExecutorSync(python_handlers={"my_handler": your_callable})
result = executor.execute(hook_definition, context)
print(result)
This confirms whether the problem is in dispatch (HookRegistry) or execution (HookExecutor).
6. Run the related tests
pytest -k "hooks" -v
A failing test that covers your path gives you a reproducible baseline and a fixture you can adapt.
Common fixes
Hook never fires — missing load_config call
If you loaded rules from YAML, you must pass the config to the registry explicitly:
config = HookConfig.from_yaml("hooks.yaml")
registry = HookRegistry()
registry.load_config(config) # required — the registry does not auto-load
Hook never fires — matcher rejects the context
HookMatcher.matches(context) receives the dict you pass to fire(). If a required key is absent or has the wrong value, the hook is silently skipped. Add logging inside matches() or temporarily replace the matcher with one that always returns True to verify.
Python handler not called — key missing from python_handlers
HookExecutor looks up handlers by name in the dict you supply at construction time. If the key is absent, the handler is not called. Confirm the key matches exactly:
executor = HookExecutorSync(python_handlers={"expected_key": your_callable})
Handler ID lost — cannot unregister
registry.register(...) returns a str handler ID. If you discard it, you cannot call registry.unregister(handler_id) later. Store the ID when you register:
handler_id = registry.register(HookEvent.YOUR_EVENT, your_handler)
# later:
registry.unregister(handler_id)
Stale execution log skewing diagnosis
The log accumulates across calls. Clear it before a focused test run:
registry.clear_execution_log()
# then reproduce the failure
Dependency or environment drift
If hooks worked previously without a code change, confirm your environment is consistent:
pip show <dependency-name>
This is a change outside the hooks feature itself — a dependency upgrade can alter behavior in HookExecutor or in your handler callables.
Source files
src/attune/hooks/**
Tags: hooks, events, automation, HookRegistry, HookExecutor, HookConfig
Unresolved references
Auto-generated by attune-author fact-check. Review and either fix the source code, fix this doc, or add an override.
| Location | Severity | Issue |
|---|---|---|
| Line 72 (code fence) | error | from hooks import … — module not importable |
| Line 87 (code fence) | error | from hooks.executor import … — module not importable |