Primitives

gate()

Register a blocking gate — nudge(message, block=True, ...) with an explicit signature.

Usage

Source

gate(
    message,
    *,
    when=None,
    signals=None,
    only_if=(),
    skip_if=(),
    guards_waiting=None,
    events=None,
    max_fires=DEFAULT_FIRES,
    tests=None,
    async_=False,
    skip_planning_agents=None,
    transcript_events=None
)

A gate keeps enforcing: it defaults to unlimited fires (once per turn) and skips while the session is ~captain_hook.types.Waiting, additively with any skip_if given. Pass guards_waiting=False to turn that skip off — the one gate that needs it is the gate whose subject is the turn that parks on background work instead of finishing. Because it blocks, skip_planning_agents resolves (via None) to False — enforcement runs on every agent type, including delegated general-purpose subagents at SubagentStop.

Example

>>> gate("Run tests before committing", when=lambda evt: not has_tests(evt))

nudge()

Register a nudge that warns (or blocks) when conditions or signals match.

Usage

Source

nudge(
    message,
    *,
    when=None,
    signals=None,
    only_if=(),
    skip_if=(),
    guards_waiting=None,
    block=False,
    advisory_on_deny=False,
    events=None,
    max_fires=DEFAULT_FIRES,
    tests=None,
    async_=False,
    skip_planning_agents=None,
    transcript_events=None
)

when and signals compose: signals decide whether the message fires, and when is a veto checked first (both must pass). The triggering event defaults to Stop | SubagentStop for a blocking gate, PostToolUse for a signal-scored nudge, and PreToolUse otherwise; pass events= to override. Blocking Stop/SubagentStop gates additionally skip while ~captain_hook.types.Waiting, on top of any skip_if — pass guards_waiting=False for a gate whose subject is the turn that parks on background work, and guards_waiting=True to opt a non-gate into the same skip.

skip_planning_agents defaults to None, resolving to not block: a blocking gate enforces on every agent type, while a warning nudge skips planning/exploration subagents (only SubagentStop/SubagentStart are affected). Pass True/False to override. Set advisory_on_deny=True only when the advice remains valid if another hook blocks the same event.

Example

>>> nudge("Remember to run tests", only_if=[TouchedFile("**/*.py")])

With signal scoring:

    >>> nudge("Stop retrying",
    ...       signals=Signals([Signal(r"retry", weight=2)], threshold=2, window=5))

lint()

Register a lint check that runs on source-file edits and writes.

Usage

Source

lint(check: Callable[[str], list[str]], message: str, lang: str = ..., trigger: str | None = ..., sep: str = ..., block: bool = ..., events: Event | None = ..., tests: InlineTests | None = ..., max_shown: int = ...) -> None
lint(check: Callable[[ast.AST], Iterator[str]], message: str, lang: str = ..., trigger: str | None = ..., sep: str = ..., block: bool = ..., events: Event | None = ..., tests: InlineTests | None = ..., max_shown: int = ...) -> None
lint(pattern: str, message: str, lang: str = ..., sep: str = ..., block: bool = ..., events: Event | None = ..., tests: InlineTests | None = ..., max_shown: int = ...) -> None

Three modes, exactly one of check or pattern: - String mode: check receives the file content as str, returns violation strings. - AST mode: check receives each ast.AST node, yields violation strings (Python only). - ast-grep mode: pattern is an ast-grep pattern string; every structural match becomes a "{snippet} (line N)" violation. lang (default "py") selects the language and the file guard, so a lang="ts" lint fires on *.ts edits.

Example

>>> def find_prints(content: str) -> list[str]:
...     return [line for line in content.splitlines() if "print(" in line]
>>> lint(find_prints, message="Remove print statements: {violations}")
>>> lint(pattern="console.log($$$)", message="Drop console.log: {violations}", lang="ts")

diff_lint()

Register a diff lint: flag only constructs the edit introduces.

Usage

Source

diff_lint(check: DiffCheck, message: str, sep: str = ..., block: bool = ..., events: Event | None = ..., tests: InlineTests | None = ..., max_shown: int = ..., only_if: Sequence[TCondition] = ..., skip_if: Sequence[TCondition] = ...) -> None
diff_lint(pattern: str, message: str, lang: str = ..., sep: str = ..., block: bool = ..., events: Event | None = ..., tests: InlineTests | None = ..., max_shown: int = ..., only_if: Sequence[TCondition] = ..., skip_if: Sequence[TCondition] = ...) -> None

Two modes, exactly one of check or pattern: - Tree mode: check receives the parsed pre- and post-edit trees as ast.AST and returns violation strings for the delta (Python only). - ast-grep mode: pattern is an ast-grep pattern string; every structural match present in the new text but absent from the old becomes a "{snippet} (line N)" violation. Match identity is the whitespace-normalized text, so a construct the edit merely moved never fires. lang (default "py") selects the language and the file guard, like lint.

Example

>>> diff_lint(pattern="print($$$)", message="This edit added a print() call: {violations}")

block_command()

Register a declarative hook that blocks a Bash command matching a pattern.

Usage

Source

block_command(
    pattern, *, reason, hint=None, only_if=(), skip_if=(), tests=None
)

Tokens mean structure, a string means a pattern. A token list is argv and lowers to [Runs][captain_hook.types.Runs], so the block catches the command anywhere in the line and never fires on one that merely quotes the words. A string stays a [Command][captain_hook.types.Command] regex over the raw line, the one form that can span a pipe, a redirect, or a flag’s value. See [command_condition][captain_hook.primitives.commands.command_condition] for the "*" carve-out.

Scope the block further with only_if/skip_if (ANDed onto the built-in [Tool("Bash"), <pattern>]), e.g. to allow it in plan mode or only under a path.

Example

>>> block_command(["git", "stash"], reason="git stash is not allowed", hint="Use jj")
>>> block_command(r"curl.*\|\s*sh", reason="no curl-pipe-shell")

warn_command()

Register a declarative hook that warns on a Bash command matching a pattern.

Usage

Source

warn_command(
    pattern,
    *,
    message,
    only_if=(),
    skip_if=(),
    tests=None,
    events=Event.PostToolUse
)

Example

>>> warn_command(["python", "-c", "*"], message="Prefer uv run mtest")

rewrite_command()

Register a PreToolUse hook that rewrites a matching Bash command.

Usage

Source

rewrite_command(
    pattern=None,
    replace=None,
    *,
    only_if=(),
    skip_if=(),
    to=None,
    block=None,
    note=None,
    tests=None
)

Two mutually exclusive forms:

  • (pattern, replace) — polymorphic by the shape of pattern. When it carries an ast-grep metavariable ($NAME / $$$NAME), the command is rewritten structurally over tree-sitter-bash via [ast_grep.rewrite][captain_hook.ast_grep.rewrite]; otherwise it is a regex rewritten via re.sub. So rewrite_command("cat $$$ARGS", "bat $$$ARGS") is structural and rewrite_command(r"^cat\s+(\S+)$", r"bat \1") is regex. note surfaces as additionalContext so the model sees the substitution.
  • Conditional — pass to, a callable mapping the event to its new command (gated by [Tool("Bash"), *only_if]); the escape hatch when the heuristic isn’t enough. When to returns None the hook blocks with block if given, otherwise passes through. note may be a callable computed from the event.

A structural single metavar binds only the first argument and drops the rest ("cat $F" turns cat -n foo.txt into bat -n); prefer $$$ARGS to carry the whole argument list. A $VAR in the replacement that the pattern never captured stays literal, so a shell variable like $HOME in the new command survives untouched.

Example

>>> rewrite_command("cat $$$ARGS", "bat $$$ARGS", note="bat renders with syntax highlighting")
>>> rewrite_command(r"^cat\s+(\S+)$", r"ccx read \1 --full", note="Rewrote cat to ccx read")
>>> rewrite_command(only_if=[Command("git push")], to=lambda e: None, block="Pushing is disabled")

rewrite_code()

Register a PreToolUse hook that structurally rewrites edited code before it is written.

Usage

Source

rewrite_code(
    pattern,
    replace,
    *,
    lang=None,
    only_if=(),
    skip_if=(),
    note=None,
    project_only=True,
    tests=None
)

The code counterpart to [rewrite_command][captain_hook.rewrite_command]: every ast-grep pattern match in the edit’s new content is rewritten to replace, an ast-grep $VAR / $$$VAR fix template. It applies across Edit, Write, MultiEdit, and NotebookEdit, and is idempotent — when nothing matches, the tool passes through untouched. note surfaces as additionalContext so the model sees that its input was rewritten.

Parameters

pattern: str

The ast-grep pattern to match, e.g. "os.system($CMD)".

replace: str

The ast-grep fix template, e.g. "subprocess.run([$CMD], check=True)".

lang: str | None = None

ast-grep language id ("py", "ts", "tsx", "js", "jsx", "go", "rs", "java", "bash"); inferred from the edited file’s extension when omitted. Pass it explicitly for an extension that carries no language, like a notebook’s .ipynb.

only_if: Sequence[TCondition] = ()

Extra conditions ANDed onto the built-in editing-tool guard.

skip_if: Sequence[TCondition] = ()

Conditions that skip the rewrite when any matches (e.g. [TestFile()]).

note: str | None = None

Advisory context surfaced alongside the rewrite.

project_only: bool = True

Only rewrite files inside the repository root (default True).

tests: InlineTests | None = None
Inline tests for the registered hook.

Example

>>> rewrite_code("os.system($CMD)", "subprocess.run([$CMD], check=True)", note="Use subprocess.run")

llm_gate()

Register an LLM-powered blocking gate.

Usage

Source

llm_gate(
    prompt,
    *,
    message,
    response_model=GateVerdict,
    verdict=lambda r: r.block,
    label=None,
    signals=None,
    when=None,
    contexts=(),
    only_if=(),
    skip_if=(),
    guards_waiting=None,
    once_per_turn=None,
    events=None,
    max_fires=DEFAULT_FIRES,
    tests=None,
    max_context=2000,
    specialty="review",
    model="small",
    agent=True,
    transcript=True,
    tool_results=False,
    budget=None,
    diff=False,
    on_incomplete=None,
    backend="jev",
    escalate=None
)

On a tool event the gate judges every call, so a call retried after a block is judged again rather than let through. On any other event a gate that blocked stays quiet for the rest of the turn, which keeps a Stop gate from looping.

message may be a literal string, a {field} template with the verdict model’s fields splatted in (same placeholder rules as ~captain_hook.Prompt.from_template(): only {identifier} substitutes, every other brace stays literal), or a callable taking the verdict.

TypeSafe Jev answers by default, in about a tenth of a second and without tools: the prompt becomes the instructions of one question per verdict field, and the transcript window, contexts, and diff become its state. agent, specialty, and model apply only to the LLM. Under Jev, reasoning holds a one-line summary of the answer, so a message that shows the model’s own words needs the LLM. A {reasoning} template judges in two stages on its own: Jev decides, and only a verdict that fires goes on to the LLM, which reads Jev’s answer and writes the verdict the message shows. A callable that reads reasoning asks for the same with escalate=lambda r: r.block, and backend="llm" skips Jev.

Defaults are tuned for the common case: agent=True and transcript=True so the gate has tool access and a recent transcript window (the path lets the agent read full history). Pass tool_results=True to render each tool result after its call in that window, as result: or failed:, and budget= a cc-transcript Budget to widen what each prose chunk, tool call and answer preview keeps. Pass diff=True to attach a compact working-tree diff as a <diff> block, or agent=False, transcript=False for cheap, stateless yes/no checks. An empty diff (or no repo) skips the LLM call entirely, consuming no fire.

contexts attaches declarative evidence blocks (~captain_hook.contexts.PromptContext), each rendered as an XML block after <context> in array order; a required context with empty content skips the LLM call entirely, consuming no fire. Passing your own contexts with no signals/when suppresses the implicit transcript <context> block — you own context assembly. The ambient defaults (~captain_hook.contexts.BeforeEdit/~captain_hook.contexts.AfterEdit) attach to every gate without suppressing it, carrying the pending edit’s before/after text on edit-shaped events and nothing elsewhere. A Write’s pre-image is only knowable at PreToolUse, so contexts reading it over Writes (Introduced, BeforeEdit(required=True)) need events=Event.PreToolUse.

Parameters

label: str | None = None

Stable identity for this gate. When set, the hook name derives from label instead of the prompt hash, so review verdicts and fire state survive prompt edits; two registrations sharing a label within a module resolve to the same hook name. Uniqueness within the module is the author’s responsibility. Omit it to derive the name from the prompt (the name then shifts whenever the prompt text changes).

guards_waiting: bool | None = None

Whether to skip while ~captain_hook.types.Waiting. None keeps the default — a blocking Stop/SubagentStop gate skips, anything else does not. Pass False for a gate whose subject is the turn that parks on background work rather than finishing.

once_per_turn: bool | None = None

Whether the gate stays quiet for the rest of the turn once any LLM hook has fired in it. None keeps the default — on everywhere except tool events. Pass False for a Stop gate that must judge every closing message: teammate and cross-session messages wake an orchestrator without opening a new turn, so one fire would otherwise silence the gate until the user next types.

on_incomplete: str | None = None

Fail closed. When set, a call the gate cannot judge — its transcript evidence came back incomplete, or the caller’s deadline left it unrun — is blocked with this remediation instead of skipped. Use it for permission gates, where a skipped judge lets the guarded action through.

backend: TJudge = "jev"

Who judges. "jev", the default, asks TypeSafe Jev whenever the verdict model is categorical (see verdict_questions()); "llm" always asks the LLM. A Jev timeout or error skips the gate exactly as a failed LLM call does.

escalate: Callable[[GateVerdict], bool] | None = None
Judge in two stages, as llm_evaluate() does: Jev first, then the LLM for a Jev verdict this returns true for, such as lambda r: r.block. None keeps one stage, except for a message template that names a str field, which escalates the verdicts that block. A Jev timeout or error there asks the LLM instead of skipping.

Example

>>> llm_gate("Is the agent making excuses?",
...          message="Fix the failure instead of blaming an external service. Rerun the failing step.",
...          signals=Signals([Signal(r"external.*service", weight=2)], threshold=2))
>>> llm_gate("Does the new code hardcode a secret?",
...          message="Secrets come from the environment, never source. Read it with `os.environ`.",
...          contexts=[Introduced(pattern='os.environ[$KEY] = $VALUE')],
...          events=Event.PreToolUse, only_if=[Tool("Edit", "Write", "MultiEdit")])

llm_nudge()

Register an LLM-powered advisory nudge.

Usage

Source

llm_nudge(
    prompt,
    *,
    message,
    response_model=NudgeVerdict,
    verdict=lambda r: r.fire,
    label=None,
    advisory_on_deny=False,
    signals=None,
    when=None,
    contexts=(),
    only_if=(),
    skip_if=(),
    events=None,
    max_fires=DEFAULT_FIRES,
    tests=None,
    async_=False,
    max_context=2000,
    specialty="review",
    model="small",
    agent=True,
    transcript=True,
    tool_results=False,
    budget=None,
    diff=False,
    backend="jev",
    escalate=None
)

message may be a literal string, a {field} template with the verdict model’s fields splatted in (same placeholder rules as ~captain_hook.Prompt.from_template(): only {identifier} substitutes, every other brace stays literal), or a callable taking the verdict.

TypeSafe Jev answers by default, in about a tenth of a second and without tools: the prompt becomes the instructions of one question per verdict field, and the transcript window, contexts, and diff become its state. agent, specialty, and model apply only to the LLM. Under Jev, reasoning holds a one-line summary of the answer, so a message that shows the model’s own words needs the LLM. A {reasoning} template judges in two stages on its own: Jev decides, and only a verdict that fires goes on to the LLM, which reads Jev’s answer and writes the verdict the message shows. A callable that reads reasoning asks for the same with escalate=lambda r: r.fire, and backend="llm" skips Jev.

Defaults are tuned for the common case: agent=True and transcript=True so the nudge has tool access and a recent transcript window (the path lets the agent read full history). Pass tool_results=True to render each tool result after its call in that window, as result: or failed:, and budget= a cc-transcript Budget to widen what each prose chunk, tool call and answer preview keeps. Pass diff=True to attach a compact working-tree diff as a <diff> block, or agent=False, transcript=False for cheap, stateless yes/no checks. An empty diff (or no repo) skips the LLM call entirely, consuming no fire.

contexts attaches declarative evidence blocks (~captain_hook.contexts.PromptContext), each rendered as an XML block after <context> in array order; a required context with empty content skips the LLM call entirely, consuming no fire. Passing your own contexts with no signals/when suppresses the implicit transcript <context> block — you own context assembly. The ambient defaults (~captain_hook.contexts.BeforeEdit/~captain_hook.contexts.AfterEdit) attach to every nudge without suppressing it, carrying the pending edit’s before/after text on edit-shaped events and nothing elsewhere. A Write’s pre-image is only knowable at PreToolUse, so contexts reading it over Writes (Introduced, BeforeEdit(required=True)) need events=Event.PreToolUse — the nudge default of PostToolUse leaves them empty on Writes.

Parameters

label: str | None = None

Stable identity for this nudge. When set, the hook name derives from label instead of the prompt hash, so review verdicts and fire state survive prompt edits; two registrations sharing a label within a module resolve to the same hook name. Uniqueness within the module is the author’s responsibility. Omit it to derive the name from the prompt (the name then shifts whenever the prompt text changes).

advisory_on_deny: bool = False

Include this nudge after another hook’s deny. Leave disabled when the message assumes the denied action ran.

backend: TJudge = "jev"

Who judges. "jev", the default, asks TypeSafe Jev whenever the verdict model is categorical (see verdict_questions()); "llm" always asks the LLM. A Jev timeout or error skips the nudge exactly as a failed LLM call does.

escalate: Callable[[NudgeVerdict], bool] | None = None
Judge in two stages, as llm_evaluate() does: Jev first, then the LLM for a Jev verdict this returns true for, such as lambda r: r.fire. None keeps one stage, except for a message template that names a str field, which escalates the verdicts that fire. A Jev timeout or error there asks the LLM instead of skipping.

Example

>>> llm_nudge("Is the agent speculating instead of observing?",
...           message="Observe, don't infer -- check traces first",
...           signals=Signals([Signal(r"should contain", weight=2)], threshold=3))
>>> llm_nudge("Does any newly introduced comment narrate the edit itself?",
...           message="Comments never narrate the edit. Delete the comment.",
...           contexts=[Introduced(kind=COMMENT_TYPES)],
...           events=Event.PreToolUse, only_if=[Tool("Edit", "Write", "MultiEdit")])

prompt_check()

Run a two-stage check with a formatted prompt and return block/warn/None.

Usage

Source

prompt_check(
    evt,
    template,
    fmt=None,
    *,
    prefix,
    suffix="",
    timeout=45,
    include_reasoning=True,
    diff=False,
    response_model=PromptCheckVerdict
)

TypeSafe Jev picks the action first; an "ok" returns None at once, and a "warning" or "block", or a Jev failure, goes on to the LLM, which reads Jev’s answer and writes the reason the message carries.

Confirm

Send a deterministic block through TypeSafe Jev before it lands.

Usage

Source

Confirm(rule, timeout_s=3.0)

The block stands only when Jev’s probability that the call is what rule protects against reaches PROTECTED, the threshold measured against reference labels. A lower answer, or a question Jev declines, allows the call without additionalContext.

Timeouts and errors allow with one-line notes, once per distinct note per hook per session. A busy note-ledger lock still surfaces the note. Claude Code’s permission flow decides as usual.

Jev sees rule, the tool input, the hook’s message, and the last few tool results; the framework stops waiting after timeout_s wall-clock seconds.

A verdict is remembered for the session per hook and exact input, so a retried identical call never pays twice.

Pair it with skip_if=[Annotated(...)]: an annotation that settles the call skips the hook, and the classifier with it.

The key comes from TYPESAFE_API_KEY or the Keychain item spawnllm key set jev writes. Without it, every confirm allows; its failure note follows the same once-per-session rule.

Attributes

rule: str

What the block protects against, the one sentence the classifier judges the call by.

timeout_s: float
Seconds the framework waits for the verdict before it allows the call.

Example

>>> hook(Event.PreToolUse, only_if=[Runs("git", "push")], block=True,
...      message="Pushing to a queued PR drops the push. Ship a stacked PR instead.",
...      confirm=Confirm(rule="A push that lands on a branch the merge queue already admitted."))

Tickets

A hook’s declaration of the tickets it accepts and the rules that mint them.

Usage

Source

Tickets(
    kind,
    place,
    reach,
    rules,
    deny,
    action=None,
    ttl=timedelta(days=7),
    window=60,
    machine=(),
    hook="tickets"
)

Attributes

kind: str

The ticket kind this declaration finds and mints, e.g. slack.write.

place: tuple[str, …]

The place keys; every action and ticket carries exactly these.

reach: Reach

How far a minted ticket reaches: this one action, or every later action at its place.

rules: tuple[Rule, …]

The rules that mint a ticket, in order; the first whose condition holds mints and allows.

deny: str

What the agent can do to get permission, said on every deny.

action: Callable[[BaseHookEvent], Proposal] | None

Maps the event to the action to cover; a hook that passes each action to check() itself declares none.

ttl: timedelta | None

How long a minted ticket lives.

window: int

How many recent messages of each session the context reads.

machine: tuple[str, …]

Markers of a prompt a tool wrote on the user’s behalf; such a prompt is never their words.

hook: str
The hook the declaration belongs to, for logs.

Methods

Name Description
check() decide() over context(), failing closed: a store, source, or judge error denies, naming it.
context() The context decide() reads for evt: action, or the declaration’s action of the event.
decide() Find the live ticket covering ctx’s action, else mint one from the first rule that holds, else deny.
mint() Mint the ticket rule earned for ctx’s action.
unchecked() Mint the once ticket that lets ctx’s action go ahead when the check itself could not finish.
check()

decide() over context(), failing closed: a store, source, or judge error denies, naming it.

Usage

Source

check(evt, action=None)
context()

The context decide() reads for evt: action, or the declaration’s action of the event.

Usage

Source

context(evt, action=None)
decide()

Find the live ticket covering ctx’s action, else mint one from the first rule that holds, else deny.

Usage

Source

decide(ctx)
mint()

Mint the ticket rule earned for ctx’s action.

Usage

Source

mint(ctx, rule, evidence)
unchecked()

Mint the once ticket that lets ctx’s action go ahead when the check itself could not finish.

Usage

Source

unchecked(ctx, cause)

It records cause as its evidence and covers this one action for UNCHECKED_TTL, so the store says why nothing checked the action and a later action at the place still gets checked.

Rule

One way an action gets a ticket: the condition that, holding, supplies the ticket’s evidence.

Usage

Source

Rule(name, when)

Parameter Attributes

name: str
when: TCondition

Reach

How far a ticket reaches: one action, or every later action at its place.

Usage

Source

Reach()

Decide

A judge’s reading of the user’s words: the evidence it cites as permitting the action, if any.

Usage

Source

Decide(
    rules,
    over=(UserSaid(), UserAnswered(approved=False)),
    contexts=(),
    model="small",
    specialty="review",
    deadline=20,
    transcript=False,
    root_transcript=False,
    tool_results=False,
    llm=True,
    second_opinion=True
)

TypeSafe Jev judges first, citing the one evidence item that permits exactly this action; sure of it, the action goes ahead on that citation. Any other answer, a refusal, or a Jev failure asks the LLM, which cites and may still allow; one LLM refusal is asked again on the evidence alone, and the action is refused only when both askings refuse. With llm off, Jev alone judges, and an unsettled answer refuses. A judge that gives no verdict in time raises Undecided, which the declaration reports as a rule that found nothing.

Attributes

rules: str

The hook’s rules for this kind of action, in prose.

over: tuple[TCondition, …]

The conditions whose evidence the judge reads; the user’s words and answers by default.

contexts: Sequence[PromptContext]

Extra prompt contexts the hook renders, such as the destination.

model: TModel

spawnllm model tier; capt-hook’s small judge model by default.

specialty: TSpecialty

spawnllm specialty for the call.

deadline: float

Seconds the judge may take before it fails.

transcript: bool | int | Literal["recent", "full"]

Recent-session window the judge reads, as llm_evaluate takes it.

root_transcript: bool | int | Literal["recent", "full"]

The spawning session’s window, for a lane.

tool_results: bool

Whether the transcript windows carry tool output.

llm: bool

Whether an action Jev does not settle goes to the LLM; off, it is refused.

second_opinion: bool
Whether one LLM refusal is asked again, on the evidence alone; the action is refused only when both askings refuse.

Methods

Name Description
quick() Jev’s allow on one evidence item, else None for the LLM.
quick()

Jev’s allow on one evidence item, else None for the LLM.

Usage

Source

quick(ctx, prompt, items)

Proposal

What a hook asks a ticket to cover.

Usage

Source

Proposal(place, payload=dict[str, Any](), summary="")

Attributes

place: Mapping[str, str]

The values of the declaration’s place keys, e.g. {"channel": "C0…", "thread": ""}.

payload: Mapping[str, Any]

Every effect-bearing field; a once ticket is bound to its fingerprint.

summary: str
One line naming the action in logs and messages.

Ticket

A record that one action, or every later action at one place, goes ahead.

Usage

Source

Ticket()

Attributes

id: str

Twelve hex characters, named in every allow.

kind: str

The declaration that minted it, e.g. slack.write.

tree: str

The root session id of the tree it was minted in; that tree and the trees reading it spend it.

place: dict[str, str]

The declaration’s place keys, each an exact value.

reach: Reach

once covers the action whose fingerprint it records; place covers every action at its place.

rule: str

The rule that minted it.

evidence: list[Evidence]

What the rule found.

fingerprint: str | None

The action a once ticket covers.

author: str

Who minted it.

created: datetime

When it was minted.

expires: datetime | None

When it stops covering anything.

revoked: datetime | None
When it was revoked.

Context

The action a declaration decides, and everything the session tree knows that bears on it.

Usage

Source

Context(
    kind,
    action,
    tree,
    trees,
    session,
    agent,
    call,
    sources,
    evt=None,
    facts=dict[type, Any]()
)

Attributes

kind: str

The declaration’s kind.

action: Proposal

The action to cover.

tree: str

The root session id of the acting tree.

trees: tuple[str, …]

The trees whose tickets cover the action: its own, and its Orca coordinator’s.

session: str

The acting session.

agent: str

The acting agent.

call: str

The id of the pending call.

sources: Sources

Where the user’s words, answers, the tree’s tool calls, and the user’s rulings are read.

evt: BaseHookEvent | None

The event being decided; None for a context a test builds.

facts: dict[type, Any]
Lookups a hook injects by type, such as a thread read from a chat system.

Attributes

Name Description
answers The user’s answers to the agent’s questions, on AskUserQuestion and the board.
said The user’s words across the tree, oldest first.
touches The tree’s tool calls, oldest first.
answers

The user’s answers to the agent’s questions, on AskUserQuestion and the board.

answers: tuple[Answer, …]

said

The user’s words across the tree, oldest first.

said: tuple[Evidence, …]

touches

The tree’s tool calls, oldest first.

touches: tuple[Touch, …]

Methods

Name Description
current() The context the declaration deciding evt built.
fact() The injected lookup of type kind, or default of this context, built once.
fake() A context a test declares outright; a plain string in said is one prompt of the user’s.
of() The live context of evt, remembered on the event so conditions reach it through current().
ruling() The user’s standing ruling ident, when it was recorded before the session started.
rulings() The user’s durable rulings naming term, recorded as cc-notes answers before the session started.
current()

The context the declaration deciding evt built.

Usage

Source

current(evt)
fact()

The injected lookup of type kind, or default of this context, built once.

Usage

Source

fact(kind, default)
fake()

A context a test declares outright; a plain string in said is one prompt of the user’s.

Usage

Source

fake(
    kind,
    place,
    *,
    payload=None,
    summary="",
    said=(),
    answers=(),
    touches=(),
    rulings=(),
    facts=(),
    tree="root-session",
    session="root-session",
    agent="main",
    call="toolu_1"
)
of()

The live context of evt, remembered on the event so conditions reach it through current().

Usage

Source

of(evt, kind, action, *, trees=(), window=WINDOW, machine=())
ruling()

The user’s standing ruling ident, when it was recorded before the session started.

Usage

Source

ruling(ident)
rulings()

The user’s durable rulings naming term, recorded as cc-notes answers before the session started.

Usage

Source

rulings(term, tags=frozenset())

Names

Matches text that names the action’s place: every non-empty value of keys as a whole name.

Usage

Source

Names(*keys)

Parameter Attributes

keys: str = ()

Example

>>> UserSaid(Names("terminal"))

Touched

The tree’s earlier tool calls whose input or result match any of patterns; by default, naming the place.

Usage

Source

Touched(*patterns)

Parameter Attributes

patterns: Pattern = ()

Example

>>> Touched()

UserAnswered

The user’s answers to questions matching any of patterns, on AskUserQuestion or the cc-present board.

Usage

Source

UserAnswered(*patterns, approved=True)

A pattern reads the question with its options. With approved, only an approving answer counts: one that picks an option other than the last shown, picks a last option that reads as a go, or types words that hold nothing back.

Parameter Attributes

patterns: Pattern = ()
approved: bool = True

Example

>>> UserAnswered(Names(), approved=True)

UserRuled

The user’s durable rulings, recorded as cc-notes answers before the acting session started.

Usage

Source

UserRuled(key=None, *, ids=(), tags=frozenset())

With key, the rulings naming the action’s value of that place key as a whole name, each carrying every tag in tags; with ids, the standing rulings those answer ids name, whatever action they cover.

Parameter Attributes

key: str | None = None
ids: Sequence[str] = ()
tags: frozenset[str] = frozenset()

Example

>>> UserRuled("program", tags=frozenset({"scope:durable"}))
>>> UserRuled(ids=("c9b27c1",))

approve()

Register a hook that answers matching tool permissions with allow.

Usage

Source

approve(
    label,
    *,
    events=Event.PreToolUse | Event.PermissionRequest,
    only_if=(),
    skip_if=(),
    tests=None
)

By default it registers on PreToolUse | PermissionRequest: the PreToolUse allow pre-authorizes the tool before Claude Code decides whether to prompt, covering paths where the dialog itself never runs hooks — most importantly an in-process teammate whose dialog is forwarded to the lead and rendered as plain UI (CC #73176) — and clearing prompts the dialog stage would otherwise force (e.g. the multi-cd “for clarity” ask); the PermissionRequest half still answers any dialog that slips through. A PreToolUse allow skips the permission prompt but does not bypass permission rules: Claude Code evaluates explicit settings deny and ask rules regardless of the hook’s answer, so a matching deny rule still blocks and a matching ask rule still prompts. Pin events=Event.PermissionRequest to answer only dialogs that actually appear.

Fires on every match — no fire cap. Warning: an unconditioned approve() answers every call, equivalent to a permanent --dangerously-skip-permissions (and on PreToolUse it fires on every tool, not just prompting ones); always scope it with only_if/skip_if.

Parameters

label: str

Short name for the hook, used in fire logs and decision records.

events: Event = Event.PreToolUse | Event.PermissionRequest

Which event(s) to answer on; defaults to PreToolUse | PermissionRequest.

only_if: Sequence[TCondition] = ()

Conditions that must all match for the approval to fire.

skip_if: Sequence[TCondition] = ()

Conditions that veto the approval (the dialog shows normally).

tests: InlineTests | None = None
Inline tests run by capt-hook test.

Example

>>> approve(
...     "teammate bash under skip-permissions",
...     only_if=[Tool("Bash"), FromSubagent(), SkipPermissions()],
...     skip_if=[ToolInput("command", r"\brm\b")],
... )

deny()

Register a hook that answers matching tool permissions with deny.

Usage

Source

deny(
    reason,
    *,
    events=Event.PreToolUse | Event.PermissionRequest,
    only_if=(),
    skip_if=(),
    tests=None
)

By default it registers on PreToolUse | PermissionRequest, so the deny lands even on paths where the dialog runs no hooks (a teammate dialog forwarded to the lead, CC #73176). At PreToolUse a deny is a real guard, not just a dialog answer: it blocks calls that would otherwise proceed without prompting at all (auto-allowed by settings, or a session in bypass mode). Avoid pinning a deny to events=Event.PermissionRequest where approves may fire at PreToolUse — a PreToolUse allow pre-empts the dialog stage (absent an explicit settings ask rule), so a dialog-only deny never gets a dialog to answer.

Fires on every match — no fire cap — returning a deny whose message is reason.

Warning

an unconditioned deny() rejects every call, bricking every matching

Parameters

reason: str

The denial message shown to the user, also the hook’s label.

events: Event = Event.PreToolUse | Event.PermissionRequest

Which event(s) to answer on; defaults to PreToolUse | PermissionRequest.

only_if: Sequence[TCondition] = ()

Conditions that must all match for the denial to fire.

skip_if: Sequence[TCondition] = ()

Conditions that veto the denial (the dialog shows normally).

tests: InlineTests | None = None
Inline tests run by capt-hook test.

Example

>>> deny("No force pushes from subagents",
...      only_if=[FromSubagent(), Runs("git", "push"), Command(r"--force\b(?!-)")])

llm_approve()

Register an LLM safety judge that auto-approves permission dialogs it deems safe.

Usage

Source

llm_approve(
    label,
    *,
    events=Event.PermissionRequest,
    rubric=None,
    only_if=(),
    skip_if=(),
    model="small",
    tests=None
)

Replicates Claude Code’s auto-mode classifier (which is not programmatically invocable): the judge’s rubric is seeded from claude auto-mode defaults (cached globally, keyed by claude --version; a static built-in rubric stands in when the binary or verb is unavailable), and rubric appends to that base. TypeSafe Jev judges first: its unsafe verdict shows the dialog at once, and its safe verdict, or a Jev failure, goes on to the LLM, whose safe verdict answers the dialog with allow. An unsafe LLM verdict or any LLM failure returns None so the real dialog shows — it never auto-denies. A safe call costs an LLM round-trip, so scope it tightly with only_if/skip_if.

Unlike approve()/deny(), the default stays PermissionRequest-only: a dialog is a rare event, but PreToolUse fires on every matching tool call, which would put the judge’s LLM round-trip on the hot path. Pass events=Event.PreToolUse | Event.PermissionRequest to pre-authorize anyway — that also covers forwarded teammate dialogs, which run no PermissionRequest hooks (CC #73176) — and mind the cost.

Under capt-hook test, the stubbed LLM always returns safe=False, so inline tests deterministically expect Ask().

Parameters

label: str

Short name for the hook, used in fire logs and decision records.

rubric: str | None = None

Extra rubric text appended to the seeded base.

only_if: Sequence[TCondition] = ()

Conditions that must all match before the judge is consulted.

skip_if: Sequence[TCondition] = ()

Conditions that veto the judge (the dialog shows normally).

model: TModel = "small"

spawnllm model tier for the judge call.

tests: InlineTests | None = None
Inline tests run by capt-hook test.

Example

>>> llm_approve("safe teammate commands", only_if=[Tool("Bash"), FromSubagent()])

style.styleguide()

Register one change-scoped hook applying the given style rules to Python edits and writes.

Usage

Source

style.styleguide(
    *rules, block=False, only_if=(), skip_if=(), events=None, max_shown=5
)

Each rule is a [StyleRule][captain_hook.style.StyleRule] (or [StyleDiffRule][captain_hook.style.StyleDiffRule]) subclass whose docstring is its message. The single registered hook parses the edited file once, runs every rule against the post-edit tree, scopes each violation to the changed lines, and emits one aggregated warning (or block, when block is set). Call again with different only_if / skip_if / events / block to register a separately scoped hook.

Parameters

rules: type[StyleRule] = ()

StyleRule / StyleDiffRule subclasses to apply.

block: bool = False

Block the tool call instead of warning.

only_if: Sequence[TCondition] = ()

Extra conditions ANDed onto the built-in Edit|Write + *.py guards.

skip_if: Sequence[TCondition] = ()

Extra conditions ORed onto the built-in test-file skip.

events: Event | None = None

Override the default PostToolUse targeting.

max_shown: int = 5
Maximum violations shown per rule.

Example

>>> styleguide(NoPrint, NoBareExcept)
>>> styleguide(NoSqlInjection, block=True, only_if=[FilePath("api/**/*.py")])

style.matchers

Usage

Composable AST matchers for style rules — import this module as M.

Every factory (kind, calls, under, …) and preset (imports, control_flow, …) is a module-level [Matcher][captain_hook.style.matchers.Matcher] builder or constant; author a rule by combining them with &, |, and ~.

Example

>>> from captain_hook.style import matchers as M
>>> M.imports & M.child_of(M.control_flow) & ~M.under(M.type_checking)

Classes

Name Description
Matcher A composable, immutable AST matcher: a node predicate that is also a tree selector.
Matcher

A composable, immutable AST matcher: a node predicate that is also a tree selector.

Usage

Source

Matcher(test, label="matcher", structural=False)

A Matcher wraps a single (node, parents) -> bool test. Node-local matchers (e.g. [calls][captain_hook.style.matchers.calls]) ignore parents; structural matchers (e.g. [under][captain_hook.style.matchers.under]) consult it. Compose with the boolean algebra & (intersection), | (union), and ~ (negation), refine with [where][captain_hook.style.matchers.Matcher.where], and finish with a terminal — [over][captain_hook.style.matchers.Matcher.over], [violations][captain_hook.style.matchers.Matcher.violations], or [exists][captain_hook.style.matchers.Matcher.exists].

The module-level vocabulary (M.imports, M.control_flow, …) and factories (M.kind, M.calls, …) are the whole surface — author a new rule by combining them, not by reaching for a framework helper.

Parameter Attributes
test: Predicate
label: str = "matcher"
structural: bool = False
Example
>>> M.imports & M.child_of(M.control_flow) & ~M.under(M.type_checking)
Methods
Name Description
diff() Yield violations for matches in post whose key was absent from pre.
exists() Return whether any node in tree matches.
matches() Test a single node (node-local matchers only; raises for structural matchers).
over() Yield every node in tree that matches.
violations() Yield a [Violation][captain_hook.style.Violation] for each match, located at its line.
where() Refine with a node-local one-off predicate — the escape hatch for bespoke conditions.
diff()

Yield violations for matches in post whose key was absent from pre.

Usage

Source

diff(pre, post, key=ast.unparse, label=None)
exists()

Return whether any node in tree matches.

Usage

Source

exists(tree)
matches()

Test a single node (node-local matchers only; raises for structural matchers).

Usage

Source

matches(node)
over()

Yield every node in tree that matches.

Usage

Source

over(tree)
violations()

Yield a [Violation][captain_hook.style.Violation] for each match, located at its line.

Usage

Source

violations(tree, label=None)
where()

Refine with a node-local one-off predicate — the escape hatch for bespoke conditions.

Usage

Source

where(predicate)

Functions

Name Description
annotated() Match an annotation owner (annotated variable, parameter, or function return).
calls() Match a call to the bare-name function name (e.g. calls("zip")).
child_of() Match a node whose immediate parent matches other.
following() Match a body statement that follows the first sibling matching boundary.
kind() Match any of the given AST node types — the primitive for a category not shipped.
kwarg() Match a call that passes the keyword argument name (combine with calls).
named() Match a class/function/assignment/parameter whose bound name matches pattern (re.search).
ref() Match the bare name reference name (e.g. ref("Any") for x: Any).
under() Match a node with any ancestor matching other (negate with ~ for “not inside”).
annotated()

Match an annotation owner (annotated variable, parameter, or function return).

Usage

Source

annotated(inner=None)

Excludes *args/**kwargs. When inner is given, the owner’s annotation expression must also match it (e.g. annotated(ref("Any"))).

calls()

Match a call to the bare-name function name (e.g. calls("zip")).

Usage

Source

calls(name)
child_of()

Match a node whose immediate parent matches other.

Usage

Source

child_of(other)
following()

Match a body statement that follows the first sibling matching boundary.

Usage

Source

following(boundary)
kind()

Match any of the given AST node types — the primitive for a category not shipped.

Usage

Source

kind(*types, label=None)
kwarg()

Match a call that passes the keyword argument name (combine with calls).

Usage

Source

kwarg(name)
named()

Match a class/function/assignment/parameter whose bound name matches pattern (re.search).

Usage

Source

named(pattern)
ref()

Match the bare name reference name (e.g. ref("Any") for x: Any).

Usage

Source

ref(name)
under()

Match a node with any ancestor matching other (negate with ~ for “not inside”).

Usage

Source

under(other)

style.StyleRule

Base class for a single-tree AST style rule applied to Python edits and writes.

Usage

Source

style.StyleRule()

Subclass it and write the rule’s message as the class docstring ({violations} is substituted at fire time). Declare the rule as data by setting match to a [Matcher][captain_hook.style.matchers.Matcher] (and optionally label); override check only for logic a matcher can’t express. The class name is the rule’s identity — NoNestedImports becomes "no-nested-imports".

Example

from captain_hook.style import matchers as M

class NoNestedImports(StyleRule):
    """Lazy imports belong at the top of the function body: {violations}"""

    match = M.imports & M.child_of(M.control_flow) & ~M.under(M.type_checking)

style.StyleDiffRule

Base class for a diff rule: flags constructs newly introduced by the change.

Usage

Source

style.StyleDiffRule()

Like [StyleRule][captain_hook.style.StyleRule], but it compares the pre-edit and post-edit trees. The declarative form flags nodes matching match in the new tree that were absent from the old tree (by unparsed source); override check when the “newly introduced” identity needs custom logic.

Example

from captain_hook.style import matchers as M

class NoNewWildcardImport(StyleDiffRule):
    """Wildcard import added by this edit: {violations}"""

    match = M.imports.where(lambda n: any(a.name == "*" for a in n.names))

style.ast_grep_rule()

Build a [StyleRule][captain_hook.style.StyleRule] from an inline ast-grep pattern.

Usage

Source

style.ast_grep_rule(
    name, *, pattern, message, lang="py", label=None, tests=None
)

pattern is an ast-grep pattern string with metavariables (print($$$)). message is the rule’s message ({violations} is substituted at fire time); label names each match, defaulting to the matched code. Register the result with [styleguide][captain_hook.style.styleguide].

Example

>>> NoPrint = ast_grep_rule("NoPrint", pattern="print($$$)", message="No print(): {violations}")
>>> styleguide(NoPrint)

style.ast_grep_diff_rule()

Like [ast_grep_rule][captain_hook.style.ast_grep_rule], but flags only matches the edit newly introduces.

Usage

Source

style.ast_grep_diff_rule(
    name, *, pattern, message, lang="py", label=None, tests=None
)

Example

>>> NoNewWildcard = ast_grep_diff_rule("NoNewWildcard", pattern="from $M import *", message="No `import *`")

style.AstGrepStyleRule

A [StyleRule][captain_hook.style.StyleRule] matched by an ast-grep pattern over source text.

Usage

Source

style.AstGrepStyleRule()

Built by [ast_grep_rule][captain_hook.style.ast_grep_rule]; the engine runs pattern against the edited source in lang and scopes each match to the changed lines.

style.AstGrepStyleDiffRule

An [AstGrepStyleRule][captain_hook.style.AstGrepStyleRule] that flags only constructs the edit introduces.

Usage

Source

style.AstGrepStyleDiffRule()

style.Violation

A single style violation, located by line so the runner can scope it to the edit.

Usage

Source

style.Violation(line, label)

Attributes

line: int

1-based line number of the offending construct in the post-edit file.

label: str
Short human-readable description, rendered as "{label} (line {line})".

style.Change

The pre- and post-edit state of a file, passed to every style rule’s check.

Usage

Source

style.Change(source, pre, path)

Attributes

source: str

The post-edit source text.

pre: str

The pre-edit source text ("" when there is nothing to diff against).

path: str

The post-edit file’s path as a string, so rules can inspect the filename.

tree: ast.Module

The parsed post-edit module (an empty module when the source doesn’t parse).

pre_tree: ast.Module
The parsed pre-edit module (parsed lazily, only when a diff rule reads it).

GateVerdict

LLM response model for llm_gate. The LLM sets block=True to deny.

Usage

Source

GateVerdict()

NudgeVerdict

LLM response model for llm_nudge. The LLM sets fire=True to trigger the nudge.

Usage

Source

NudgeVerdict()

PromptCheckVerdict

LLM response model for prompt_check. Action is "ok", "warning", or "block".

Usage

Source

PromptCheckVerdict()

SafetyVerdict

LLM response model for llm_approve. The LLM sets safe=True to auto-approve.

Usage

Source

SafetyVerdict()