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.
The signed host is missing or dispatch fails
Every event runs the plugin’s bin/hook, which binrun resolves to the capt-hookd inside ~/Applications/Captain Hook.app. Neither case falls back to an unfenced wheel. If the app is missing, no hook runs and each event exits 0. The session’s first event shows a note naming the install command. If the app is older than the plugin needs, dispatch exits non-zero. Either way, a PreToolUse or PermissionRequest that names a session-ending program runs under your permissions with a skip note and exit 0. Install or repair the application:
uvx capt-hook helper installThe wheel that handles each event is chosen by the app itself: the host runs the capt-hook wheel at exactly its own build, so the wheel and app cannot drift apart. The app also keeps itself current — a session-start check, at most one per 12 hours, upgrades the formula in the background and posts a notification when the upgrade lands (how it works). The manual path still works — check what’s installed, upgrade the Cellar, then deploy from it:
"$HOME/Applications/Captain Hook.app/Contents/Helpers/capt-hookd" version
brew upgrade --formula yasyf/tap/captain-hook
uvx capt-hook helper installLegacy dispatch entries. The only consumer command is "${CLAUDE_PLUGIN_ROOT}/bin/hook" run <Event> from the captain-hook plugin. A surviving uvx --isolated --from capt-hook hook run <Event> line — an old plugin release, or a hand-copied settings entry — is a second registration: Claude Code dedupes hook commands per registering source, not globally, so every hook on that event can run 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. Update the plugin and delete hand-wired entries from project settings; do not preserve them as a fallback:
/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. Sessions on plugin 12.28 or older fail every hook until they reload.
Quiet every hook for a while
Pause every captain-hook hook across all sessions, guards included. This needs the signed host from this release; update the host first. Set a pause, check its status, or lift it early:
uvx capt-hook pause --for 30m --reason "desktop CPU saturation"
uvx capt-hook pause --status
uvx capt-hook resumeThe reason is optional. Durations such as 45s, 30m, and 1h work; the cap is one hour. The pause expires by itself, with no cleanup. Every event returns without dispatch, whichever plugin version the session is pinned to, and a SessionStart banner names the expiry and how to resume. Guards are off for the whole pause.
The command writes pause.json under $CAPTAIN_HOOK_STATE_DIR (default ~/.claude/state). A malformed file or an expiry more than an hour away leaves hooks running.
Quiet hooks only through uvx capt-hook pause; never edit the plugin cache. An edited bin/hook can silence every hook in every session pinned to that version with no signal, while Claude Code still prints completed successfully.
Check a restored plugin launcher
A captain-hook fault: plugin launcher warning naming LauncherTampered means captain-hook found a changed bin/hook and restored what that version shipped in git, including its mode and whether it was a symlink. The warning lists the restored versions.
SessionStart checks installed versions and versions still in use by a live process, after replying and at most once per ten minutes. A repair appears in the next SessionStart warning. Look for the launcher restored: line in ~/.claude/state/update/update.log, or $CAPTAIN_HOOK_STATE_DIR/update/update.log when you override the state directory.
Run the check and repair by hand:
uvx capt-hook update launchersA 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 the en_core_web_sm spaCy model (~13 MB). A pack that declares it under resources in its descriptor, as general and steering do, provisions it eagerly: init downloads it 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. 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.
Only Phrase.expand reads the oewn:2025 WordNet lexicon, a 13 MB download that imports into a 113 MB database. No builtin pack calls it, so a session that runs only builtin hooks never touches WordNet. The first hook that reads an expanded phrase provisions the lexicon and waits for it. One process imports into a staging copy while every other session and thread waits on the same file lock, and the database moves into place only after the import finishes and passes an integrity check. A stamp beside the database records that, and a database no stamp vouches for is rebuilt, never read. That covers a database an interrupted run left behind, one that changed since its stamp, and one an earlier capt-hook version imported in place. To pay for the import at session start instead of inside a hook, declare wordnet:oewn:2025 under resources in the pack’s descriptor.
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 every declared resource 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, Runs, Tool
hook(
Event.PreToolUse,
message="Use jj shelve instead of git stash",
block=True,
only_if=[Tool("Bash"), Runs("git", "stash")],
)The block works. It also blocks git stash pop, because Runs matches an argv prefix and ["git", "stash", "pop"] starts with ["git", "stash"]. 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, each as its own argv prefix.
from captain_hook import Or
hook(
Event.PreToolUse,
message="Use jj shelve instead of git stash",
block=True,
only_if=[Tool("Bash"), Runs("git", "stash")],
skip_if=[
Or(
Runs("git", "stash", "pop"),
Runs("git", "stash", "apply"),
Runs("git", "stash", "list"),
Runs("git", "stash", "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.
Write the exemption structurally, the way the block itself is written. A regex skip_if on a blocking hook fails in the direction nobody notices: Command(r"git\s+stash\s+(pop|apply|list|show)") also matches echo "git stash pop", and an exemption that is too wide stands the block down without saying so. Runs compares tokens, so the exemption covers the four commands it names and nothing else.