# 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.

``` bash
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, reason
```

`logs` 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](../../docs/guide/packs.md#log_dir) to relocate the directory.

If a hook never appears in the log, dispatch never reached it. Work through [It never fires](#it-never-fires).


# Confirm the hook fires

Confirm three things, in order.

First, re-run the inline tests.

``` bash
uvx capt-hook --hooks <dir> test
```

Each test prints the [Input](../../reference/testing.md#captain_hook.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.

``` bash
uvx capt-hook logs --tail 40
```

A 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](../../docs/reference/conditions.md) for every condition and the [conditions guide](../../docs/guide/primitives.md#filter-with-conditions) for how to combine them.

Cap the count. A [nudge](../../reference/primitives.md#captain_hook.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](../../reference/primitives.md#captain_hook.llm_gate) at 1 and [llm_nudge](../../reference/primitives.md#captain_hook.llm_nudge) at 3. See the [primitives reference](../../docs/reference/primitives.md).

Check the event set. A hook registered with `Event.Stop | Event.SubagentStop` fires on both. Confirm that both belong by reading the [events reference](../../docs/reference/events.md).


# 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](../../reference/primitives.md#captain_hook.block_command) and a bare [nudge](../../reference/primitives.md#captain_hook.nudge) target `PreToolUse`, a signal-scored [nudge](../../reference/primitives.md#captain_hook.nudge) targets `PostToolUse`, and a blocking [gate](../../reference/primitives.md#captain_hook.gate) targets `Event.Stop | Event.SubagentStop`. If you need a different one, pass `events=` explicitly. The [events reference](../../docs/reference/events.md) 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](../../reference/testing.md#captain_hook.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.

``` bash
uv tool list
```

If `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:

``` bash
uv tool upgrade capt-hook      # keep the PATH binary, current again
uv tool uninstall capt-hook    # drop it; uvx resolves fresh without it
```

Then 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](../../docs/guide/packs.md#ship-a-pack-in-a-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](../../docs/guide/packs.md#ship-a-pack-in-a-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:

``` bash
claude plugin marketplace add yasyf/captain-hook
```

`capt-hook init` in any repo does the same as a side effect. See [Tell users how to install](../../docs/guide/packs.md#tell-users-how-to-install).


# [NlpSignal](../../reference/signals.md#captain_hook.NlpSignal) hooks need the spaCy model

Hooks that use [NlpSignal](../../reference/signals.md#captain_hook.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.

``` bash
uvx capt-hook init
```

A standalone hooks directory that uses NLP outside any pack skips eager provisioning. Provision the resources directly with the bundled helper.

``` bash
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.

``` python
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](../../reference/files-commands.md#captain_hook.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.

``` python
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.
