Transcript & Signals
Transcript
The Transcript provides a typed API for querying the conversation history. Access it via evt.ctx.t in hook handlers.
Loading
from captain_hook import Transcript
# From a JSONL file (how Claude Code stores transcripts)
t = Transcript.from_path("/path/to/transcript.jsonl")
# From raw message dicts
t = Transcript.from_messages([
{"role": "user", "content": "Fix the bug"},
{"role": "assistant", "content": [
{"type": "text", "text": "I'll look into it"},
{"type": "tool_use", "name": "Bash", "id": "tu1",
"input": {"command": "pytest tests/ -x"}},
]},
])Querying tool uses
# All tool uses (filters errors by default)
for tu in t.tool_uses:
print(tu.name, tu.file)
# Include errors
for tu in t.tool_uses.with_errors:
print(tu.name, tu.is_error)
# Filtered queries
edits = t.tool_uses.where(name="Edit", file="*.py")
bash_cmds = t.tool_uses.where(name="Bash")
errors = t.tool_uses.where(is_error=True)
# Terminal methods
edits.count() # int
edits.any() # bool
edits.first() # ToolUse | None
edits.last() # ToolUse | None
edits.files() # list[File]
edits.list() # list[ToolUse]Convenience checks
# Has a specific tool been used? (alias-aware: Bash ↔ Execute)
t.has_tool("Bash")
# Has a command matching a regex been run?
t.has_command(r"uv\s+run\s+mtest")
# Has a file matching a glob been edited?
t.has_edit_to("**/*.py")
# Has a file been read?
t.has_read("TESTING.md")
# Did the user say something?
t.user_said("fix", "bug")
# Has a skill been used?
t.has_skill("codex")Slicing
# Messages after the last use of a tool
after_edit = t.after("Edit")
# Messages before the last use of a tool
before_test = t.before("Bash")
# Last N messages
recent = t.recent(5)
# All messages before the current turn
prior = t.priorCurrent turn
turn = t.current_turn
turn.user_text # The user's message text
turn.edited_files # Set of File objects edited this turnText extraction
t.full_text # All message text concatenated
t.assistant_text # Assistant messages only, truncated
t.commands # Parsed Command objects from Bash usesTyped tool inputs
Tool uses have typed input objects accessible via tu.input:
| Tool | Input type | Key fields |
|---|---|---|
| Bash / Execute | BashInput | command, timeout |
| Edit | EditInput | file_path, old, new, file |
| Write / Create | WriteInput | file_path, content, file |
| Read | ReadInput | file_path, limit, offset, file |
| Task | AgentInput | agent_type, prompt |
| Grep | GrepInput | pattern, file_type, path |
| Glob | GlobInput | patterns |
| Skill | SkillInput | skill |
| TaskCreate | TaskCreateInput | subject, description |
| TaskUpdate | TaskUpdateInput | task_id, status |
| (other) | GenericInput | raw (the raw dict) |
Use .as_() for safe type narrowing:
if bash := tu.input.as_(BashInput):
print(bash.command)The live task list
For task-completion gates (e.g. a Stop hook that blocks until work is done), read the live task list via evt.tasks instead of replaying TaskCreate/TaskUpdate tool uses from the transcript:
@on(Event.Stop)
def task_gate(evt: BaseHookEvent) -> HookResult | None:
if open_tasks := evt.tasks.open:
return evt.block(f"{len(open_tasks)} of {len(evt.tasks)} tasks still open")
return Noneevt.tasks reads Claude Code’s native task store (~/.claude/tasks/<session-id>/) keyed by the event’s session_id, so it stays in sync with what TaskList actually shows. A session with no task store has no tasks — it never falls back to another session’s. Transcript-derived task_ops() is still useful for analyzing behavior (did the agent create tasks this turn?), but it drifts from the real list: it misses updates made by subagents and teammates, deleted tasks, and lists carried across resumed sessions.
Signals
Signals score recent transcript text to detect patterns like retry loops, speculation, or blame-shifting.
Signal (regex)
from captain_hook import Signal
Signal(pattern=r"let me try again", weight=2)
Signal(pattern=r"retry|retrying", weight=1)
Signal(pattern=r"investigating", weight=-3) # suppresses scoreNlpSignal (NLP-based)
from captain_hook import NlpSignal, Clause, Phrase
NlpSignal(
clauses=[
Clause(noun=Phrase("test"), verb=Phrase("run")),
],
weight=3,
)NLP signals use spaCy dependency parsing to match semantic relationships (e.g., “run the test” matches verb=run, noun=test). Phrases can be expanded with WordNet synonyms via Phrase("run").expand().
Signals bundle
from captain_hook import Signals, Signal
signals = Signals(
patterns=[
Signal(r"retry", weight=2),
Signal(r"investigating", weight=-3),
],
threshold=3, # minimum score to trigger
window=10, # last N messages to score
)Using signals with primitives
Pass a Signals bundle to nudge(), gate(), llm_gate(), or llm_nudge():
from captain_hook import nudge, Signals, Signal
nudge(
"Stop retrying — narrow the test first",
signals=Signals(
patterns=[Signal(r"try again", weight=2)],
threshold=3,
window=10,
),
)The primitive automatically:
- Extracts text from the last
windowtranscript messages - Scores each text against all patterns
- Deduplicates via content hashing (same text won’t re-trigger)
- Appends “Triggered by: …” context to the message