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=(),
events=None,
max_fires=DEFAULT_FIRES,
tests=None,
async_=False,
skip_planning_agents=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. 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=(),
block=False,
events=None,
max_fires=DEFAULT_FIRES,
tests=None,
async_=False,
skip_planning_agents=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.
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.
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
)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")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=(),
events=None,
max_fires=DEFAULT_FIRES,
tests=None,
max_context=2000,
specialty="review",
model="small",
agent=True,
transcript=True,
diff=False
)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.
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 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).
Example
>>> llm_gate("Is the agent making excuses?",
... message=lambda r: f"Excuse detected: {r.reasoning}",
... signals=Signals([Signal(r"external.*service", weight=2)], threshold=2))
>>> llm_gate("Does the new code hardcode a secret?",
... message=lambda r: f"Secret detected: {r.reasoning}",
... 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,
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,
diff=False
)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.
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 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).
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="Tombstone comment: {reasoning}",
... contexts=[Introduced(kind=COMMENT_TYPES)],
... events=Event.PreToolUse, only_if=[Tool("Edit", "Write", "MultiEdit")])prompt_check()
Run an LLM 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
)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(), Command(r"push\s+--force")])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. A safe verdict answers the dialog with allow; an unsafe verdict or any LLM failure returns None so the real dialog shows — it never auto-denies. Adds an LLM round-trip to every matching ask, 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()