Session workflow

Carry one Pydantic state object across a session and block Stop if 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 None
NoteVerified by replay

The 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 tests

What 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 assert

The 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

See also