Observers watchDocumentation 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.
tool_result events — they fire after a tool call executes and can record state into pi’s session JSONL. Rules watch tool_call events — they fire before execution and consult that recorded state via when.happened. Together they enable stateful multi-step policies: “run sync before cr,” “tests must pass before publishing,” “re-read the description before each commit.” Rules gate forward; observers record backward; when.happened connects the two.
Observer interface
Unique observer identifier. Deduplicated across plugins — first-registered wins; later declarations with the same name log a warning. Rules reference observers by this name via
Rule.observer.Session-entry event type literals this observer’s
onResult may write. Declaring them here adds those literals to the AllWrites union inside defineConfig, making them referenceable from when.happened.event anywhere in the same config. Zero runtime cost — the engine never reads writes at dispatch time.Filter narrowing which
tool_result events trigger this observer. Omitted: every tool_result fires onResult.toolName— restrict to a specific tool ("bash","read","write","edit")inputMatches— per-field regex constraints on the tool INPUT (e.g.{ command: /^npm\s+test/ })exitCode—"success","failure", an exact exit-code number, or"any"(explicit no-filter)
Called on every matching
tool_result event. Typically writes a session entry via ctx.appendEntry(customType, data). Must be idempotent — the same event may fire the observer more than once across pi’s lifecycle (e.g. on session restart mid-turn). Async OK; the dispatcher awaits it per-observer with per-observer isolation (a throw in one observer does not skip the next).watch.inputMatches.command is wrapper-aware
The inputMatches.command filter is applied wrapper-aware: a regex for /^npm\s+test/ matches both npm test and sh -c 'npm test'. The dispatcher parses the bash command via the same walker used by the evaluator and tests the filter against both the raw outer command and every extracted ref text. This means the observer fires correctly for wrapped invocations without requiring separate patterns.
Full observer example
The following is the canonical observer from the work-item plugin (npm-test-tracker.ts), which demonstrates the complete ADR §14 encapsulation pattern:
Consuming the event in a rule
A rule that depends on the observer’s event imports theTEST_PASSED_EVENT constant — never the raw string — so a typo in either direction becomes a compile error:
defineConfig wires the compile-time cross-reference:
defineConfig, the event field of every when.happened is narrowed to the union of all declared writes across plugins, observers, and rules. A typo like "example-npm-test-passd" is rejected by the compiler.
Observer encapsulation convention (ADR §14)
Every observer file exports exactly three things:- A
<EVENT>_EVENTconstant — the session-entry event type literal. The raw string lives in exactly one place; all consumers import this constant. - A
mark<Event>(ctx, payload?)helper — encapsulates the write shape. Accepts eitherObserverContextorPredicateContextso it can be called from both observers and ruleonFirehooks. - The observer itself — uses the helper in its
onResultimplementation.
commit-description-check.ts for that case.
writes declarations have zero runtime cost — they are purely documentation and type-level plumbing. The engine does NOT verify at dispatch time that onResult only calls ctx.appendEntry with declared types. Their sole purpose is to populate the AllWrites union inside defineConfig so when.happened.event references can be compile-time-checked.&&-chain speculative allow
Agents frequently chain related commands in one tool call:
cr, the observer hasn’t written ws-sync-done yet. The rule fires, the chain is blocked, the agent retries, and the same block repeats — an infinite loop.
pi-steering resolves this with a walker-level speculative-entry synthesis pass. For every command ref in an unconditionally-&&-reachable segment, every observer that declares writes: [event] AND whose watch filter matches that ref contributes a synthetic entry into the next ref’s walkerState.events[event]. The built-in when.happened merges these synthetic entries with real session entries by timestamp, so a speculative ws-sync-done entry satisfies the rule exactly as a real one would.
The && short-circuit makes this semantically safe: either the prior command succeeds (and writes the event, retroactively justifying the allow), or it fails and the guarded command never runs.
Authoring requirement. Observers participating in the speculative allow must declare watch.inputMatches.command. An observer matching every bash event is not a strong enough signal to grant the allow.
Which joiners qualify
| Joiner | Speculative allow? | Reason |
|---|---|---|
A && B | ✅ | B runs only if A succeeded |
A ; B | ❌ | B runs regardless of A |
A | B | ❌ | pipeline, no ordering |
A || B | ❌ | B runs only if A FAILED |