# 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.

``` python
"""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
```

> **Note: Verified 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

``` python
# 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

``` python
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

``` bash
uvx capt-hook --hooks docs/examples test
```


# See also

- [Workflows](../../docs/guide/state.md#enforce-a-multi-step-workflow)
- [State](../../docs/guide/state.md)
- [Conditions](../../docs/reference/conditions.md)
