Packs & configuration

Use the packs you have, ship your own in a Claude plugin, and tune hook settings.

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 .gitignored 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:

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:

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 — 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:

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

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:

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

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

The object form is required: 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:

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:

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 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. Add your own fields with type annotations and defaults:

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

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

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

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

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

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() 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:

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