Troubleshooting
A hook misbehaved. captain-hook ships no doctor command. You diagnose with two tools, the dispatch logs and a re-run of your inline tests.
Diagnostic commands
Reach for these first. The logs show what dispatch actually saw; the tests show what your conditions match.
uvx capt-hook logs # Print the most recent session's log
uvx capt-hook logs --tail 40 # Show only the last 40 lines
uvx capt-hook logs --session <id-or-path> # Target a specific session (id, or transcript path)
uvx capt-hook --hooks <dir> test # Re-run inline tests; prints the Input and PASS/FAIL
uvx capt-hook --hooks <dir> test --json # One record per test: id, status, expected, reasonlogs reads from the log directory, which defaults to ~/.cache/captain-hook/logs. Each session writes one log file there. To target a past session, pass its id, or pass the transcript path and logs hashes it for you. See Configuration to relocate the directory.
If a hook never appears in the log, dispatch never reached it. Work through It never fires.
Confirm the hook fires
Confirm three things, in order.
First, re-run the inline tests.
uvx capt-hook --hooks <dir> testEach test prints the Input it built and a PASS or FAIL. A FAIL means your conditions don’t match the input you expect, so fix the condition before you touch dispatch. If the test passes but the hook still seems dead, move on.
Second, check that --hooks points at the directory holding your hook file. captain-hook loads .py files under that root and nothing else.
Third, read the session log.
uvx capt-hook logs --tail 40A firing hook leaves a dispatch line here. If you see the event arrive but no line for your hook, a condition skipped it. If you see no event at all, the hook targets the wrong event or the captain-hook plugin isn’t enabled in this session.
It fires too often
A hook that fires on every event, or twice per event, is matching too broadly. Tighten it three ways.
Narrow the conditions. Add a skip_if for the cases you want to spare, or a tighter only_if glob. A FilePath("**/*.py") matches every Python file in the project; FilePath("src/**/*.py") matches only your source tree. See the conditions cheatsheet for every condition and the conditions guide for how to combine them.
Cap the count. A nudge already defaults to one fire, or three when signal-scored, and budgets count per agent context — the main agent and each subagent get their own allowance; pass max_fires=N to change the cap. The LLM primitives also default low, with llm_gate at 1 and llm_nudge at 3. See the primitives reference.
Check the event set. A hook registered with Event.Stop | Event.SubagentStop fires on both. Confirm that both belong by reading the events reference.
It never fires
The hook is silent because dispatch never matched it. Check four causes.
Wrong event. Each primitive picks a default event. block_command and a bare nudge target PreToolUse, a signal-scored nudge targets PostToolUse, and a blocking gate targets Event.Stop | Event.SubagentStop. If you need a different one, pass events= explicitly. The events reference lists which event fires when.
Unsatisfiable conditions. An only_if is AND, so every condition must match at once. A hook with only_if=[Tool("Bash"), FilePath("**/*.py")] never fires, because a Bash command carries no file path. Run uvx capt-hook --hooks <dir> test and read the Input each test built against your conditions.
Plugin not enabled. The captain-hook plugin registers every hook event; without it, no event reaches dispatch at all. Confirm .claude/settings.json still enables captain-hook, and re-run uvx capt-hook init if the registration is missing.
Session not fresh. Claude Code loads plugin hooks at session start. A hook on a brand-new event, or a just-enabled plugin, takes effect when the next session starts.
Hooks run a stale capt-hook
The hooks fire, but they behave like an old release: a feature you just read about is missing, a misfire the changelog says is fixed keeps firing. The framework version is the problem, not your hook.
Check for an installed tool first.
uv tool listIf capt-hook appears there, that install is the cause on plugin versions before 9.7.0. A bare uvx capt-hook prefers an installed tool over resolving fresh — uv documents this shortcut — so one uv tool install capt-hook, however long ago, pins every hook on the machine to that version. Fix it either way:
uv tool upgrade capt-hook # keep the PATH binary, current again
uv tool uninstall capt-hook # drop it; uvx resolves fresh without itThen upgrade the plugin. From 9.7.0 the plugin wires every hook as uvx --isolated capt-hook run <Event>, which ignores installed tools entirely, so a stale install can’t capture dispatch again. Claude Code checks marketplace plugins for updates at session start only, and auto-update is off by default for third-party marketplaces, so on most machines the upgrade is manual:
/plugin marketplace update captain-hook
/plugin update
A session that is already running keeps the old hook commands until you run /reload-plugins or start a new session. Claude Code refreshes a marketplace plugin’s cache only when its plugin.json version increases; captain-hook’s release workflow bumps and commits that version on every tagged release, so /plugin update picks up each release as soon as the sync commit lands on main.
Last, hunt down legacy capt-hook run <Event> entries. Claude Code dedupes hook commands per registering source, not globally, so a run entry that survives anywhere besides the captain-hook plugin — a hand-wired line in a project’s .claude/settings.json, or a pack-shipping plugin built before the discovery contract — dispatches the event again in a sibling process, and every hook on that event runs twice. The tell is doubled output: two permission prompts for one tool call, the same block message landing twice, or a side-effecting hook applying its effect twice. Delete hand-wired entries from the project settings yourself; a legacy pack plugin sheds its run entries when it releases against the fixed-path contract in Ship a pack in a Claude plugin, since the plugin cache re-pulls on a version bump.
A SessionStart warning about pack attach
Claude Code shows a non-blocking hook warning at session start naming a failed capt-hook pack attach command. An enabled plugin still ships the attach line from the pre-10.0.0 contract; the command no longer exists, so the hook exits non-zero and Claude Code reports it. The warning is not the real problem: a plugin that old also predates the fixed capt-hook/ layout, so its pack no longer loads at all — uvx capt-hook pack list shows no plugin row for it. Both symptoms clear when the plugin ships a release on the current contract; Ship a pack in a Claude plugin covers the layout.
A plugin lands disabled with dependency-unsatisfied
A pack plugin declares captain-hook as a plugin.json dependency, and Claude Code won’t add an unknown marketplace from a dependency alone — so on a machine that has never seen captain-hook, the plugin installs but lands disabled. Register the marketplace once and the dependency resolves:
claude plugin marketplace add yasyf/captain-hookcapt-hook init in any repo does the same as a side effect. See Tell users how to install.
NlpSignal hooks need the spaCy model
Hooks that use NlpSignal need two downloads: the en_core_web_sm spaCy model (~13MB) and the oewn:2025 WordNet lexicon (~231MB). Packs that declare them as resources in their descriptor — general and steering do — provision both eagerly: init downloads them on the spot, and an async SessionStart hook re-checks at every session start, so an install that deferred the download (offline, say) heals itself the next session. Mid-session, the two resources behave differently. A live hook never downloads the spaCy model; that would be a silent network fetch behind the agent’s back, so it raises a RuntimeError with an install hint instead. The oewn lexicon does self-heal lazily. If it’s missing, the first hook that needs it downloads it on the spot, filelock-guarded against concurrent sessions, at the cost of latency on that first NLP hook.
A hook that raises RuntimeError: spaCy model 'en_core_web_sm' is not installed means provisioning never ran for this project. Re-run init; it downloads both resources on the spot, and the async SessionStart hook re-checks at every session start after that.
uvx capt-hook initA standalone hooks directory that uses NLP outside any pack skips eager provisioning. Provision the resources directly with the bundled helper.
python -c "from captain_hook.util.model_cache import ensure_nlp_resources; ensure_nlp_resources()"Case study, a block that caught a safe command
You add a hook that blocks git stash, because your team uses jj instead.
from captain_hook import hook, Event, Tool
from captain_hook.types import Command
hook(
Event.PreToolUse,
message="Use jj shelve instead of git stash",
block=True,
only_if=[Tool("Bash"), Command(r"git\s+stash")],
)The block works. It also blocks git stash pop, because git\s+stash is a substring of git stash pop and Command matches anywhere in the command text. Popping a stash is safe, so the hook now blocks a command you want to allow.
Fix it with a skip_if, which skips the hook when any of its conditions match. List the safe subcommands there.
hook(
Event.PreToolUse,
message="Use jj shelve instead of git stash",
block=True,
only_if=[Tool("Bash"), Command(r"git\s+stash")],
skip_if=[Command(r"git\s+stash\s+(pop|apply|list|show)")],
)Now the safe subcommands pass through while a bare git stash still blocks. A re-run of uvx capt-hook --hooks <dir> test with a test for each subcommand confirms the split.