# Packs & configuration

Packs bundle hooks you install once and get everywhere. Exactly two providers exist -- the capt-hook wheel's builtins and Claude Code plugins -- with zero per-repo config: no manifest, no enable list, no `.claude/capt-hook.toml`. This page covers using the packs you have, shipping your own in a plugin, and tuning hook settings.


# Builtin packs activate themselves

`capt-hook` ships six builtin packs. Four are always active in every repo:

- `general` -- language-agnostic guards: git/jj command safety, doc and prompt nudges, task and plan discipline, model-routing nudges, a code-review gate, a docs-freshness gate, a detour check-in nudge, verbose-comment enforcement, and `rm` safety.
- `fixes` -- workarounds for upstream Claude Code issues: auto-approving a teammate's permission dialogs and scratch-file writes when the session ran with `--dangerously-skip-permissions`.
- `steering` -- judgment nudges: prefer first-principles fixes over band-aids, don't dismiss a real pre-existing issue, don't chase trivial type noise, don't swap the requested fix for docs.
- `performance` -- wall-clock pipelining nudges that speculate a held fix while verification runs.

Two more activate by language, detected from the repo's build manifests:

- `go` -- a go-test-before-commit gate and a `go.mod` nudge -- active when a recursive, non-ignored `go.mod` or `go.work` exists anywhere in the repo.
- `python` -- a pytest-before-commit gate, AST style rules, and a `uv` missing-dependency nudge -- active when a recursive, non-ignored `pyproject.toml` exists.

Detection walks the repo once, pruning VCS metadata and `.gitignore`d directories, so a vendored `go.mod` under `.venv` or a testdata `pyproject.toml` you ignore never counts. There is no opt-out for a builtin: to change what fires, fix the policy in `capt-hook` itself rather than adding a repo exception. To turn every capt-hook behavior off, disable the captain-hook plugin -- its wiring dispatches every event, so without it no hook runs, builtin or plugin.


# List the active packs

`pack list` shows the active builtins plus any pack your enabled plugins ship -- read-only, no config to edit:

``` bash
uvx capt-hook pack list
```

      builtin:fixes                      builtin  2 hooks
      builtin:general                    builtin  12 hooks
      builtin:performance                builtin  1 hooks
      builtin:python                     builtin  3 hooks
      builtin:steering                   builtin  3 hooks
      plugin:cc-context@cc-context       plugin   32 hooks
      # ...plus a row per pack your other enabled plugins ship

A pack's runtime id is `builtin:<name>` for a wheel builtin and `plugin:<plugin-id>` for a plugin pack. A plugin pack's version, description, and repository all derive from its `plugin.json` -- nothing about a pack is separately authored.


# Test your own hooks

`capt-hook test` runs the inline tests on your repo's own `.claude/hooks/` modules:

``` bash
uvx capt-hook test
```

The builtins are tested by captain-hook itself, and a plugin's pack is tested from its own repo with [`pack test`](#test-the-pack) -- so `capt-hook test` covers only what your repo owns.


# How packs combine with your own hooks

Your `.claude/hooks/` modules import first, so they register ahead of every pack. A block from any hook wins the event outright; among approvals the first registered wins, so a local approval outranks a pack's on the same event. Packs are the shared baseline; your local hooks stay authoritative.


# Author a pack

A pack is a directory of hook `.py` files plus an optional `pack.toml` descriptor. Each hook file is a flat module that imports from `captain_hook` and registers with the same primitives you use locally:

``` python
from captain_hook import Allow, Block, Input, block_command

block_command(
    ["git", "push", "--force"],
    reason="Force-push rewrites shared history",
    hint="Push a new commit instead",
    tests={
        Input(command="git push --force"): Block(),
        Input(command="git push"): Allow(),
    },
)
```

You ship that pack to other repos by putting it inside a Claude plugin at the fixed path `capt-hook/{pack.toml, hooks/}`. [Ship a pack in a plugin](#ship-a-pack-in-a-plugin) covers the layout, the descriptor, and the `pack test` authoring lane end to end.


# Ship a pack in a plugin

A Claude plugin can carry one capt-hook pack, and dispatch discovers it: every event, the dispatcher enumerates the project's enabled plugins and loads the pack any of them ships at the fixed path `capt-hook/{pack.toml, hooks/}` under the plugin root. The pack applies to whatever project the plugin is enabled in, with zero repo edits and zero per-session wiring -- the plugin runs nothing itself.

There is no other way to distribute a pack: no GitHub sources, no tarballs, no pins, no per-repo enable list. A standalone pack becomes a minimal plugin.


## The layout

A pack plugin ships its pack at one fixed path:

``` text
your-plugin/
├── .claude-plugin/
│   └── plugin.json
└── capt-hook/
    ├── pack.toml
    └── hooks/
        ├── guard.py
        └── ...
```

The `capt-hook/` directory is the pack. Present, it must carry both a `pack.toml` and a `hooks/` directory; a `capt-hook/` missing either, or with a malformed `pack.toml`, is a **fatal** load error at every dispatch in every repo that enables the plugin -- never a silent skip, because a dropped pack silently drops guards. A plugin with no `capt-hook/` directory merely consumes packs and is skipped. Wire `pack test` (below) into the plugin's CI so a malformed pack is caught before release.


## `pack.toml` is a tiny descriptor

The descriptor holds only what can't be derived from the plugin -- its identity, version, description, and repository all come from `plugin.json`. So `pack.toml` carries just two things, both optional:

``` toml
# Resources the pack's hooks need provisioned before they run.
resources = ["spacy:en_core_web_sm", "wordnet:oewn:2025"]

# MCP tool gate semantics: how a tool lowers to a built-in gate.
[tools.ccx_code_edit]
behaves_like = "Edit"
span_edit = { path = "path", content = "content", delete = "delete" }

[tools.BashFormat]
behaves_like = "Bash"
```

`resources` names NLP or tool resources the pack's hooks need eagerly provisioned; `capt-hook` provisions the union of every active pack's resources once at session start. A `[tools.<name>]` table tells `capt-hook` an MCP tool behaves like a built-in gate -- its key is the **bare tool segment** (`ccx_code_edit`, not `mcp__server__ccx_code_edit`), which matches the tool under any server or mount prefix. A tool name may be claimed by only one pack; a collision across packs is fatal. A pack that needs neither still ships a `pack.toml` (an empty `resources = []` is fine) -- the file is what declares `capt-hook/` an intentional pack.


## The captain-hook dependency

`plugin.json` must declare the captain-hook dependency with a version floor:

``` json
{
  "dependencies": [
    { "name": "captain-hook", "marketplace": "captain-hook", "version": ">=11.0.0" }
  ]
}
```

The object form is required: [name](../../reference/files-commands.md#captain_hook.Call.name), `marketplace`, and a `>=X.Y.Z` floor. This dependency is what pulls the dispatcher onto a machine that installs only your plugin; without it the pack has no runtime.


## Test the pack

`pack test` validates and runs a working-tree pack, so CI needs one line:

``` bash
uvx capt-hook pack test .
```

Pass the plugin root -- the directory holding `.claude-plugin/plugin.json` and `capt-hook/`. It requires all three artifacts, validates the captain-hook dependency floor, validates the `pack.toml` resources and tool specs, loads only this working-tree pack, and runs its inline tests. It exits non-zero on a missing layout, a bad dependency, an unknown resource, a load error, zero hooks, an `async_=True` decision hook, or a failing test.


## Tell users how to install

Two lines -- your marketplace and your plugin:

``` bash
claude plugin marketplace add your-org/your-repo
claude plugin install your-plugin@your-marketplace
```

On a machine that has never seen captain-hook, installing just your plugin surfaces Claude Code's dependency error: the `plugin.json` dependency points at a marketplace the machine doesn't know, so the plugin lands disabled with `dependency-unsatisfied`. The one-time fix is registering the marketplace -- `claude plugin marketplace add yasyf/captain-hook`, or `capt-hook init` in any repo, which registers it as a side effect. From then on the dependency resolves and Claude Code installs the dispatcher itself.


## How discovery works

Dispatch enumerates the project's enabled plugins via `claude plugin list --json` and checks each one's install root for the fixed `capt-hook/` path. That CLI spawns Node and takes about a second, far too slow for the ~3ms dispatch hot path, so the roster is cached per project and invalidated by stat changes to the files that shape it: Claude Code's `installed_plugins.json`, the user, project, and local `settings.json`, and the `managed-settings.json` locations along with their `managed-settings.d/` drop-in directories. Installing, enabling, or disabling a plugin re-runs the CLI once; every other event reads the snapshot. The fixed-path probe itself runs live, so editing a hook file inside a plugin needs no invalidation.

Claude Code re-lists an enabled plugin once per settings scope it's enabled in, so the same plugin id can appear several times in the roster. Discovery collapses identical entries and, when scopes pin different installs, follows Claude Code's own precedence -- local over project over user -- so the pack loads from the same install Claude runs. Two different installs at the same scope mean a corrupt roster; discovery refuses it (one warning, zero plugin packs) rather than guessing.

The stat tuple can only watch files. Policy that arrives another way -- an MDM plist, a remote managed policy, or per-session flags like `--plugin-dir` and `--settings` -- changes what `claude plugin list` would report without moving any watched stat, and the roster stays stale until one does. When that bites, force a refresh: touch any settings file, or delete the project's `.plugins` snapshot under the capt-hook cache. A missing `claude` CLI, a failing invocation, or roster output discovery can't use all degrade to an empty plugin roster -- one warning, cached against the same stat tuple, so the failure never becomes a per-event cost.

Discovered packs are first-class: they show in `uvx capt-hook pack list` as kind `plugin`. Their inline tests run from the plugin's own repo with `pack test`, not from a consuming project's `capt-hook test`.


## Route a misfire home

When the session reviewer mines a misfiring hook, it routes the fix PR to the pack's home repo: a builtin pack's fix goes to captain-hook, and a plugin pack's fix goes to the `repository` in its `plugin.json`. A plugin that declares no `repository` has no route, so its misfires are dropped rather than misfiled.


# Configure hook settings

You define a [HooksSettings](../../reference/configuration-prompts.md#captain_hook.HooksSettings) subclass in `.claude/hooks/conf.py`, override its fields from code or environment variables, and read the values inside a handler through `evt.ctx.c`.


## Define a settings class

Create `.claude/hooks/conf.py` and subclass [HooksSettings](../../reference/configuration-prompts.md#captain_hook.HooksSettings). Add your own fields with type annotations and defaults:

``` python
from captain_hook import HooksSettings


class ProjectSettings(HooksSettings):
    test_command: str = "pytest"
    require_tests_after_edit: bool = True
```

The loader discovers `conf.py` when it imports your hooks package, finds the [HooksSettings](../../reference/configuration-prompts.md#captain_hook.HooksSettings) subclass, and instantiates it for every event. You do not call any builder yourself. Put exactly one subclass in `conf.py` so the loader has a single class to pick.


## Read settings in a handler

Every event carries the active settings on `evt.ctx.c`. Cast it to your subclass to get typed field access:

``` python
from typing import cast

from captain_hook import Event, Tool, on

from .conf import ProjectSettings


@on(Event.PreToolUse, only_if=[Tool("Bash")])
def enforce_test_command(evt):
    settings = cast(ProjectSettings, evt.ctx.c)
    if (cmd := evt.command).raw and cmd.q.runs("pytest") and settings.test_command != "pytest":
        return evt.block(f"Run the project test command: {settings.test_command}")
    return None
```


## Override fields from the environment

[HooksSettings](../../reference/configuration-prompts.md#captain_hook.HooksSettings) reads environment variables with the `HOOKS_` prefix, so each field maps to one variable. Set `HOOKS_TEST_COMMAND=mtest` to override `test_command` without touching `conf.py`. List and tuple fields take JSON:

``` bash
HOOKS_PLANNING_AGENTS='["Explore","Plan","research"]'
```

To namespace project settings under a different prefix, set `model_config` on your subclass:

``` python
from pydantic_settings import SettingsConfigDict

from captain_hook import HooksSettings


class ProjectSettings(HooksSettings):
    model_config = SettingsConfigDict(env_prefix="MYAPP_")
    require_tests_after_edit: bool = True
```

Now `MYAPP_REQUIRE_TESTS_AFTER_EDIT=false` flips the field, and every inherited field moves under the new prefix too. Pick the prefix once at the project root and keep it stable.


## Tune the built-in fields

[HooksSettings](../../reference/configuration-prompts.md#captain_hook.HooksSettings) ships built-in fields you can override the same way.


### `planning_agents`

A list of subagent names that the framework treats as scratch work, not real edits. `skip_planning_agents` is on by default for every hook, so hooks ignore `Event.SubagentStart` and `Event.SubagentStop` for any agent on this list and planning runs do not trip your gates. The defaults cover Claude Code's built-in planning agents, such as `Explore` and `Plan`. Add your own when your team uses custom planning agent types:

``` python
class ProjectSettings(HooksSettings):
    planning_agents: list[str] = [
        *HooksSettings.model_fields["planning_agents"].default_factory(),
        "research",
    ]
```


### `waiting_tools`

A list of tool names that mean the agent is waiting on something external, not working. The [Waiting()](../../reference/conditions.md#captain_hook.Waiting) condition reads this list to suppress nudges and gates while a background tool is still running. The defaults cover Claude Code's long-running tools, such as `Monitor` and `TeamCreate`. Append your custom async tools to the defaults:

``` python
class ProjectSettings(HooksSettings):
    waiting_tools: list[str] = [
        *HooksSettings.model_fields["waiting_tools"].default_factory(),
        "MyAsyncTask",
    ]
```


### `state_dir`

Where `@session_state` and `@workflow_state` models persist their JSON. The field reads `CAPTAIN_HOOK_STATE_DIR`, falling back to `~/.claude/state`. Set `CAPTAIN_HOOK_STATE_DIR` to relocate state, which helps in CI sandboxes that block writes outside the workspace. See [State and sessions](../../docs/guide/state.md) for how state directories are laid out.


### `log_dir`

Where the framework writes its own session logs. The field reads `CAPTAIN_HOOK_LOG_DIR` and otherwise falls back to `~/.cache/captain-hook/logs`, or to `$XDG_CACHE_HOME/captain-hook/logs` when you set `XDG_CACHE_HOME`.


# See also

- [How hooks run](../../docs/guide/how-it-works.md) explains the dispatch pipeline and the verdict precedence behind pack ordering.
- [State & workflows](../../docs/guide/state.md) covers where `@session_state` and `@workflow_state` data lands under `state_dir`.
- [Configure settings example](../../docs/examples/settings-config.md) is the tested, runnable version of the settings handler.
