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")],
)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 historyThe 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 --forceThe 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