Skip to content

v1.18.0 — loop-state evidence, and an advisory continuation that is not a gate

Choose a tag to compare

@cheezy cheezy released this 01 Sep 10:51
· 11 commits to main since this release

Added — loop-state evidence on completion, and an advisory continuation that says plainly it is not a gate (W2150, W2151)

Stride's Claude Code plugin refuses to let a session end while a claimable task remains: its Stop hook exits 2 and the harness honours the refusal. This port cannot do that, and the reason is not a gap someone forgot to fill — it is visible in the type signatures. turn_end and agent_end register as ExtensionHandler<TurnEndEvent> with one type parameter, while tool_call and before_agent_start get two; the on() overloads that accept a returned object are exactly the two-parameter ones. An end-of-turn handler is therefore structurally incapable of returning a refusal — whatever it computes is discarded. So this release ships the two things that are available: the evidence, and an advisory.

The completion record (W2150). A successful /complete now writes .stride/.loop-state.json, and any claim clears it. Four keys, in the shell writer's order: identifier, needs_review, completed_at, session_id. This is a fleet contract, not a port-local artifact — stride's stride-stop-gate.sh and its PowerShell twin read exactly these keys out of a shared checkout — so needs_review is written as the literal JSON boolean rather than a string. That detail is the whole point of the contract: the gate tests (.needs_review | type) == "boolean", so a stringified "false" would silently defeat terminal-state detection rather than erroring, and the failure would look exactly like a session with nothing to hold open. The write is atomic (a temp file inside .stride/, then a rename, so a reader never sees a half-file) and never fatal to the completion that triggered it; the clear is an unlink rather than an empty write, because {} parses and would read as a record with no usable verdict.

Sourcing the record is two-tier, and the second tier is guarded. Tier 1 is the intercepted call's own output. Tier 2 is the canonical response file, admitted only when .hooks is an array and .data.id matches the id the command routed on — because that file survives across calls, so on a truncated or failed completion it still holds the previous claim payload, which carries both fields and would record a completion that never happened. The regression test for this deliberately uses a truncated body rather than a 422: a 422 body is valid JSON and overwrites the canonical file, so it cannot reach the trap at all.

The advisory continuation (W2151). A before_agent_start handler reads that record and, when it shows a completion no claim has followed, injects one message naming the claimable task from GET /api/tasks/next. It advises the next turn; it does not gate the previous one. By the time it can speak the turn that should have claimed is over, and if nobody prompts again it never fires. An injected message is context, not a veto — the model remains free to do something else.

Because Pi supplies no stop_hook_active equivalent on this path, the only bound is the port's own counter at .stride/.pi-advisory-continuations, keyed on the completed task and defaulting to two injections per unfollowed completion. STRIDE_PI_ADVISORY_MAX overrides it, unsigned decimal only — and that validation is load-bearing rather than tidy: Number("off") is NaN, count + 1 > NaN is false, so an unvalidated override would make the advisory unbounded, reached by someone trying to switch it off. The counter is written before the message is returned, since an injection the port cannot count is one it cannot bound.

What reaches the prompt, and what is refused. Only the claimable task's identifier — never a token, a URL, or a response body. It is checked against ^[A-Za-z0-9_-]{1,32}$ and refused rather than sanitised when it does not match, because a scrubbed value is still a value a remote server chose and it lands in text a model reads. A session id of unknown — the writer's sentinel for "identity could not be established" — is likewise refused on both sides; the shell gate's time-window fallback for that case is deliberately not copied, because it exists only where the bash side cannot learn the session id at all, and Pi always can.

Credential handling tightened beyond the shell twin. The new .stride_auth.md reader selects the production **API Token:** line by literal substring so a **Local API Token:** line cannot be picked up instead, and the API base is now re-parsed with a real URL parser rather than trusted as a regex match. Two properties of the bare match made it unsafe as a token destination: @ fell outside the character class, so https://evil.example@www.stridelikeaboss.com matched only https://evil.example and the bearer token would have gone to the attacker's host — a different host than any conformant parser resolves — and plaintext http was accepted, putting a token on the wire. Userinfo is now refused, plaintext is refused except to loopback so local development keeps working, and the origin is used. The shell resolver in stride-hook.sh still has both properties; this port diverges deliberately and says so in the module header.

ADR-002 records the decision. A new evidence subsection (d) proves the result-type asymmetry with file and line references, and a closing Addendum states the three consequences a reader must not mistake for a gate: it cannot prevent a stop, the model may ignore it, and nothing bounds it but us. The tool_call veto was considered as a lever and left alone — blocking the tool calls of a turn that is trying to finish is far more invasive and deserves its own decision.

Outstanding. The end-to-end confirmation that the injection appears on the next turn requires a running Pi session and was not performed.