@cad0p/pi-steering
AST-backed steering hooks for pi — deterministic tool-call guardrails with command-level effective-cwd scoping.
Package details
Install @cad0p/pi-steering from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@cad0p/pi-steering- Package
@cad0p/pi-steering- Version
0.2.0- Published
- Aug 11, 2026
- Downloads
- 3,835/mo · 925/wk
- Author
- cad0p
- License
- MIT
- Types
- extension, skill
- Size
- 2.1 MB
- Dependencies
- 2 dependencies · 1 peer
Pi manifest JSON
{
"skills": [
"./skills"
],
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-steering
AST-backed steering rules for 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, 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, andgit push "--force"should all trigger the same^git\s+push.*--force(?!-)rule, andecho '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
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:
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:
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") anddist/is gitignored, so edits tosrc/only take effect after a build. /reloadinside pi picks up settings, skills, prompts, and themes — but for compiled extension code, transitivedist/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 otherdist/*.jsfiles reached through it). Compiled-JS modules innode_modulesend up cached for the process lifetime; only.tssource 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 fromdist/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.tssingle-file form), loaded from the directory pi was launched in. - Global —
<agentDir>/steering/, whereagentDiris$PI_CODING_AGENT_DIR(tilde-expanded) or~/.pi/agentby 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:
mv ~/.pi/steering ~/.pi/agent/steering
Quick start
Create .pi/steering/index.ts at your project root:
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', andcd /repo && git push --forceall block via your rule.git push --force-with-leaseis not matched.git commitonmain/master/mainline/trunkblocks via the git plugin'sno-main-commitrule (opt-in — declareplugins: [gitPlugin], see 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. Seesrc/defaults.tsfor the exact patterns.
DEFAULT_PLUGINS is deliberately empty — domain plugins are opt-in. The git plugin (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:
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.
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).
Typecheck payoff. Declare anything that should be typo-checked:
// @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'sagent_startevent. 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 constructA && 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_resultevents and persists session entries viactx.appendEntry. Plugin authors register underPlugin.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/-Cconstructs. Alwaystool_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 logevaluates thegit logrule 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 isuseror 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
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:
{
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
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 (socd ~/personal && git commitevaluates against~/personal). Dynamic targets —cd "$WS_DIR/pkg",cd ~/proj— resolve through the walker's env tracker (seeded fromprocess.env.{HOME, USER, PWD}plus any bare assignments,exports, orunsets in the same chain). Intractable targets (cd $(pwd),cd $UNDEFINED) surface as the"unknown"sentinel; applyonUnknown: "allow" | "block"(default"block", fail-closed) to choose. For write/edit, it's the session cwd.happened— fires when an entry ofeventhas NOT occurred ininscope."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. Optionalsinceacts as an invalidation sentinel — see "Temporal ordering withhappened.since" below. OptionalnotInsubtracts a narrower scope fromin(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).notInis set subtraction, distinct from the clause-levelnot(boolean negation). Synthesizes speculative entries across&&bash chains — see "&&-chain speculative allow" below.not— boolean NOT over an inner predicate block. One level only (nonot: not: ...recursion). Insidenot:, leaf-levelonUnknown:is forbidden; the block-levelonUnknown: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 insidenot: { 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:
// 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
PredicateFns and plugin PredicateHandlers receive a PredicateContext:
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:
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:
{
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
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}:
// 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:
{
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:
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:
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
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:
// ✅ 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 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
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:
- A
<EVENT>_EVENTconstant — the session-entry event literal. - A
mark<Event>(ctx)helper — encapsulates the shape of what gets written. - 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 for a complete file following this pattern.
Typed predicate handlers
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/ 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— the canonical plugin reference for trackers + tracker extensions. Shipsbranch/upstream/commitsAheadpredicates, abranchTracker, a--git-dir/--work-treecwd extension, and theno-main-commit+no-main-commit-githubrules.pi-steering-flags— first official external plugin, establishing the precedent for community plugins. Own repo + package since the monorepo split (2026-08-10). ShipsrequiresFlag/allowlistedFlagsOnlypredicates and helper primitives.pi-steering-commit-format— commit-message format predicates. Own repo + package since the monorepo split (2026-08-10). Ships thecommitFormatpredicate plus acommitFormatFactoryfor 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:
{
"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-packageis pi's ecosystem-wide convention.pi-steering-packageis 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-steeringcore andpi-steering-flags. Scoped names (@org/pi-steering-<x>) are fine for internal packages. - Peer range: pin to a major once
@cad0p/pi-steeringis 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:
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
namefor the replacement. Reusing the plugin rule's name has two failure modes — same name + nodisabledRuleskeeps both rules (your customization silently fails to apply) and same name +disabledRulesfilters out both (silent fail-OPEN, the worst outcome for a safety rule). The git plugin's Customization section walks through worked examples (soften the reason text; cwd-based exemption with the array-formcwd:predicate'sonUnknown: "allow"pin to keepnot: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 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 thecd,walkerState.cwd === "/ws/pkg"at the following commands. export NAME=VALUEandunset NAME.- Subshell isolation:
(FOO=/s; cd "$FOO"); cmd— outercmdsees neitherFOOnor the subshell'scd. - Seeded from
process.env.{HOME, USER, PWD}at tracker initialization, so~/$HOME/$USER/$PWDexpand 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) 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:
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:
import { loadHarness, expectBlocks, expectAllows, testPredicate, testObserver }
from "@cad0p/@cad0p/pi-steering/testing";
Harness-level
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
// 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:
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:
$ 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:
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:
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:
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 viawhen.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:
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.happenedrules that share atype. - Rotating / truncating the session JSONL between work sessions.
Further reading
CHANGELOG.md— per-package changelog (Keep-a-Changelog format). Tracks breaking changes and visibility-only behavior shifts.examples/— rule-pack examples (force-push-strict,no-amend,draft-prs-only,combined-git-discipline) — copy-paste starting points.examples/work-item-plugin/— canonical plugin reference.src/plugins/git/— production plugin with trackers and tracker extensions.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— the AST + tracker utility this package is built on. Own repo + package since the monorepo split (2026-08-10); consumed here viagithub:cad0p/unbash-walkeruntil the first npm publish.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.