A complete hooks file

Compose a deterministic safety block and a live-state workflow gate in one .claude/hooks/ file.

Hooks rarely live alone. A real .claude/hooks/ directory mixes a safety block that fires the instant the agent runs a command with a workflow gate that watches session state. This capstone composes two primitives in one file: block_command (deterministic, carries inline tests) and gate (judges live session state). One file listens on PreToolUse, Stop, and SubagentStop at once.

"""Compose a safety block and a workflow gate in one hooks file."""

from __future__ import annotations

from captain_hook import (
    Allow,
    Block,
    Input,
    RanCommand,
    TouchedFile,
    block_command,
    gate,
)

# A complete .claude/hooks/ file: a safety block and a workflow gate composing in one
# place.

# 1. Safety: never let the agent force-push.
block_command(
    r"git\s+push\s+--force(?!-)",
    reason="Force-push rewrites shared history",
    hint="Use --force-with-lease",
    tests={
        Input(command="git push --force origin main"): Block(),
        Input(command="git push --force-with-lease"): Allow(),
    },
)

# 2. Workflow: don't finish a Python change without running the tests.
# RanCommand matches parsed argv prefixes (launcher-literal), so each spelling
# of the test runner gets its own entry — skip_if is OR.
gate(
    "You edited Python files but never ran the tests. Run `uv run pytest` before finishing.",
    only_if=[TouchedFile("**/*.py")],
    skip_if=[RanCommand("uv", "run", "pytest"), RanCommand("pytest")],
)
NoteVerified by replay

The gate reads live session state — which files were touched and whether the tests ran — so its judgment is exercised by replaying a real session, not by the inline tests.

What it catches

git push --force origin main   # block_command: force-push rewrites shared history

The gate also blocks Stop/SubagentStop when you edited **/*.py but never ran pytest, judged from live session state rather than a single input.

What it allows

git push --force-with-lease   # --force-with-lease is safe; not a bare --force

The block / allow split mirrors the hook inline tests, so it stays true as the hook evolves.

Run it yourself

uvx capt-hook --hooks docs/examples test

See also