Playbook not saved
The failure
Section titled “The failure”Something you asked for never became a task, and never got explicitly dismissed either. It didn’t error and it wasn’t refused — it just fell out of view between one thing and the next. Nothing marks the gap, because a request that vanishes leaves nothing behind to notice. The only signal is later, when the thing you expected never arrives.
How it works
Section titled “How it works”Every request has to end up accounted for — either mapped to a task or explicitly mapped to nothing. This is reconciliation: collect all the requests, subtract the ones that were handled, and refuse if anything is left.
-
A context records each request the agent explicitly excuses — a declared dismissal that names exactly which message needs no task — into its own registry.
-
A gate at
Stopcollects every request, subtracts the ones a task references and the ones explicitly excused, and refuses if any residue is left unaccounted for.
A context can only track; the gate is what refuses the incomplete turn.
The actual configuration
Section titled “The actual configuration”This is the real example shipped at examples/intake-nothing-unprocessed/. Two
natures, each a directory under .sloprail/ — a *.yaml declaration plus the
scripts it names:
Directory.sloprail/
Directorycontext/
Directoryskip-declared/
- context.yaml
- enter.sh
- exit.sh
Directorygate/
Directoryverify-intake-complete/
- gate.yaml
- verify-no-residue.sh
context
Section titled “context”Wakes on a declared skip and logs each excused request into its own registry, so the gate can subtract it from the residue. It only tracks — it never refuses.
# The "explicitly linked to nothing" half of intake. The agent marks a message# needing no task with `#skip <line>...` in its prose; this context enters on# the tag and logs each excused message as skip:<transcript>:<line>-<line> into# its own registry, the exact ref shape the intake gate collects and subtracts# via `state list --owner skip-declared`.## Must be a context, not the agent's own Bash: `state set` needs the hook# environment the agent's shell lacks. --owner is the cross-context read.on: - event: PostTagWrite match: any(event.tags, .label == "skip")enter: ./enter.shexit: ./exit.sh#!/usr/bin/env bash# Read each #skip message's line number(s) from the trajectory and log each as# skip:<transcript>:<line>-<line> into this context's own state — the ref shape# the gate subtracts via --owner. The agent names the line; transcriptPath# supplies the path.set -uo pipefail
input="$(cat)"transcript_path="$(printf '%s' "$input" | jq -r '.transcriptPath // ""')"
if [ -z "$transcript_path" ]; then # No transcript to key skips against — nothing to log, activate quietly. jq -n '{skips_declared: "trajectory"}' exit 0fi
# Every #skip message's prose: each entry that wrote a skip tag, its text.skip_texts="$(sr-session trajectory normalize \ --path "$transcript_path" \ --events PostTagWrite \ | jq -r ' def msgtext: if type == "string" then . elif type == "array" then [.[] | select(.type? == "text") | .text] | join(" ") elif type == "object" then [(.content // [])[] | select(.type? == "text") | .text] | join(" ") else "" end; [ .[] | select(any(.events[]?; .kind == "PostTagWrite" and any(.tags[]?; .label == "skip"))) ] | .[] | .message | msgtext')"
# Each bare integer in the skip prose is an excused message line; log each as a# skip:<transcript>:<n>-<n> registry entry.printf '%s\n' "$skip_texts" | grep -oE '[0-9]+' | sort -u -n | while IFS= read -r n; do [ -z "$n" ] && continue sr-session state set "skip:${transcript_path}:${n}-${n}" "declared"done
# Activate. The real payload is the registry just written, read via --owner.jq -n '{skips_declared: "trajectory"}'#!/usr/bin/env bash# exit: nothing to keep open. The skips live in state that persists across# cycles on its own, so deactivate (exit 0); a later #skip re-enters and appends.cat >/dev/nullexit 0The piece that blocks. It runs on every Stop, collects every request, subtracts
what was handled or excused, and refuses when the residue is non-empty.
# On Stop: every user message either mapped to a task or explicitly mapped# to nothing. Success is when the residue is empty — collect all user# messages, subtract the ones a task references, refuse if anything is left# unaccounted for.on: - event: Stopchecks: - script: ./verify-no-residue.sh#!/usr/bin/env bash# The residue pattern: collect every user message this turn, subtract those a# task file references AND those the sibling skip-declared context excused (read# via `state list --owner skip-declared`), refuse if anything is left.## Runs on EVERY Stop and does NOT require the skip context — it must check the# residue regardless, and the Stop order (context enters before Stop gates)# makes a #skip declared this cycle visible here anyway.set -uo pipefail
ws="${SR_WORKSPACE:-.}"
input="$(cat)"transcript_path="$(printf '%s' "$input" | jq -r '.transcriptPath')"if [ -z "$transcript_path" ] || [ "$transcript_path" = "null" ]; then echo "verify-no-residue: could not read .transcriptPath from the hook payload; refusing because a residue check that cannot see the transcript must not be read as approval" >&2 exit 1fi
# Every GENUINE user message this turn as /abs/path:line-line — absolute path,# not a bare id, since a session can span multiple jsonl files. A genuine user# message is an entry with .type == "user" and isMeta NOT true; .line is its# 1-based jsonl position.## isMeta excludes a real, previously-hit failure mode: Claude Code records a# Stop hook's OWN refusal text ("Stop hook feedback: These user messages are# not mapped to any task...") as a plain `type: "user"` entry, shape-identical# to something the person typed (internal/transcript/entry.go's own IsMeta doc# comment names this exact case). Without excluding it, every refusal this gate# emits becomes a NEW unresolved "user message" on the very next cycle: the# agent skips the real residue, the gate's own refusal about it becomes fresh# residue, the agent skips THAT, ad infinitum — measured directly in a real# Haiku run (examples/intake-nothing-unprocessed/eval/multi-ask-turn): #skip 47# begat a residue at line 51 (the refusal that had just fired), #skip 51 begat# 57, and so on for 7 cycles with no way out, regardless of how the agent# responded — a structural trap this check created, not an agent failure to# fix by teaching a better response.normalized="$(sr-session trajectory normalize --path "$transcript_path")"normalize_status=$?if [ "$normalize_status" -ne 0 ]; then echo "verify-no-residue: sr-session trajectory normalize failed (exit $normalize_status) on $transcript_path; refusing because a residue check that could not read the transcript must not be read as approval" >&2 exit 1fi
all_message_refs="$(printf '%s' "$normalized" \ | jq -r --arg t "$transcript_path" '.[] | select(.type == "user" and (.isMeta // false) == false) | "\($t):\(.line)-\(.line)"')"jq_status=$?if [ "$jq_status" -ne 0 ]; then echo "verify-no-residue: could not extract user messages from the normalized transcript (jq exit $jq_status); refusing because a residue check that could not parse the transcript must not be read as approval" >&2 exit 1fi
if [ -z "$all_message_refs" ]; then # A genuinely empty transcript (no user messages at all) is the one case # this is allowed to read as "no residue" — every error path above already # refused before reaching here. exit 0fi
# Every message a task file references — same shape. tasks/ is anchored on# $SR_WORKSPACE because a check runs with cwd = its own guardrail folder, not# the repo root.referenced="$(grep -rohE '\(/[^)]+:[0-9]+-[0-9]+\)' "$ws/tasks" 2>/dev/null | tr -d '()' | sort -u)"
# Every message the skip-declared context excused, read across the per-guardrail# boundary with --owner. `state list` emits JSON lines, so slurp with `jq -s`.skipped="$(sr-session state list --owner skip-declared 2>/dev/null \ | jq -s -r '[.[] | select(.key | startswith("skip:"))] | .[].key | ltrimstr("skip:")')"
accounted_for="$(printf '%s\n%s' "$referenced" "$skipped" | sed '/^$/d' | sort -u)"
residue=""while IFS= read -r ref; do [ -z "$ref" ] && continue if ! grep -qx "$ref" <<< "$accounted_for"; then residue="$residue $ref" fidone <<< "$all_message_refs"
if [ -n "$residue" ]; then echo "These user messages are not mapped to any task, and none was marked skip: to skip one, write a message containing '#skip <line-number>' naming its 1-based line in the transcript (not a direct sr-session state set call — that needs the hook environment your shell does not have; the skip-declared context reads your #skip tag for you):$residue" >&2 exit 1fi
exit 0