Session workflow
Carry one Pydantic state object across a session and block
Stopif the agent edited files but never ran tests.
You want state that survives across events in one session. You capture the user’s intent at prompt submit, accumulate the files the agent edits, mark whether tests have run, and block Stop if the agent wraps up without running tests. Three standalone @session_state models would work, but you’d hand-roll the same read-update-write plumbing in every handler. @workflow_state("review") decorates one model and gives every handler ReviewState.load(evt) / state.save(evt) over a single namespace key.
"""Carry one Pydantic state object across a session and block Stop if the agent skipped tests."""
from __future__ import annotations
from pydantic import Field
from captain_hook import (
Allow,
BaseHookEvent,
Event,
HookResult,
Input,
Tool,
Waiting,
WorkflowState,
on,
workflow_state,
)
@workflow_state("review")
class ReviewState(WorkflowState):
intent: str | None = None
ran_tests: bool = False
edits: list[str] = Field(default_factory=list)
@on(Event.UserPromptSubmit, tests={Input(prompt="refactor the auth module"): Allow()})
def capture_intent(evt: BaseHookEvent) -> HookResult | None:
state = ReviewState.load(evt)
state.intent = (evt.user_prompt or "").strip()[:200]
state.save(evt)
return None
@on(
Event.PreToolUse,
only_if=[Tool("Edit|Write")],
tests={Input(tool="Edit", file="app/users.py", content="x = 1\n"): Allow()},
)
def record_edit(evt: BaseHookEvent) -> HookResult | None:
if not (fp := evt.file):
return None
state = ReviewState.load(evt)
state.edits = [*state.edits, str(fp.path)]
state.save(evt)
return None
@on(Event.PreToolUse, only_if=[Tool("Bash")], tests={Input(command="pytest -q"): Allow()})
def mark_tested(evt: BaseHookEvent) -> HookResult | None:
cmd = evt.command
if cmd.raw and cmd.q.runs("pytest"):
state = ReviewState.load(evt)
state.ran_tests = True
state.save(evt)
return None
@on(Event.Stop, skip_if=[Waiting()], tests={Input(): Allow()})
def require_tests_after_edits(evt: BaseHookEvent) -> HookResult | None:
state = ReviewState.load(evt)
if state.edits and not state.ran_tests:
return evt.block(
f"You edited {len(state.edits)} file(s) for `{state.intent}` "
"but never ran tests. Run pytest before stopping."
)
return NoneThe Stop block fires on accumulated state across multiple events, so it’s exercised by replaying real sessions rather than the single-event inline tests, which assert only the per-event narrowing and state plumbing.
This page composes four primitives over one shared state object: UserPromptSubmit captures intent, two PreToolUse handlers record edits and test runs, and Stop reads the accumulated state to decide whether to block.
What it catches
# After Edit/Write but no pytest run, the agent's Stop is blocked:
Stop # edited file(s) for the intent, never ran testsWhat it allows
Input(prompt="refactor the auth module") # UserPromptSubmit — captures intent, never blocks
Input(tool="Edit", file="app/users.py", content="x = 1\n") # records the edit, never blocks
Input(command="pytest -q") # marks tests as run, never blocks
Input() # Stop with no edits pending — nothing to assertThe block / allow split mirrors the hook inline tests, so it stays true as the hook evolves.
Run it yourself
uvx capt-hook --hooks docs/examples test