Skip to content

Context

A context is an activatable scope. It is not a check that refuses — it is a mode that turns on and off as the session goes, accumulates what it sees while on, and exists so that other rules can depend on it: a gate requires it, or a match reads its active flag and payload.

.sloprail/context/<name>/context.yaml
# research-run — a #research tag was written this turn, so this branch is
# research. Wakes on the tag event, filtered to #research, and activates.
on:
- event: PostTagWrite
match: any(event.tags, .label == "research")
enter: ./enter.sh
exit: ./exit.sh

Three keys. on is the list of triggers that wake enter (each an event and an optional match — the same nested event.* scope a gate uses). enter is the script that decides whether to activate. exit is the script consulted on Stop while active, deciding whether the context may deactivate. A context has no checks — its verdict-bearing work is done by the gates that require it.

Any pre-action kind (so a refactor can be spotted before a write via PreToolUse), plus the PostFile* and PostTagWrite kinds (so it can wake on a tag the agent just wrote, or a file that just landed). A context may not trigger on Stop — its exit is always checked on Stop anyway, so a Stop trigger would be redundant. Ask the load check for the build’s exact list, or see the per-nature admission table in Events.

PostFileWrite is available here (context-only) as the alias for PostFileCreate

  • PostFileUpdate.

enter runs on every occurrence of a trigger — active or not — and its exit code decides activation; its stdout only decides the payload:

  • Exit 0 and print a JSON object → the context activates (or stays active), and that object replaces its payload, readable elsewhere as context["<name>"].payload.
  • Exit 0 and print nothing → the context still activates (or stays active), keeping the payload it already had — none, the first time. A silent clean exit is not a “no”.
  • Exit non-zero → decline: this trigger leaves the context exactly as it was. An inactive context stays inactive; an active one stays active with its payload (declining is not exit saying done).

So every “no” path in an enter must exit non-zero. This is how a cheap match narrows to “some tool is about to run” and enter makes the real decision from the trajectory.

Terminal window
# enter: activate only if a refactor was actually declared.
input="$(cat)"
# ... inspect the trajectory via "$(jq -r '.transcriptPath' <<<"$input")" ...
[ -n "$declared" ] || exit 1 # not a refactor — decline (exit 0 would activate)
jq -n --arg scope "$scope" '{declared_markers: ($scope | split(",")), declared_at: "trajectory"}'

Output that is not a flat JSON object (an array, a string, invalid JSON) is not read as a payload: the engine reports it and leaves the context as it was.

On stdin enter receives a ContextEnterPayload: the flat event (read .event.path, .event.newContent, .event.tags, .event.kind), .transcriptPath, .currentContext (this context’s own last {active, payload} — because enter runs whether or not it was already active), and .gates (every declared gate’s most recent verdict). Its full shape and the other payload envelopes are in Events.

When a trigger’s match already settled the condition (e.g. any(event.tags, .label == "research")), enter need not re-check it — activating unconditionally is fine:

Terminal window
jq -n '{declared: true}'

A context that must remember several things across a turn — every tag declared, every artifact that landed — logs each into sr-session state under its own name, rather than growing payload in place (payload is one object, replaced on each enter). One entry per subject:

Terminal window
kind="$(printf '%s' "$input" | jq -r '.event.kind // ""')"
case "$kind" in
PostTagWrite)
printf '%s' "$input" | jq -r '.event.tags[]?.label // empty' | while IFS= read -r tag; do
[ -n "$tag" ] && sr-session state set "tag:$tag" "declared"
done ;;
PostFileCreate|PostFileUpdate)
path="$(printf '%s' "$input" | jq -r '.event.path // ""')"
[ -n "$path" ] && sr-session state set "artifact:$path" "$kind" ;;
esac
jq -n '{active_since: "trajectory"}'

A paired gate then reads that registry back with sr-session state list --owner <this-context> — the one read that crosses the per-guardrail boundary. See State management.

exit is consulted on a Stop while the context is active, and it decides one thing: whether the context deactivates. It never refuses the Stop — a context is a mode, not a check. To keep the turn from ending while the mode is unfinished, a gate on Stop that reads the context does the refusing (see “A thin exit that reads a gate’s verdict” below).

  • exit 0 → the context deactivates.
  • non-zero → the context stays active for the next cycle. The Stop still proceeds unless a gate refuses it.
Terminal window
# exit: is the declared refactor done?
input="$(cat)"
declared="$(printf '%s' "$input" | jq -r '.currentContext.payload.declared_markers[]?' 2>/dev/null)"
[ -n "$declared" ] || exit 0 # nothing declared to reconcile — let it close
# ... check each declared marker actually landed ...
[ -z "$missing" ] || exit 1 # not done — stay active (a gate refuses the Stop)
exit 0

On stdin exit receives a ContextExitPayload: the flat event (always the Stop, so only .event.kind), .transcriptPath, .currentContext (this context’s own {active, payload} — where payload is what enter produced), and .gates (every gate’s most recent verdict, by name). Full shape in Events.

Often the real judgement lives in a gate, and exit only mirrors its verdict — symmetric to how a gate’s match reads context. exit reads .gates["<gate>"] and its .status:

Terminal window
input="$(cat)"
status="$(printf '%s' "$input" | jq -r '.gates["depth-check"].status // "fail"' 2>/dev/null)"
[ "$status" = "pass" ] && exit 0
exit 1

.gates[...].status is "pass"/"fail". This keeps the depth logic in one place (the gate) and lets the context’s exit be a one-line consequence of it.

This is the point of a context — what it is for.

  • A gate requires it. require: [{context: research-run}] holds the gate’s check until the context has entered this cycle, so anything the context accumulated is current when the gate reads it.
  • A match reads its state. In a gate’s or another context’s match, context["research-run"].active (bool) and context["research-run"].payload (the object enter produced) are readable. context["tag-declared"].active skips a gate declaratively when the context never activated; not context["tag-declared"].active is exactly “no tag was declared this cycle”.

The active read in a match is only meaningful once the context has actually run its enter this cycle — which is why a gate that acts on it also requires it, to guarantee that ordering. (A match that merely reports absence — not …active — needs no require; there is nothing for the context to have run first.)

Note that a match reads context[...] at run time (context names are project-defined, so the type checker leaves the map open) — a typo in the context name is not caught at load. Cause the trigger and confirm the dependent rule actually fires.

Keep the folder; disable the context from .sloprail/config.yaml by its qualified name — remembering that any gate which requires it will then refuse (its precondition can never be met), so disable the dependents too if that is not what you want:

disabled:
- <plugin-or-project>/context/<name>

The nature is part of the key — .../context/<name> — because a context and a gate may share a bare name.