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. In a delegated lane the detected classifier keeps the sidechain prompts, so the record is the lane’s own brief and the later messages its team sent it, never the orchestrator’s conversation.
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