Skip to content

Playbook not saved

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.

Watch it happen — then get refused

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.

  1. A context records each request the agent explicitly excuses — a declared dismissal that names exactly which message needs no task — into its own registry.

  2. A gate at Stop collects 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.

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

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.

.sloprail/context/skip-declared/context.yaml
# 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.sh
exit: ./exit.sh
.sloprail/context/skip-declared/enter.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 0
fi
# 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"}'
.sloprail/context/skip-declared/exit.sh
#!/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/null
exit 0

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

.sloprail/gate/verify-intake-complete/gate.yaml
# 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: Stop
checks:
- script: ./verify-no-residue.sh
.sloprail/gate/verify-intake-complete/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 1
fi
# 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 1
fi
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 1
fi
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 0
fi
# 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"
fi
done <<< "$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 1
fi
exit 0