Prompt Contexts
PromptContext
One declarative XML block attached to an LLM primitive’s prompt.
Usage
PromptContext()content(evt) runs at evaluation time; None or empty content omits the block. When a required context yields empty content, the primitive skips the LLM call entirely — nothing fires and no fire is consumed.
apply_contexts()
Append each context’s block to prompt in order, each clipped to max_len characters.
Usage
apply_contexts(prompt, evt, contexts, *, max_len=2000)Over-long content is clipped with an explicit …(+Nch) marker, never a silent truncation. A context yielding None or whitespace-only content has its block omitted; when that context is required, returns None instead — the caller must skip the LLM call entirely.
BeforeEdit
The pending edit’s pre-image, as a <before_edit> block.
Usage
BeforeEdit(tag="before_edit", required=False)Reads ~captain_hook.events.ToolHookEvent.replaced — an Edit’s old text or a MultiEdit’s joined olds at any event, a Write’s current on-disk content only at PreToolUse (after the Write lands, disk holds the new text). The block is omitted whenever the pre-image is unknowable: non-edit events, Writes off PreToolUse, unreadable paths. Attached to every LLM primitive as a default context; required=False because it is ambient enrichment. Pass BeforeEdit(required=True) explicitly to gate a hook on a non-empty pre-image.
Parameter Attributes
tag: str = "before_edit"required: bool = False
AfterEdit
The pending edit’s new text, as an <after_edit> block.
Usage
AfterEdit(tag="after_edit", required=False)Reads ~captain_hook.events.BaseHookEvent.content — the text an Edit, Write, MultiEdit, or NotebookEdit is about to land. Attached to every LLM primitive as a default context; required=False because it is ambient enrichment, empty (and omitted) on non-edit events.
Parameter Attributes
tag: str = "after_edit"required: bool = False
Introduced
Constructs the pending edit newly introduces, as an auto-tagged block.
Usage
Introduced(kind=None, pattern=None, required=True, tag=None)Exactly one of kind/pattern selects the constructs: kind names tree-sitter node kinds (a bare string or any set — normalized to frozenset; see ~captain_hook.ast_grep.COMMENT_TYPES), pattern is an ast-grep pattern. Extraction diffs the event’s before/after text, so only constructs absent before the edit appear; files without a supported language yield nothing. The pre-image comes from evt.replaced, so hooks covering Writes need events=Event.PreToolUse — at any other event a Write’s pre-image is unknowable, the context yields None, and (being required) the LLM call skips rather than misreporting every construct as introduced.
required defaults to True: a context you attach explicitly IS the evidence — no evidence, no LLM call. tag auto-derives from the class name in snake_case (Introduced() renders <introduced>; a subclass named TombstoneComments renders <tombstone_comments>). Subclass and override keep() to filter which introduced constructs count.
Parameter Attributes
kind: str | Set[str] | None = Nonepattern: str | None = Nonerequired: bool = Truetag: str | None = None
Example
>>> llm_nudge("...", contexts=[Introduced(pattern="print($$$)")],
... events=Event.PreToolUse, only_if=[Tool("Edit", "Write", "MultiEdit")])Methods
| Name | Description |
|---|---|
| keep() | Whether an introduced construct’s text belongs in the block — override to filter. |
keep()
Whether an introduced construct’s text belongs in the block — override to filter.
Usage
keep(text)WorkflowScriptSource
Gating context: the pending Workflow call’s script source, headed by its model pins.
Usage
WorkflowScriptSource(tag="workflow_script", required=True)Resolves the script via ~captain_hook.conditions.workflow_script_source(), excerpts the text around every model: pin into a header capped at PIN_EXCERPT_CAP, and truncates the body past WORKFLOW_SCRIPT_CAP with an explicit marker. Yields None off a Workflow event or an unreadable script.
Parameter Attributes
tag: str = "workflow_script"required: bool = True
UserMessages
The session’s user prompts as a <user_messages> request/authorization record.
Usage
UserMessages(last=4, per_message=600, tag="user_messages", required=True)Collects every real user prompt from the transcript — turn-opening text under the native classifier, so meta and hook-injected user events are excluded — then renders the first prompt followed by the most recent last, deduped where they overlap. Each prompt is clipped to per_message characters with an explicit …(+Nch) marker and prefixed [first] or [recent -N] (-1 most recent) so a judge can order them. The first prompt leads so the original ask survives the tail clip apply_contexts() applies. Yields None when the session carries no user prompt; being required by default, that skips the LLM call rather than judging a hook against an empty authorization record.
Attributes
last: int-
How many of the most recent prompts to render after the first.
per_message: int-
Character budget each rendered prompt is clipped to.
tag: str-
The XML block tag; defaults to
user_messages. required: bool-
Whether empty content skips the LLM call; defaults to
True.
Example
>>> llm_gate("does this edit stay within what the user asked?", contexts=[UserMessages()])Excerpts
Verbatim excerpts pulled from a text under a character budget.
Usage
Excerpts(excerpts, quoted, dropped)excerpts are the kept windows in source order; quoted counts the spans they cover and dropped the spans excluded once the budget filled. Render with block().
Attributes
excerpts: tuple[str, …]-
The kept excerpt strings, in source order, without indentation.
quoted: int-
How many input spans the kept excerpts cover.
dropped: int- How many input spans the budget excluded.
Attributes
| Name | Description |
|---|---|
| capped | Whether the budget excluded at least one span. |
capped
Whether the budget excluded at least one span.
capped: bool
Methods
| Name | Description |
|---|---|
| block() |
Render the excerpts as an indent-prefixed block.
|
block()
Render the excerpts as an indent-prefixed block.
Usage
block(noun, *, indent=" ", empty="(none)")A capped block ends in a … [+N more <noun> not excerpted] marker line; an empty one renders the empty placeholder.
Parameters
noun: str-
Plural noun for the dropped-count marker, e.g.
"model pins". indent: str = " "-
Prefix applied to every line, the marker and placeholder included.
empty: str = "(none)"- Placeholder rendered when there are no excerpts.
excerpt_around()
Verbatim excerpts of text around each (start, end) character span.
Usage
excerpt_around(
text, spans, *, before=160, after=60, whole_line_at=200, budget=2000
)Spans are whole-text character offsets — re.Match.span() is the natural producer, and ast-grep callers can pass node.range().start.index / node.range().end.index. Spans group by line: a line at or under whole_line_at characters is quoted whole in one window; a longer line yields one window per span, clamped to the line as [start - before, end + after] and merged when windows overlap or touch, each bracketed by … only where text was actually elided. Excerpts accumulate in source order under a greedy character budget — the first excerpt is always kept, and once the budget fills every later span is dropped.
Parameters
text: str-
The source text to excerpt from.
spans: Sequence[tuple[int, int]]-
(start, end)character spans intotext, in source order. before: int = 160-
Characters of context kept before each span on a long line.
after: int = 60-
Characters of context kept after each span on a long line.
whole_line_at: int = 200-
Lines at or under this length are quoted whole.
budget: int = 2000- Character budget across the kept excerpts.
COMMENT_TYPES
Tree-sitter node kinds that denote a comment, across every supported grammar.
COMMENT_TYPES: frozenset[str] = GENERATED_COMMENT_TYPES
The union covers every [LANG_GLOBS][captain_hook.langs.LANG_GLOBS] grammar. Markdown (md) is the verified exception with no comment kinds. A Dart block documentation comment has an outer comment with a nested documentation_block_comment; its grammar has no literal documentation_comment kind. Derived at build time from each grammar’s node-types.json: the named kinds whose name contains comment, minus any referenced as a child inside another comment kind (Rust’s doc_comment and its markers, Dart’s nested block). Generation fails loud if any grammar but Markdown yields no comment kinds.
Edit
The pending edit’s before/after source, parsed for structural queries.
Usage
Edit(old_text, new_text, lang)Reached via [evt.edit][captain_hook.ToolHookEvent.edit]; None there when the event carries no edit or the file’s language has no grammar. old and new are lazily parsed [SyntaxNode][captain_hook.SyntaxNode] trees; matches and introduced take ast-grep pattern strings ("print($$$)").
Parameter Attributes
old_text: strnew_text: strlang: str
Example
>>> if evt.edit and (added := evt.edit.introduced("print($$$)")):
... return evt.warn(f"New print() calls: {[m.line for m in added]}")Methods
| Name | Description |
|---|---|
| introduced() |
Matches of pattern present after the edit but absent before it, by whitespace-normalized text.
|
| matches() |
Whether pattern matches anywhere in the post-edit source.
|
introduced()
Matches of pattern present after the edit but absent before it, by whitespace-normalized text.
Usage
introduced(pattern)matches()
Whether pattern matches anywhere in the post-edit source.
Usage
matches(pattern)SyntaxNode
One node of a parsed syntax tree — the framework’s face over the ast-grep binding node.
Usage
SyntaxNode(raw)Parameter Attributes
raw: SgNode
Methods
| Name | Description |
|---|---|
| descendants() | Every node below this one, in document order. |
descendants()
Every node below this one, in document order.
Usage
descendants()Match
A structural match, located by 1-based line to align with Violation and changed-line scoping.
Usage
Match(line, end_line, text)Parameter Attributes
line: intend_line: inttext: str