Primitives
gate()
Register a blocking gate — nudge(message, block=True, ...) with an explicit signature.
Usage
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
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
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 = ...) -> NoneThree 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
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] = ...) -> NoneTwo 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
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
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
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 viare.sub. Sorewrite_command("cat $$$ARGS", "bat $$$ARGS")is structural andrewrite_command(r"^cat\s+(\S+)$", r"bat \1")is regex.notesurfaces asadditionalContextso 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. Whentoreturns None the hook blocks with block if given, otherwise passes through.notemay 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
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
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
labelinstead of the prompt hash, so review verdicts and fire state survive prompt edits; two registrations sharing alabelwithin 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.Nonekeeps the default — a blocking Stop/SubagentStop gate skips, anything else does not. PassFalsefor 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.
Nonekeeps the default — on everywhere except tool events. PassFalsefor 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 (seeverdict_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 aslambda r: r.block.Nonekeeps one stage, except for amessagetemplate that names astrfield, 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
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
labelinstead of the prompt hash, so review verdicts and fire state survive prompt edits; two registrations sharing alabelwithin 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 (seeverdict_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 aslambda r: r.fire.Nonekeeps one stage, except for amessagetemplate that names astrfield, 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
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
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
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()
Usage
check(evt, action=None)context()
The context decide() reads for evt: action, or the declaration’s action of the event.
Usage
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
decide(ctx)mint()
Mint the ticket rule earned for ctx’s action.
Usage
mint(ctx, rule, evidence)unchecked()
Mint the once ticket that lets ctx’s action go ahead when the check itself could not finish.
Usage
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
Rule(name, when)Parameter Attributes
name: strwhen: TCondition
Reach
How far a ticket reaches: one action, or every later action at its place.
Usage
Reach()Decide
A judge’s reading of the user’s words: the evidence it cites as permitting the action, if any.
Usage
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_evaluatetakes 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
quick(ctx, prompt, items)Proposal
What a hook asks a ticket to cover.
Usage
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
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;
placecovers 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
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;
Nonefor 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
current(evt)fact()
The injected lookup of type kind, or default of this context, built once.
Usage
fact(kind, default)fake()
A context a test declares outright; a plain string in said is one prompt of the user’s.
Usage
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
of(evt, kind, action, *, trees=(), window=WINDOW, machine=())ruling()
The user’s standing ruling ident, when it was recorded before the session started.
Usage
ruling(ident)rulings()
The user’s durable rulings naming term, recorded as cc-notes answers before the session started.
Usage
rulings(term, tags=frozenset())Names
Matches text that names the action’s place: every non-empty value of keys as a whole name.
Usage
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
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
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
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 = Noneids: 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
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
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
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
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+*.pyguards. skip_if: Sequence[TCondition] = ()-
Extra conditions ORed onto the built-in test-file skip.
events: Event | None = None-
Override the default
PostToolUsetargeting. 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
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: Predicatelabel: 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
diff(pre, post, key=ast.unparse, label=None)exists()
Return whether any node in tree matches.
Usage
exists(tree)matches()
Test a single node (node-local matchers only; raises for structural matchers).
Usage
matches(node)over()
Yield every node in tree that matches.
Usage
over(tree)violations()
Yield a [Violation][captain_hook.style.Violation] for each match, located at its line.
Usage
violations(tree, label=None)where()
Refine with a node-local one-off predicate — the escape hatch for bespoke conditions.
Usage
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
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
calls(name)child_of()
Match a node whose immediate parent matches other.
Usage
child_of(other)following()
Match a body statement that follows the first sibling matching boundary.
Usage
following(boundary)kind()
Match any of the given AST node types — the primitive for a category not shipped.
Usage
kind(*types, label=None)kwarg()
Usage
kwarg(name)named()
Match a class/function/assignment/parameter whose bound name matches pattern (re.search).
Usage
named(pattern)ref()
Match the bare name reference name (e.g. ref("Any") for x: Any).
Usage
ref(name)under()
Match a node with any ancestor matching other (negate with ~ for “not inside”).
Usage
under(other)style.StyleRule
Base class for a single-tree AST style rule applied to Python edits and writes.
Usage
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
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
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
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
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
style.AstGrepStyleDiffRule()style.Violation
A single style violation, located by line so the runner can scope it to the edit.
Usage
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
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
GateVerdict()NudgeVerdict
LLM response model for llm_nudge. The LLM sets fire=True to trigger the nudge.
Usage
NudgeVerdict()PromptCheckVerdict
LLM response model for prompt_check. Action is "ok", "warning", or "block".
Usage
PromptCheckVerdict()SafetyVerdict
LLM response model for llm_approve. The LLM sets safe=True to auto-approve.
Usage
SafetyVerdict()