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.

Observers watch 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

interface Observer {
  name: string;
  writes?: readonly string[];
  watch?: ObserverWatch;
  onResult(event: ToolResultEvent, ctx: ObserverContext): void | Promise<void>;
}

interface ObserverWatch {
  toolName?: string;
  inputMatches?: Record<string, Pattern>;
  exitCode?: number | "success" | "failure" | "any";
}
name
string
required
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.
writes
readonly string[]
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.
watch
ObserverWatch
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)
onResult
(event, ctx) => void | Promise<void>
required
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:
// From examples/work-item-plugin/src/observers/npm-test-tracker.ts

import type { Observer, ObserverContext, PredicateContext } from "pi-steering";

// 1. Export the event-type constant — raw string lives in exactly one place.
export const TEST_PASSED_EVENT = "example-npm-test-passed" as const;

export interface TestPassedPayload {
  command: string;
}

// 2. Export a mark helper — encapsulates the write shape.
//    Callable from both an observer (ObserverContext) and a rule's
//    onFire hook (PredicateContext) — both expose appendEntry.
export function markTestPassed(
  ctx: ObserverContext | PredicateContext,
  payload: TestPassedPayload = { command: "npm test" },
): void {
  ctx.appendEntry<TestPassedPayload>(TEST_PASSED_EVENT, payload);
}

// 3. Export the observer itself, using the helper.
export const npmTestTracker = {
  name: "npm-test-tracker",
  writes: [TEST_PASSED_EVENT],
  watch: {
    toolName: "bash",
    inputMatches: { command: /^npm\s+test\b/ },
    exitCode: "success",
  },
  onResult: (event, ctx) => {
    const input = event.input as { command?: string } | undefined;
    markTestPassed(ctx, {
      command: input?.command ?? "npm test",
    });
  },
} as const satisfies Observer;

Consuming the event in a rule

A rule that depends on the observer’s event imports the TEST_PASSED_EVENT constant — never the raw string — so a typo in either direction becomes a compile error:
import { TEST_PASSED_EVENT } from "./observers/npm-test-tracker.ts";

const publishNeedsTests = {
  name: "publish-needs-tests",
  tool: "bash",
  field: "command",
  pattern: /^npm\s+publish\b/,
  when: {
    happened: { event: TEST_PASSED_EVENT, in: "agent_loop" },
  },
  reason: "Run `npm test` first. Tests must pass before publishing.",
} as const satisfies Rule;
Registering both together in defineConfig wires the compile-time cross-reference:
import { defineConfig } from "pi-steering";

export default defineConfig({
  observers: [npmTestTracker],
  rules: [publishNeedsTests],
});
Inside 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:
  1. A <EVENT>_EVENT constant — the session-entry event type literal. The raw string lives in exactly one place; all consumers import this constant.
  2. A mark<Event>(ctx, payload?) helper — encapsulates the write shape. Accepts either ObserverContext or PredicateContext so it can be called from both observers and rule onFire hooks.
  3. The observer itself — uses the helper in its onResult implementation.
When no observer corresponds (self-marking rule only), the constant and helper live in the rule file instead. See 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:
sync && cr --description notes.md
Without speculative synthesis, the evaluator would block this chain: it runs before execution, so when it reaches 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

JoinerSpeculative allow?Reason
A && BB runs only if A succeeded
A ; BB runs regardless of A
A | Bpipeline, no ordering
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: (_event, 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;

// Given the pair above:
// bash `sync && cr ...`  → allowed (cr has prior-&& ref matching the sync observer)
// bash `cr ...`          → blocked (no prior && ref, observer hasn't fired yet)
// bash `sync ; cr ...`   → blocked (semicolon doesn't short-circuit)

Build docs developers (and LLMs) love