# pi-steering

AST-backed steering rules for [pi](https://github.com/earendil-works/pi) agents, with stateful predicates and plugin-first composition.

## What this is

A deterministic guardrail layer that sits between your pi agent and the tools it invokes. You declare TypeScript rules that gate `bash` / `write` / `edit` tool calls; the engine parses every command with [`unbash-walker`](https://github.com/cad0p/unbash-walker), walks a per-call tracker state, matches against your rules, and returns a block verdict before pi executes. Observers record state from `tool_result` events so later rules can say "this must have happened first".

Use it when:

- You want to gate commands by structure, not substring — `sh -c 'git push --force'`, `cd /repo && git push --force`, and `git push "--force"` should all trigger the same `^git\s+push.*--force(?!-)` rule, and `echo 'git push --force'` should not.
- You want "must run X before Y" rules that survive across tool calls within the same user prompt.
- You want to ship + version a rule pack as an npm dependency (plugins), not a shared JSON file.

## Install

```bash
pi install npm:@cad0p/pi-steering
```

Requires **Node ≥ 22** — the loader reads `.pi/steering.ts` files via native type-stripping (no `tsx` / `ts-node` runtime). On older Node the loader throws with an upgrade message at startup.

### Local install (during the PoC)

Until the first npm publish, install from a local clone:

```bash
git clone https://github.com/cad0p/pi-steering.git
cd pi-steering

pnpm install
pnpm --filter pi-steering build   # dist/ is gitignored — build first

pi install .
```

Then restart pi.

**After code changes.** Rebuild, then restart pi:

```bash
pnpm --filter pi-steering build
```

Why both steps matter:

- `pi install <local-path>` only registers the path in settings — it does **not** run a build or any install hook.
- The package is compiled (`"main": "./dist/index.js"`) and `dist/` is gitignored, so edits to `src/` only take effect after a build.
- `/reload` inside pi picks up settings, skills, prompts, and themes — but for compiled extension code, transitive `dist/` imports sit in Node's native ESM cache and are not reliably reloaded. A full pi restart is the safe option after rebuilding.

### Hot-reload of the user config

`/reload` **does** pick up edits to your `.pi/steering/index.ts` (or `.pi/steering.ts`) without a pi restart. The loader cache-busts the dynamic import (`?t=<hrtime-bigint>`) so Node's ESM module map can't serve a stale copy of your config across reloads, and an initial-load failure (broken syntax, missing value import) doesn't poison subsequent loads after you fix the file.

It also picks up edits to **plugin source code** — if the plugin ships its `.ts` source as the package entry (Node 22+ native type-stripping; `"main": "./src/index.ts"`, `allowImportingTsExtensions: true`, `noEmit: true`). Pi loads the bridge via jiti with `moduleCache: false`, so static + dynamic imports inside the user config re-route through jiti's loader on every reload, and `.ts` modules get re-read from disk and re-evaluated.

What `/reload` still does **not** pick up:

- Edits to a plugin's **compiled** `dist/index.js` (or other `dist/*.js` files reached through it). Compiled-JS modules in `node_modules` end up cached for the process lifetime; only `.ts` source goes through jiti's re-evaluation path.
- Edits to pi-steering's own `dist/`. Same reason — the bridge entry (`src/index.ts`) is re-evaluated each reload, but transitively-imported compiled-JS modules from `dist/` are cached.

**Recommendation for plugin authors:** ship `.ts` source as the package entry to enable hot-reload during plugin development. The migration is small: switch `package.json#main` to `./src/index.ts`, drop the `tsc` build step (or keep it as `tsc --noEmit` for typecheck), set `allowImportingTsExtensions: true` and `noEmit: true` in `tsconfig.json`, optionally add `erasableSyntaxOnly: true` to reject non-strippable TS features (`enum`, namespaces, parameter properties) at compile time. Consumers must run Node ≥ 22.6 for native type-stripping.

## Config layers

pi-steering resolves exactly two layers, mirroring pi's own settings model:

- **Project** — `<cwd>/.pi/steering/` (or the `.pi/steering.ts` single-file form), loaded from the directory pi was launched in.
- **Global** — `<agentDir>/steering/`, where `agentDir` is `$PI_CODING_AGENT_DIR` (tilde-expanded) or `~/.pi/agent` by default. Applies to every project.

The project layer is merged INNERMOST: on rule/plugin/observer-name collision the project entry wins, so a project can override or soften a global rule by declaring the same name. There is no walk-up discovery — intermediate directories contribute nothing, and nothing below `~/.pi/agent/` is special-cased.

**Breaking change (v0.2.0): the old global location `~/.pi/steering/` is no longer loaded** — no alias, no deprecation diagnostic. The only situation where it still works is launching pi from `$HOME` itself, where `<cwd>/.pi/steering/` happens to be `~/.pi/steering/`. Migrate with:

```bash
mv ~/.pi/steering ~/.pi/agent/steering
```

## Quick start

Create `.pi/steering/index.ts` at your project root:

```ts
import { defineConfig } from "@cad0p/pi-steering";

export default defineConfig({
  rules: [
    {
      name: "no-force-push",
      tool: "bash",
      field: "command",
      // `(?!-)` rules out `--force-with-lease` — `\b` alone would match
      // it, since `-` is a non-word character and `--force\b` sees a
      // word boundary between `e` and `-`.
      pattern: /^git\s+push.*--force(?!-)/,
      reason: "Force-push rewrites history. Use --force-with-lease if needed.",
    },
  ],
});
```

With this config:

- `git push --force`, `sh -c 'git push --force'`, and `cd /repo && git push --force` all block via your rule.
- `git push --force-with-lease` is not matched.
- `git commit` on `main` / `master` / `mainline` / `trunk` blocks via the git plugin's `no-main-commit` rule (opt-in — declare `plugins: [gitPlugin]`, see [Defaults](#defaults) below).
- `echo 'git push --force'` correctly does not block — the AST extraction anchors patterns on real command refs, not substrings of arguments.

## Defaults

One default bundle ships with the package and is layered onto every config automatically:

- **`DEFAULT_RULES`** — `no-force-push`, `no-hard-reset`, `no-rm-rf-slash`, `no-long-running-commands`. Domain-agnostic safety rails. See [`src/defaults.ts`](./src/defaults.ts) for the exact patterns.

**`DEFAULT_PLUGINS` is deliberately empty** — domain plugins are opt-in. The [git plugin](./src/plugins/git/README.md) (the `branch` / `upstream` / `commitsAhead` / `hasStagedChanges` / `isClean` / `remote` predicates, the `no-main-commit` and `no-main-commit-github` rules (both overridable per commit via `# steering-override: <name> — <reason>`), the branch tracker (tool_call-scoped `git checkout` awareness), and the `cwd.git` tracker extension (`--git-dir=` / `--work-tree=` parsing)) is enabled by declaring it:

```ts
import { defineConfig } from "@cad0p/pi-steering";
import gitPlugin from "@cad0p/pi-steering/plugins/git";

export default defineConfig({ plugins: [gitPlugin] });
```

Declaring the plugin is what gives `defineConfig`'s generics the rule / plugin name unions for typo-checking on `disabledRules` / `disabledPlugins` — relying on a default would silently widen the union and let typos through. That is why the default is empty: runtime registration and type-level visibility must not diverge.

```ts
import gitPlugin from "@cad0p/pi-steering/plugins/git";

// Drop the shipped rule but keep the git predicates + tracker:
defineConfig({ plugins: [gitPlugin], disabledRules: ["no-main-commit"] });

// Drop EVERYTHING shipped — DEFAULT_RULES (and whatever plugins you declared):
defineConfig({ disableDefaults: true });
```

All three fields are typo-checked by `defineConfig`'s generics (see [Compile-time safety](#compile-time-safety-via-defineconfig)).

**Typecheck payoff.** Declare anything that should be typo-checked:

```ts
// @ts-expect-error — "wrong-name" is not a registered rule
disabledRules: ["wrong-name"],
```

This fails at `tsc --noEmit` time — rule / plugin / observer names are threaded through `defineConfig`'s generics and cross-validated.

## Glossary

Three orthogonal axes, three distinct word families. Keep them straight and the docs / rules / errors all line up.

**Time scope** (`TopLevelWhenClause.happened.in`):

- **`agent_loop`** — the current user prompt plus every tool call it spawns. Bumped on pi's `agent_start` event. Most common scope for workflow rules.
- **`session`** — the entire pi session across all agent loops. Persisted in the session JSONL, survives restarts.
- **`tool_call`** — the current bash tool call only. Considers ONLY speculative entries synthesized from `&&`-reachable observers. Use when the event MUST be chained directly before the guarded command.

**Entry origin** (how a session entry came to exist):

- **Real entry** — persisted in pi's session JSONL via `ctx.appendEntry`. Outlives the current tool call.
- **Speculative entry** — synthesized by the engine for a `&&`-chain, representing "if this chain runs to completion, this entry WILL be written." Not persisted; exists only for the current evaluation.
- **Synthesis pass** — walker-level pass that produces speculative entries from observer `writes:` declarations plus `&&`-chain reachability.

**Shell constructs** (what the agent typed):

- **`&&`-chain** — the shell construct `A && B && C`. Legitimate bash terminology throughout these docs; distinct from the retired adjective "chain-aware".
- **Pipeline** (`|`) — each peer runs in its own subshell; cwd / branch / state effects don't propagate across peers.
- **Subshell** (`(…)`) — cwd / branch effects are isolated to the subshell's body.

**Hook surfaces** (where code runs):

- **Tracker** — walker-level, static. Models per-ref state (cwd, branch, …) from the bash AST before execution. Plugin authors register under `Plugin.trackers`. See "Walker extensibility".
- **Observer** — engine-level, dynamic. Watches `tool_result` events and persists session entries via `ctx.appendEntry`. Plugin authors register under `Plugin.observers`. See "Observers".

**Walker terminology** (shell-semantics terms of art):

- **Effective cwd** — the cwd a command runs at, computed statically by the walker from preceding `cd` / `-C` constructs. Always `tool_call`-scoped (fresh per bash invocation).
- **Command ref** (`CommandRef`) — one extracted command node with its args, per bash tool call. Multiple per `&&`-chain.

## How it works

Concrete execution trace — what happens when an agent issues `bash("git push --force && cd /tmp && git log")` under the config above:

```
User prompt sent to pi.

1. pi.on("agent_start") → engine bumps agentLoopIndex from N to N+1.
   One "agent loop" = one user prompt + every tool call it spawns.

2. Agent decides to run the bash tool with:
     command = "git push --force && cd /tmp && git log"

3. pi emits tool_call. Evaluator runs (once per tool_call):

   a. parseBash(command)       → AST
   b. extractAllCommandsFromAST → 3 CommandRefs:
        ref#0: basename="git", args=[push, --force]   (Word[])
        ref#1: basename="cd",  args=[/tmp]
        ref#2: basename="git", args=[log]
   c. expandWrapperCommands    → no wrappers; still 3 refs.
   d. walk(ast, { cwd }, trackers) → per-ref state:
        ref#0 at cwd=/original
        ref#1 at cwd=/original
        ref#2 at cwd=/tmp  (the `cd /tmp` applied)
      Walker-level speculative-entry synthesis runs in the same pass,
      populating `walkerState.events` per ref (see "`&&`-chain
      speculative allow" below).
   e. For each ref × for each rule, build a Candidate:
        input.command   = ref.text (FLATTENED: "git push --force")
        input.basename  = "git"
        input.args      = ref.node.suffix (Word[] with quote-aware .value)
        cwd             = walkerState.cwd   (per-ref)
        walkerState     = { cwd, branch, …, events }  (all trackers +
                          synthesized events under the reserved `events` key)
        agentLoopIndex  = N+1
   f. Test rule.pattern / requires / unless against ref.text.
      Run when.cwd / when.branch / when.happened / plugin predicates.
      `when.happened` merges real entries (ctx.findEntries) with
      synthesized speculative ones (walkerState.events) by timestamp
      — one unified latest-entry comparison.
   g. First rule that ALL predicates pass on wins.
      Return { block: true, reason: "[steering:no-force-push@user] …" }.
      If the rule defines `onFire`, invoke it first (may writeSession entries,
      which the engine auto-tags with _agentLoopIndex).

4. If no rule blocked, pi executes the command.

5. pi emits tool_result. Dispatcher runs (once per tool_result):

   a. Parse event.input.command via walker. (The dispatcher parses
      independently from step 3 today — sub-millisecond per event.
      Cross-step AST caching is a future optimization.)
   b. For every observer whose `watch` filter matches:
        - `watch.inputMatches.command` matches raw outer command
          OR any ref.text (wrapper-aware, ADR §12).
        - `observer.onResult(event, observerCtx)` fires.
        - observerCtx.appendEntry(type, data) writes an entry —
          auto-tagged with _agentLoopIndex for `when.happened` filtering.
```

The important bits worth stressing:

- **One parse, many rules.** The AST walk happens once per tool call; every rule sees the same extracted refs and walker state. Adding rules is cheap.
- **Per-ref evaluation.** `cd /tmp && git log` evaluates the `git log` rule AT cwd `/tmp`, not at `/original`. Walker trackers (cwd by default; branch via the git plugin) update state as refs flow through the command chain.
- **Source-tagged reasons.** Block reasons carry `[steering:<rule>@<source>]` where source is `user` or the shipping plugin name. The agent can see both what fired and where to look it up.
- **First match wins.** Rule order matters within a layer, and the project layer beats the global layer on rule-name collision.

## Authoring rules

### Rule shape

```ts
interface Rule {
  name: string;                                 // unique; shown in block reason
  tool: "bash" | "write" | "edit";
  field: "command" | "path" | "content";        // which input field pattern tests
  pattern: string | RegExp;                     // main match
  requires?: Pattern | PredicateFn;             // AND extra
  unless?: Pattern | PredicateFn;               // exemption
  when?: TopLevelWhenClause;                   // composable predicates
  reason: string | ReasonFn;                    // message (or fn) to the agent
  noOverride?: boolean;                         // default: true (fail-closed)
  observer?: Observer | string;                 // name-ref to a shipped observer
  writes?: readonly string[];                   // declared session-entry types
  onFire?: (ctx: PredicateContext) => void;     // side-effect hook on block
}

type ReasonFn = (ctx: PredicateContext) => string | Promise<string>;
```

The **pattern** tests against the flattened `basename + " " + args.join(" ")` of each extracted command ref (bash). Anchor with `^` so substrings of arguments don't accidentally match. For write/edit, the pattern tests `path` or `content` directly.

The **reason** is written for the agent. Include what was blocked and what the safe alternative is — the agent reads it and acts on it. A plain string is the common case. For dynamic context (the walker-resolved cwd, a count pulled from `findEntries`), pass a function instead:

```ts
{
  name: "cr-upstream-mainline",
  tool: "bash", field: "command",
  pattern: /^cr\b/,
  reason: (ctx) =>
    ctx.walkerState?.cwd === "unknown"
      ? "Walker could not resolve cwd statically. Retry with a literal path, or run `cr` from inside a package directory."
      : "Your branch's upstream must track origin/mainline before running `cr`.",
}
```

Reason functions are awaited, and the result is prefixed the same way as string reasons (`[steering:<rule>@<source>] …`). If the function throws or rejects, the engine logs the error with `console.warn` and emits a fail-safe fallback body (`(reason failed to format; see log)`) so the block verdict still lands without leaking the raw error to the agent.

### `TopLevelWhenClause`

```ts
type TopLevelWhenClause<Writes extends string = string> = {
  // Built-in non-registry leaves (lifted onto BuiltInWhenLeavesOuter):
  cwd?: Pattern | Pattern[]
      | { pattern: Pattern | Pattern[]; onUnknown?: "allow" | "block" };
  happened?: {
    event: Writes;
    in: "agent_loop" | "session" | "tool_call";
    since?: Writes;     // optional invalidation sentinel
    notIn?: "agent_loop" | "session" | "tool_call";  // scope subtraction
  };
  condition?: (ctx: PredicateContext) => boolean | Promise<boolean>;

  // One level of negation (no recursion). Inside `not:`, leaf-level
  // `onUnknown:` is forbidden; the block-level modifier owns the
  // walker-unknown projection (default `"block"` = fail-CLOSED).
  not?: TopLevelWhenClauseNoRecurse<Writes>;
} & {
  // Plugin-registered predicate leaves — narrowed via the
  // PiSteeringPredicates registry. `branch:`, `upstream:`,
  // `isClean:`, etc. each declare their bare / spread shape via
  // `declare global` augmentation in the plugin's index.ts.
  // Homomorphic-with-filter mapping (constraint inlined as `keyof
  // PiSteeringPredicates` + as-clause filter); see the schema
  // doc-comment for why this AST shape is load-bearing for hover.
  [K in keyof PiSteeringPredicates as K extends ReservedPredicateKey
      ? never
      : K]?: OuterValue<K & PluginPredicateKey>;
};
```

Built-ins:

- **`cwd`** — rule fires only when the command's effective cwd matches. For bash, this is the per-ref cwd from the walker (so `cd ~/personal && git commit` evaluates against `~/personal`). Dynamic targets — `cd "$WS_DIR/pkg"`, `cd ~/proj` — resolve through the walker's env tracker (seeded from `process.env.{HOME, USER, PWD}` plus any bare assignments, `export`s, or `unset`s in the same chain). Intractable targets (`cd $(pwd)`, `cd $UNDEFINED`) surface as the `"unknown"` sentinel; apply `onUnknown: "allow" | "block"` (default `"block"`, fail-closed) to choose. For write/edit, it's the session cwd.
- **`happened`** — fires when an entry of `event` has NOT occurred in `in` scope. `"agent_loop"` filters by `_agentLoopIndex === ctx.agentLoopIndex` (one user prompt + its tool calls); `"session"` scans the whole session JSONL; `"tool_call"` considers only speculative entries synthesized for THIS tool_call's `&&`-chain. Optional `since` acts as an invalidation sentinel — see "Temporal ordering with `happened.since`" below. Optional `notIn` subtracts a narrower scope from `in` (e.g. `{ in: "agent_loop", notIn: "tool_call" }` means "happened in a prior tool_call in this loop", blocking the same-tool_call speculative bypass). `notIn` is set subtraction, distinct from the clause-level `not` (boolean negation). Synthesizes speculative entries across `&&` bash chains — see "`&&`-chain speculative allow" below.
- **`not`** — boolean NOT over an inner predicate block. One level only (no `not: not: ...` recursion). Inside `not:`, leaf-level `onUnknown:` is forbidden; the block-level `onUnknown:` modifier projects walker-unknown verdicts (default `"block"` = fail-CLOSED, rule fires).
- **`condition`** — escape hatch for one-off logic. Prefer plugin predicates when the logic is reusable. Throws (sync or rejected promise) are caught and treated as `"unknown"` → default `"block"` policy fires the rule fail-CLOSED. Authors needing fail-OPEN wrap inside `not: { condition: fn, onUnknown: "allow" }` OR catch the throw inside the callback body.

Plugin-registered predicate leaves come from the `PiSteeringPredicates` registry, populated by each plugin's `declare global` block:

```ts
// inside a plugin's index.ts
import type { Patterns, PredicateShape } from "@cad0p/pi-steering";

declare global {
  interface PiSteeringPredicates {
    // Auto-detected spreadBase form: `Bare` is the bare leaf type;
    // SpreadBase auto-detects to `{ pattern: Bare }` via
    // `DefaultSpreadBase<Bare>`.
    branch: PredicateShape<Patterns>;
    // Or explicit when the auto-detect doesn't fit:
    // commitsAhead: PredicateShape<number, { gt?: number; eq?: number; lt?: number }>;
  }
}
```

`PredicateShape<Bare, SpreadBase = DefaultSpreadBase<Bare>>` takes two type parameters. Each registry key contributes a leaf-level field on `TopLevelWhenClause` accepting the bare or spread form. `when.branch: /^main$/` is valid only when a plugin has augmented `PiSteeringPredicates` with a `branch` key. See `plugins/git/index.ts` for a worked example with all six gitPlugin predicates.

The legacy `WhenClause` interface is `@deprecated` for the JSON-v1 compatibility path (`compat.ts`); new code authors against `TopLevelWhenClause`.

### Predicate context

`PredicateFn`s and plugin `PredicateHandler`s receive a `PredicateContext`:

```ts
interface PredicateContext {
  cwd: string;                                  // effective cwd for this ref
  tool: "bash" | "write" | "edit";
  input: PredicateToolInput;                    // tool-shaped input
  agentLoopIndex: number;                       // current agent loop counter
  exec: (cmd, args, opts?) => Promise<ExecResult>;  // memoized per (cmd, args, cwd)
  appendEntry<T>(type: string, data?: T): void;
  findEntries<T>(type: string): Array<{ data: T; timestamp: number }>;
  walkerState?: Readonly<WhenWalkerState>;      // tracker snapshot (bash only)
}

interface WhenWalkerState {
  readonly cwd: string;                          // effective cwd, or "unknown"
  readonly env: ReadonlyMap<string, string>;     // env map (HOME/USER/PWD + chain writes)
  readonly [key: string]: unknown;               // plugin trackers (e.g. `branch`)
}
```

`exec` is memoized per `(cmd, args, cwd)` within a single tool_call — two rules reading the same git state don't re-fork git. No cross-call cache.

`PredicateToolInput.args` on bash gives you the `Word[]` suffix — quote-aware; `.value` is the lexical unwrapped value, `.text` is the raw source. Use this when a predicate needs to read `-m "feat: x"` without losing the quoted content.

`walkerState.env` carries the per-ref env map: bare assignments (`FOO=bar`), `export NAME=value`, and `unset NAME` from the same bash chain, plus `HOME`/`USER`/`PWD` seeded from `process.env` at session start. Use it to resolve `$VAR` / `${VAR}` / `~` in user-supplied patterns via the `resolveWord` helper re-exported from the package root:

```ts
import { resolveWord } from "@cad0p/pi-steering";

const myPredicate: PredicateHandler = (args, ctx) => {
  const expanded = resolveWord(userWord, ctx.walkerState!.env);
  return expanded !== undefined && /workspace/.test(expanded);
};
```

`resolveWord` returns `undefined` when any part of the word is statically intractable (unknown var, command substitution, arithmetic, parameter-expansion with modifiers). Handle that the same way the built-in `when.cwd` does — via an `onUnknown: "allow" | "block"` policy on your own predicate surface.

### `onFire`

`Rule.onFire` runs after all predicates pass and BEFORE the block verdict is returned. Use it for self-marking patterns:

```ts
{
  name: "commit-description-check",
  pattern: /^git\s+commit\b/,
  when: { happened: { event: "description-reviewed", in: "agent_loop" } },
  reason: "Re-read the commit message first.",
  writes: ["description-reviewed"],
  onFire: (ctx) => ctx.appendEntry("description-reviewed", {}),
}
```

First commit per agent loop blocks + self-marks. Second commit in the same loop: the self-mark satisfies `when.happened`, commit passes.

`onFire` errors are caught, logged, and the block still returns. The block already passed every predicate; a broken self-mark should not invalidate it.

### Observers

```ts
interface Observer {
  name: string;                                  // deduped across plugins
  writes?: readonly string[];
  watch?: ObserverWatch;
  onResult(event, ctx): void | Promise<void>;
}

interface ObserverWatch {
  toolName?: string;
  inputMatches?: Record<string, Pattern>;
  exitCode?: number | "success" | "failure" | "any";
}
```

Observers fire on matching `tool_result` events. `watch.inputMatches.command` is **wrapper-aware** — a regex for `/^npm\s+test/` matches both `npm test` and `sh -c 'npm test'`.

`observerCtx.appendEntry` auto-tags writes with `_agentLoopIndex`. Don't inject that tag yourself. Use `ctx.findEntries<Payload>(type)` to read prior entries back.

### `writes` declarations

Both `Rule.writes` and `Observer.writes` are optional string-literal arrays naming the custom session-entry event types the handler may `appendEntry`. They have **zero runtime cost** — the engine never reads them at dispatch time. Their sole purpose is compile-time cross-referencing inside {@link defineConfig}:

```ts
// observer ships the event
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;

export default defineConfig({
  observers: [syncObserver],
  rules: [{
    name: "cr-needs-sync",
    tool: "bash", field: "command",
    pattern: /^cr\b/,
    // `event` is type-narrowed to the union of all declared `writes`
    // across plugins + user observers. A typo like "ws-sync-don" is
    // rejected by the compiler.
    when: { happened: { event: "ws-sync-done", in: "agent_loop" } },
    reason: "Run sync first.",
  }],
});
```

When you skip declaring `writes`, the observer's produced events stay out of the `AllWrites` union and `when.happened.event` references to them are rejected as typos. The failure mode biases toward catching real typos (a plugin typo producing a non-firing rule turns into a compile error) at the cost of requiring each producer to enumerate its events once.

### Temporal ordering with `happened.since`

Sometimes "X happened" isn't enough — a later event should invalidate it. `happened.since` adds an optional invalidation sentinel:

```ts
{
  name: "cr-needs-fresh-sync",
  pattern: /^cr\b/,
  when: {
    happened: {
      event: "ws-sync-done",
      in: "agent_loop",
      since: "upstream-failed",
    },
  },
  reason: "Upstream failed after your last sync. Re-sync before cr.",
}
```

Semantics: the event counts as "happened" only if its most-recent entry in scope is strictly newer than the most-recent `since` entry. If `since` has never been written in scope, the clause degrades to the simple presence check — so adding `since` is safe even when the invalidator isn't in play yet.

Contrast with a hand-rolled `condition:` handler doing the same comparison: `since` is declarative, cross-checked at compile time (both `event` and `since` are constrained to the `Writes` union), and shared across rules without duplicating helper code. Reach for `condition` only when the comparison isn't "my event after their event" — e.g. counting, content matching, or quorum across multiple invalidators.

### `&&`-chain speculative allow

Agents frequently chain related commands in one tool_call:

```bash
sync && cr --description notes.md
```

The naive evaluation path blocks this chain: the evaluator runs BEFORE execution, so when it sees `cr`, the observer hasn't written `ws-sync-done` yet. Rule fires, block, retry, same block — an infinite loop.

pi-steering resolves this via a walker-level **speculative-entry synthesis pass**. For every ref in an unconditionally-`&&`-reachable segment, every observer declaring `writes: [event]` and matching the ref (via the shared `watch` filter) contributes a synthetic entry into the next ref's `walkerState.events[event]`. The built-in `when.happened` then 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, and the chain is allowed.

`&&` short-circuits on the prior's failure, so the speculative decision is safe: either the prior succeeds (and writes the event, retroactively justifying the allow), or it fails and the current ref never runs. Synthetic entries carry `speculative: true` so plugin predicates wanting pure historical semantics can filter them out; the built-in `happened` treats real and speculative entries identically.

**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 |

**Authoring requirement.** Observers participating in the speculative allow must declare `watch.inputMatches.command`. An observer matching every bash event isn't a strong enough signal to grant the allow.

Worked example:

```ts
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;

// 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)
```

### Compile-time safety via `defineConfig`

```ts
import { defineConfig } from "@cad0p/pi-steering";

export default defineConfig({
  plugins: [gitPlugin, myPlugin],
  rules: [
    {
      name: "must-read-docs",
      tool: "bash", field: "command",
      pattern: /^npm\s+publish/,
      observer: "description-read",               // ← typo-checked against plugin + inline observers
      when: { happened: { event: "doc-read", in: "agent_loop" } },  // ← event literal checked against writes
      reason: "Read the release notes before publishing.",
    },
  ],
  disabledRules: ["no-main-commit"],                // ← typo-checked against rule names
  disabledPlugins: ["git"],                         // ← typo-checked against plugin names
});
```

**Authoring gotcha.** For cross-reference checking to work, TypeScript must preserve literal types. Use `as const satisfies` on reusable constants:

```ts
// ✅ works
const myRule = { name: "x", writes: ["thing"], ... } as const satisfies Rule;

// ❌ widens to `name: string` + `writes: readonly string[]` — breaks inference
const myRule: Rule = { name: "x", writes: ["thing"], ... };
```

See [`src/v2/schema.ts`](./src/v2/schema.ts) `Rule.writes` JSDoc for the full footgun explanation.

## Writing plugins

A plugin is a named bundle of predicates / rules / observers / trackers / tracker extensions. Users opt in via `plugins: [...]`.

### Shape

```ts
interface Plugin {
  name: string;
  predicates?: Record<string, PredicateHandler>;
  rules?: Rule[];
  observers?: Observer[];
  trackers?: Record<string, Tracker<unknown>>;           // new state dimensions
  trackerExtensions?: Record<string, Record<string, Modifier<unknown> | readonly Modifier<unknown>[]>>;
}
```

### Canonical file layout (ADR §13)

```
src/
├── index.ts                              # default export: Plugin; re-exports
├── index.test.ts                         # plugin-level integration
├── predicates/
│   ├── <predicate>.ts
│   └── <predicate>.test.ts
├── observers/
│   ├── <observer>.ts                     # exports TYPE constant + mark helper + observer
│   └── <observer>.test.ts
└── rules/
    ├── <rule-or-group>.ts
    └── <rule-or-group>.test.ts
```

### Observer encapsulation convention (ADR §14)

Every observer file exports three things:

1. A `<EVENT>_EVENT` constant — the session-entry event literal.
2. A `mark<Event>(ctx)` helper — encapsulates the shape of what gets written.
3. The observer itself, using the helper.

Rules that consume the event import the EVENT constant, never the raw string. When no observer corresponds (self-marking rule only), the constant + helper live in the rule file instead.

See [`examples/work-item-plugin/src/observers/npm-test-tracker.ts`](./examples/work-item-plugin/src/observers/npm-test-tracker.ts) for a complete file following this pattern.

### Typed predicate handlers

```ts
import { definePredicate } from "@cad0p/pi-steering";

interface BranchArgs {
  pattern: RegExp;
  onUnknown?: "allow" | "block";
}

export const branch = definePredicate<BranchArgs>(async (args, ctx) => {
  // args is narrowed to BranchArgs here.
  return args.pattern.test(await resolveBranch(ctx));
});
```

`definePredicate<T>` is a zero-cost type helper — pure pass-through at runtime. Use it so plugin authors can declare typed arg shapes without having to cast at the plugin registration site.

### The canonical reference

[`examples/work-item-plugin/`](./examples/work-item-plugin/) is a compact, domain-generic plugin that demonstrates every v0.1.0 authoring pattern in one place. Read it top-to-bottom — the structure is meant to be copied.

Production plugins in this repo:

- [`src/plugins/git`](./src/plugins/git) — the canonical plugin reference for trackers + tracker extensions. Ships `branch` / `upstream` / `commitsAhead` predicates, a `branchTracker`, a `--git-dir` / `--work-tree` cwd extension, and the `no-main-commit` + `no-main-commit-github` rules.
- [`pi-steering-flags`](https://github.com/cad0p/pi-steering-flags) — first official external plugin, establishing the precedent for community plugins. Own repo + package since the monorepo split (2026-08-10). Ships `requiresFlag` / `allowlistedFlagsOnly` predicates and helper primitives.
- [`pi-steering-commit-format`](https://github.com/cad0p/pi-steering-commit-format) — commit-message format predicates. Own repo + package since the monorepo split (2026-08-10). Ships the `commitFormat` predicate plus a `commitFormatFactory` for composing custom format checkers; bundled formats include Conventional Commits 1.0.0 (Angular preset type allowlist) and bracketed JIRA-style references.

### Ecosystem discovery

Tag your plugin's `package.json` `keywords` with:

```jsonc
{
  "keywords": [
    "pi-package",            // surfaces on pi.dev alongside every pi extension
    "pi-steering-package"    // surfaces specifically for pi-steering plugins
    // ...plus any domain tags (cli, git, test-runner, ...)
  ]
}
```

- `pi-package` is pi's ecosystem-wide convention.
- `pi-steering-package` is the pi-steering plugin-specific tag. Community plugins using it will be surfaced in pi-steering's plugin directory (once one exists) without needing a manual registry.

Publishing conventions:

- **Package name**: `pi-steering-<domain>` (unscoped). Mirrors `@cad0p/pi-steering` core and `pi-steering-flags`. Scoped names (`@org/pi-steering-<x>`) are fine for internal packages.
- **Peer range**: pin to a major once `@cad0p/pi-steering` is v1+ (`"pi-steering": "^1"`). During the v0.x window, match the release train closely (`"pi-steering": "^0.1.0"`).
- **License**: MIT by default, matching the core. Amazon-internal / proprietary plugins use their own license; the core has no opinion on this.

### Overriding a built-in rule

Plugin-shipped rules are individually exported from their plugins (see `pi-steering/plugins/git`'s named exports). To tighten a rule's reason message — e.g. pointing your agents at an internal skill or team runbook — disable the original and re-register under a new name:

```ts
import { defineConfig } from "@cad0p/pi-steering";
import gitPlugin, { noMainCommit } from "@cad0p/pi-steering/plugins/git";
import type { Rule } from "@cad0p/pi-steering";

// Reuse everything about the original, just swap the reason.
const myNoMainCommit = {
  ...noMainCommit,
  name: "myorg-no-main-commit",
  reason: async (ctx) => {
    const original =
      typeof noMainCommit.reason === "function"
        ? await noMainCommit.reason(ctx)
        : noMainCommit.reason;
    return `${original}\n\nFor our workflow, see skill \`git-discipline@myorg\` or run \`pi-help git-flow\`.`;
  },
} as const satisfies Rule;

export default defineConfig({
  plugins: [gitPlugin],
  disabledRules: ["no-main-commit"],  // original off
  rules: [myNoMainCommit],            // replacement on
});
```

`as const satisfies Rule` preserves literal types so `defineConfig`'s cross-reference checks (on `happened.event`, `observer`, etc.) still run on the replacement. No need to restate `pattern` / `when` / `observer` / `onFire` — the spread carries them through.

Changing more than the reason (tightening the pattern, scoping by cwd, swapping the observer) works the same way: spread the original, then override the fields you want to change.

> **Always use a fresh `name` for the replacement.** Reusing the plugin rule's name has two failure modes — same name + no `disabledRules` keeps both rules (your customization silently fails to apply) and same name + `disabledRules` filters out both (silent fail-OPEN, the worst outcome for a safety rule). The git plugin's [Customization](./src/plugins/git/README.md#customization) section walks through worked examples (soften the reason text; cwd-based exemption with the array-form `cwd:` predicate's `onUnknown: "allow"` pin to keep `not:` carve-outs fail-closed under walker-unknown cwd).

## Walker extensibility

Plugin authors who need a new walker state dimension (something beyond `cwd` / `env` / `branch`) register a `Tracker<T>` under `Plugin.trackers`. The engine composes trackers at config load and feeds the merged map into unbash-walker's `walk()`.

Tracker authoring is a larger topic — see the [unbash-walker README](https://github.com/cad0p/unbash-walker) for the full `Tracker<T>` / `Modifier<T>` API. Plugins extend an existing tracker (e.g. layering a `--git-dir=…` parser on the core cwd tracker) via `Plugin.trackerExtensions`. Name collisions on `Plugin.trackers` are a hard error; modifier collisions log a WARN and keep the first-registered.

Most users never need this — plugin-registered predicates alone cover 90% of use cases.

### Shell-var expansion (`envTracker` + `resolveWord`)

The engine ships an `envTracker` alongside the built-in `cwdTracker`. It captures statically-resolvable env mutations from the same bash chain:

- Bare assignments: `WS_DIR=/ws; cd "$WS_DIR/pkg"` → `walkerState.env.get("WS_DIR") === "/ws"` at the `cd`, `walkerState.cwd === "/ws/pkg"` at the following commands.
- `export NAME=VALUE` and `unset NAME`.
- Subshell isolation: `(FOO=/s; cd "$FOO"); cmd` — outer `cmd` sees neither `FOO` nor the subshell's `cd`.
- Seeded from `process.env.{HOME, USER, PWD}` at tracker initialization, so `~` / `$HOME` / `$USER` / `$PWD` expand out of the box.

Out of scope for v0.1.0: `readonly`, `local`, `declare`, `typeset`, `source` / `.`, function-body walking. The envTracker's module-level JSDoc (`src/trackers/env.ts` in the [unbash-walker repo](https://github.com/cad0p/unbash-walker)) lists the full deferred-scope inventory and graduation criteria.

**`resolveWord(word, env)`** — re-exported from the package root — is the shared helper the built-in `cd` modifier uses to resolve a dynamic word (`$VAR`, `${VAR}`, `~`) through an env map. Plugin predicates that want the same semantics on user-supplied args should reuse it:

```ts
import { resolveWord, type PredicateHandler } from "@cad0p/pi-steering";

export const matchesHome: PredicateHandler = (args, ctx) => {
  const word = /* one of ctx.input.args */ args as Word;
  const resolved = resolveWord(word, ctx.walkerState!.env);
  return resolved !== undefined && resolved.startsWith("/home/");
};
```

Returning `undefined` means the word is statically intractable (unknown var, command substitution, arithmetic, parameter-expansion with modifiers). Handle it via an `onUnknown`-style policy on your predicate's option shape.

## Testing rules

The package exports a `@cad0p/pi-steering/testing` subpath with primitives that exercise the full pipeline without booting pi:

```ts
import { loadHarness, expectBlocks, expectAllows, testPredicate, testObserver }
  from "@cad0p/@cad0p/pi-steering/testing";
```

### Harness-level

```ts
const harness = loadHarness({
  config: { plugins: [myPlugin], rules: [...] },
});

await expectBlocks(
  harness,
  { command: "git push --force" },
  { rule: "no-force-push" },
);

await expectAllows(harness, { command: "git push" });
```

`loadHarness` runs the same `resolvePlugins` + `buildEvaluator` + `buildObserverDispatcher` path as production. `expectBlocks` / `expectAllows` accept bash/write/edit shorthand plus full `ToolCallEvent` shapes. Optional `rule` / `reason` fields on `expectBlocks` narrow the assertion.

### Unit-level

```ts
// Predicate in isolation:
const fires = await testPredicate(branch, /^main$/, {
  walkerState: { branch: "main" },
});

// Observer in isolation:
const { entries, watchMatched } = await testObserver(
  myObserver,
  { toolName: "bash", input: { command: "npm test" }, output: {}, exitCode: 0 },
);
```

`testPredicate` builds a `PredicateContext` (see `MockContextOptions` for knobs — `exec` stub, `entries`, walker state, etc.) and calls the handler. `testObserver` does the same for observers, returning the `appendEntry` captures and whether the `watch` filter accepted the event.

### Adversarial matrices

For bug-pinning tables:

```ts
import { runMatrix, formatMatrix } from "@cad0p/@cad0p/pi-steering/testing";

const result = await runMatrix(harness, [
  { name: "raw",           event: { command: "git push --force" },           expect: "block" },
  { name: "subshell",      event: { command: "sh -c 'git push --force'" },   expect: "block" },
  { name: "sudo",          event: { command: "sudo git push --force" },      expect: "block" },
  { name: "quoted-arg",    event: { command: "git push '--force'" },         expect: "block" },
  { name: "false-friend",  event: { command: "echo 'git push --force'" },    expect: "allow" },
]);
console.log(formatMatrix(result));
```

The `examples/work-item-plugin` tests use exactly this pattern.

## CLI

### `pi-steering list`

Load the project layer (`<cwd>/.pi/steering/`) and the global layer (`~/.pi/agent/steering/`), merge them project-first, and print the resolved state:

```bash
$ pi-steering list
Resolved config: 1 plugin, 2 rules, 0 observers.

git  [pi-steering/plugins/git]
  no-main-commit            bash  when: branch

User (project + global):
  no-force-push             bash

Disabled: (none)
```

JSON output for machine consumers:

```bash
pi-steering list --format=json
```

No config → "No steering config found." and exit 0.

### `pi-steering import-json`

One-shot conversion from a v1 JSON config to a v0.1.0 TypeScript config:

```bash
pi-steering import-json .pi/steering.json -o .pi/steering/index.ts
```

Emits a `defineConfig({...})` module using JSON-literal rendering. Rule patterns come across verbatim; `requires` / `unless` / override semantics are preserved. Plugins, observers, and function-valued predicates are rejected — those features only exist in the TypeScript shape and must be authored directly.

## Override comments

For overridable rules (`noOverride: false`), the agent can annotate a tool call with an inline comment to bypass the block:

```bash
git commit -m "release" # steering-override: no-main-commit
```

The engine parses the comment before AST extraction (so the override persists across wrappers). Overrides are recorded as `steering-override` session entries for audit.

**Default is `noOverride: true` (fail-closed).** Rules must explicitly opt INTO overridability. Set `defaultNoOverride: false` at config top-level to flip the default if your guardrails are mostly advisory.

## Security and trust boundaries

pi-steering is a guardrail layer, not a sandbox. Several parts of the system execute arbitrary code your config authors control, and a few state surfaces are trusted by convention rather than enforced. Understand these boundaries before running pi under an untrusted config tree.

### Config execution

`.pi/steering/index.ts` (and the `.pi/steering.ts` shorthand) is **arbitrary TypeScript executed at extension factory time with your full user privileges**. The loader reads exactly two layers — the project layer at `<launch-cwd>/.pi/steering/` and the global layer at `<agentDir>/steering/` — and merges them project-first (the project layer wins on name collisions). The bridge factory awaits the load before pi continues startup, so any throw from your config (or from a colliding plugin set) lands in pi's `[Extension issues]` diagnostic block at startup.

Implication: running pi inside a directory hierarchy whose steering configs you don't trust is equivalent to running `node -e '…'` with that same file. Symlinked config directories are followed — a symlinked `.pi/steering/` landing in an unexpected directory executes as if it had been placed there directly.

Only run pi in directory hierarchies whose steering configs you trust.

### Plugin trust

Plugins register predicates (`when.<key>` handlers), observers, and `onFire` hooks — all of which **run arbitrary code during the evaluator's hot path**. A malicious or buggy plugin can:

- Shell out via `ctx.exec` (with the same privileges as pi).
- Forge session entries via `ctx.appendEntry`, which later rules consult via `when.happened`.
- Throw in unexpected places — predicate-runtime throws fail open (the rule never fires). Factory-time load failures throw with strict mode; see "Strict mode + load failures" below for the opt-out.

A malicious plugin can trivially defeat any guardrail ship with your config. Review plugin source before adding it to `plugins: [...]` the same way you'd review any third-party dependency.

### Session JSONL trust

`when.happened` reads entries tagged via `appendEntry`. The write path (`createAppendEntry`) is engine-controlled — every write gets the current `_agentLoopIndex` stamped on it automatically, and names go through name validation.

The **read path (`findEntries`) treats every tagged entry in the session JSONL as authentic**. Entries written OUTSIDE the engine (direct JSONL writes by another pi extension, hand-edited session files, a `pi.appendEntry` call from non-steering code) can forge type tags and trick `when.happened` into thinking an event occurred when it didn't — bypassing rules that gate on that event.

This is the out-of-band trust boundary. Within the steering engine, the invariant holds; cross-extension and external writes are outside the engine's reach.

### Strict mode + load failures

Strict mode = `failOnWarnings: true`, the default. Opt out per-config-layer with `failOnWarnings: false`.

If your steering config fails to load at extension factory time (a plugin throws during import, a syntax error in `index.ts`, `pnpm` fails to resolve a dependency), pi-steering's bridge factory **throws and surfaces the diagnostic in pi's `[Extension issues]` block at startup** (yellow). Pi disables the extension for the session and continues running unsteered.

Default behavior: any warning-class loader/merger diagnostic (cross-layer plugin name collision, within-layer rule/observer collision, predicate-key collision, etc.) escalates to the same thrown factory. Error-class diagnostics (tracker-name collision, reserved-name violations) ALWAYS throw. The aggregated message lists every diagnostic with errors first, one bullet per issue.

Opt out of warning-class escalation by setting `failOnWarnings: false` on any layer of your config:

```ts
import { defineConfig } from "@cad0p/pi-steering";
export default defineConfig({
  failOnWarnings: false,   // legacy fail-soft semantics for warnings
  plugins: [/* ... */],
});
```

With `failOnWarnings: false`, warning-class diagnostics fall through to `console.warn` (single-line `[pi-steering] [warning] <message>` shape on stderr) and the bridge keeps running with the merged config. Error-class diagnostics still throw — the engine cannot operate safely with two plugins claiming the same state dimension.

Note: pi's interactive TUI clobbers `console.warn` on `/reload` — for visibility prefer fixing the warnings or running `pi-steering list`.

### Cross-project resume

When you `pi --resume` a session originally created in another project (Tab → "All" scope in the picker), pi-steering's rules are loaded from your launch cwd, NOT the session's cwd. Pi's footer (the bottom bar in interactive TUI mode) shows the session cwd; if it differs from where you launched, the bridge emits a single `[pi-steering] session cwd ... differs from launch cwd ...` line on stderr and continues evaluating with launch-cwd rules. To use the resumed session's project rules, exit pi and re-launch from that project's directory.

### Block-reason tag trust

The `[steering:<name>@<source>]` tag prepended to every block reason is only as trustworthy as your plugin authors. Name validation (regex-constrained rule / plugin / observer names) prevents tag SPOOFING — a name like `phony] ALL CLEAR [real` would have forged the tag; now it throws at load time.

Beyond the tag shape, the contents are plugin-authored. A plugin shipping a rule with `reason: "[steering:other-rule@other-plugin] …"` can make its block look like it came from another plugin. The guardrail here is plugin trust (see above), not the tag machinery.

## Performance notes

### `when.happened` scaling

The built-in `when.happened` predicate filters session entries by `customType` via `ctx.findEntries`. Cost is **O(N_session_entries) per unique `customType` per tool_call** — entries are scanned on first read per customType and cached for the rest of the phase (the shared session-entry cache invalidates on writes, see the ADR).

Example: a 5000-entry session with 6 distinct `when.happened` rules costs roughly 600 µs per tool_call on findEntries alone. Typical sessions (< 500 entries) are fine; long-running multi-day sessions may notice the overhead as the JSONL grows.

Future versions will add a session-manager-side index keyed by `customType`, moving the cost from O(N) to O(entries-of-that-type). For now, if you hit the scaling edge, consider:

- Consolidating `when.happened` rules that share a `type`.
- Rotating / truncating the session JSONL between work sessions.

## Further reading

- [`CHANGELOG.md`](./CHANGELOG.md) — per-package changelog (Keep-a-Changelog format). Tracks breaking changes and visibility-only behavior shifts.
- [`examples/`](./examples/) — rule-pack examples (`force-push-strict`, `no-amend`, `draft-prs-only`, `combined-git-discipline`) — copy-paste starting points.
- [`examples/work-item-plugin/`](./examples/work-item-plugin/) — canonical plugin reference.
- [`src/plugins/git/`](./src/plugins/git) — production plugin with trackers and tracker extensions.
- [`unbash-walker`](https://github.com/cad0p/unbash-walker) — the AST walker (own repo since the monorepo split).
- Design decisions behind every field, flag, and semantic covered above are recorded in the repo's ADR log (napkin vault).

## Relationship to related packages

- **[`unbash-walker`](https://github.com/cad0p/unbash-walker)** — the AST + tracker utility this package is built on. Own repo + package since the monorepo split (2026-08-10); consumed here via `github:cad0p/unbash-walker` until the first npm publish.
- **[`samfoy/pi-steering-hooks`](https://github.com/samfoy/pi-steering-hooks)** — inspired schema DNA, override-comment syntax, and the default-rule set. Diverged: AST-backed evaluation instead of raw-string; plugin system; observer + turn-state machinery; TypeScript-only config; walker-threaded trackers.

## License

MIT. See `LICENSE`.
