Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/cad0p/pi-steering-hooks/llms.txt

Use this file to discover all available pages before exploring further.

Agents frequently chain related commands in a single tool call: sync && cr --description notes.md. The problem is timing. The evaluator runs before execution, so when it reaches cr, the observer that writes ws-sync-done hasn’t fired yet. The rule blocks. The agent retries. Same block. Infinite loop. The &&-chain speculative allow pass is pi-steering’s solution to this class of deadlock.

The synthesis pass

When the engine walks the bash AST for a tool call, it runs a speculative-entry synthesis pass alongside the normal tracker state walk. For every ref in an unconditionally-&&-reachable segment of the chain, every observer that:
  1. Declares writes: [event], AND
  2. Matches the ref via its watch filter (including watch.inputMatches.command)
…contributes a synthetic entry into the next ref’s walkerState.events[event]. The built-in when.happened predicate merges real session entries (from ctx.findEntries) with speculative entries (from walkerState.events) by timestamp. A speculative ws-sync-done entry satisfies the rule exactly as a real one would — the chain is allowed through.

Why it’s safe

&& short-circuits on prior failure. There are exactly two outcomes:
  • The prior command succeeds → it runs, the observer fires, appendEntry writes the real event. The speculative allow is retroactively justified.
  • The prior command fails → the current ref never runs at all. The speculative entry never mattered.
No other joiner offers this guarantee:
JoinerSpeculative allow?Reason
A && BB runs only if A succeeded
A ; BB runs regardless of A
A | BPipeline — no ordering guarantee
A || BB runs only if A failed

Worked example

const syncObserver = {
  name: "ws-sync-tracker",
  writes: ["ws-sync-done"],
  watch: {
    toolName: "bash",
    inputMatches: { command: /^sync\b/ },
    exitCode: "success",
  },
  onResult: (_e, ctx) => ctx.appendEntry("ws-sync-done", {}),
} as const satisfies Observer;

const crNeedsSync = {
  name: "cr-needs-sync",
  tool: "bash", field: "command",
  pattern: /^cr\b/,
  when: { happened: { event: "ws-sync-done", in: "agent_loop" } },
  reason: "Run `sync` first.",
} as const satisfies Rule;

// bash `sync && cr ...` → allowed (speculative entry satisfies the rule)
// bash `cr ...`         → blocked (no prior &&, observer not synthesized)
// bash `sync ; cr ...`  → blocked (semicolon doesn't short-circuit)
With sync && cr ...: the synthesis pass sees sync as unconditionally-&&-reachable before cr, finds syncObserver matching sync, and populates walkerState.events["ws-sync-done"] at the cr ref. The when.happened check passes. After execution completes, syncObserver.onResult fires and writes the real entry to the session JSONL. With sync ; cr ...: the semicolon joiner does not qualify. cr has no synthetic entry and the rule blocks normally.

Authoring requirement

Observers participating in speculative allow must declare watch.inputMatches.command. A broad observer that matches every bash event is too weak a signal — the engine cannot safely synthesize an allow from it because there’s no basis for knowing which prior command in the chain would have written the event. Without watch.inputMatches.command, the synthesis pass skips the observer.

The speculative: true flag

Synthetic entries carry a speculative: true flag. The built-in when.happened treats real and speculative entries identically — the merge is transparent. Plugin predicates that need pure historical semantics (e.g. they want to count only entries that actually occurred before this session, not hypothetical ones) can inspect and filter on this flag:
const realOnly = ctx.walkerState?.events["ws-sync-done"]?.filter(
  (e) => !e.speculative
);

when.happened with notIn

The notIn option subtracts a narrower scope from in:
when: {
  happened: {
    event: "ws-sync-done",
    in: "agent_loop",
    notIn: "tool_call",
  },
}
This means “happened in a prior tool call in this agent loop” — it blocks the same-tool_call speculative bypass when you need that strictness. notIn is scope subtraction, not boolean negation: it does not invert the in scope; it carves out the narrower notIn scope from the wider in scope. Use this when you want to ensure the user explicitly ran sync in a previous tool call, not just chained it in the same command string.

Performance notes

when.happened is O(N_session_entries) per unique event type per tool call. Entries are scanned on first read per type and cached for the rest of the evaluation phase — the cache invalidates when new entries are written within the same phase. A 5000-entry session with six distinct when.happened rules costs roughly 600 µs per tool call on findEntries alone. Typical sessions under 500 entries are well within budget. Long-running multi-day sessions may notice the overhead as the JSONL grows. Mitigation options until a customType-keyed index lands in a future version:
  • Consolidate when.happened rules that share an event type, reducing the number of unique scan passes per tool call.
  • Rotate or truncate the session JSONL between work sessions to keep entry counts manageable.

Build docs developers (and LLMs) love