Skip to main content

Documentation Index

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

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

pi-steering is TypeScript-first. All types are exported from the package root via import type { ... } from "pi-steering". This page documents the most important types for rule authors and plugin authors, organized by the surface they belong to.

Core config types

SteeringConfig

Top-level config shape. What a user’s .pi/steering.ts default-exports, either via defineConfig or satisfies SteeringConfig. All fields are optional — the loader merges multiple layers into a single effective config.
interface SteeringConfig {
  defaultNoOverride?: boolean;
  disabledRules?: readonly string[];
  disabledPlugins?: readonly string[];
  disableDefaults?: boolean;
  failOnWarnings?: boolean;
  plugins?: readonly Plugin[];
  rules?: readonly Rule[];
  observers?: readonly Observer[];
}
The five array-typed fields are readonly. This is load-bearing: DefineConfigInput extends SteeringConfig and narrows each slot with const-generic-aware subtypes for typo-checking. See defineConfig for the narrowed input shape.

Rule

A single steering rule — discriminated union over the three gateable pi tools. The tool discriminant determines which field values are valid.
type Rule<ObsName extends string = string, Writes extends string = string> =
  | BashRule<ObsName, Writes>
  | WriteRule<ObsName, Writes>
  | EditRule<ObsName, Writes>;
All three variants share BaseRule:
interface BaseRule<ObsName extends string = string, Writes extends string = string> {
  /** Unique rule identifier. Used in override comments and audit logs. */
  name: string;

  /** Main match predicate tested against the chosen field value. */
  pattern: Pattern;

  /** Optional extra AND predicate. Rule fires only if this also matches. */
  requires?: Pattern | PredicateFn;

  /** Exemption predicate. When provided and matches, rule does NOT fire. */
  unless?: Pattern | PredicateFn;

  /** Composable predicate block. See TopLevelWhenClause. */
  when?: TopLevelWhenClause<Writes>;

  /** Message shown to the agent when blocked. */
  reason: string | ReasonFn;

  /** Override policy. Omitted: falls back to SteeringConfig.defaultNoOverride. */
  noOverride?: boolean;

  /** Observer attached to this rule — inline or referenced by name. */
  observer?: Observer | ObsName;

  /** Session-entry event types this rule's onFire may write. */
  writes?: readonly string[];

  /** Side-effect hook invoked when the rule fires, before the block verdict. */
  onFire?: (ctx: PredicateContext) => void | Promise<void>;
}
Tool-specific discriminants:
interface BashRule extends BaseRule {
  tool: "bash";
  field: "command";      // Only valid field for bash rules
}

interface WriteRule extends BaseRule {
  tool: "write";
  field: "path" | "content";
}

interface EditRule extends BaseRule {
  tool: "edit";
  field: "path" | "content";
}

Observer

A reactive hook that runs on tool_result events. Typically writes session-entry records that later predicates read via when.happened.
interface Observer {
  /** Unique name. Referenced from Rule.observer as a string. */
  name: string;

  /** Session-entry event types this observer's onResult may write. */
  writes?: readonly string[];

  /** Filter narrowing which tool_result events fire onResult. */
  watch?: ObserverWatch;

  /** Called on every matching tool_result event. */
  onResult: (event: ToolResultEvent, ctx: ObserverContext) => void | Promise<void>;
}

Plugin

Distribution unit for rule packs and extension points. Plugins register predicates, rules, observers, trackers, and tracker extensions.
interface Plugin {
  /** Unique plugin identifier. */
  name: string;

  /** Predicate handlers keyed by the when.<key> slot they register. */
  predicates?: Record<string, AnyPredicateHandler>;

  /** Rules the plugin suggests. Users opt out via disabledRules. */
  rules?: readonly Rule[];

  /** Observers the plugin ships. Referenced by name from rules. */
  observers?: readonly Observer[];

  /** New walker state dimensions the plugin introduces. */
  trackers?: Record<string, Tracker<unknown>>;

  /** Modifiers added to an existing tracker. */
  trackerExtensions?: Record<
    string,
    Record<string, Modifier<unknown> | readonly Modifier<unknown>[]>
  >;
}

ObserverWatch

Filter applied to tool_result events before Observer.onResult runs. Omitting watch entirely fires the observer on every tool result.
interface ObserverWatch {
  /** Only fire on results from this tool. */
  toolName?: "bash" | "read" | "write" | "edit" | (string & {});

  /**
   * Per-field regex constraints on the tool INPUT.
   * Observer fires only when every listed field matches.
   */
  inputMatches?: Record<string, Pattern>;

  /** Constrain by exit code or success/failure classification. */
  exitCode?: number | "success" | "failure" | "any";
}

Predicate types

PredicateContext

Context passed to every predicate — both escape-hatch PredicateFns and plugin-registered PredicateHandlers.
interface PredicateContext {
  /** Session cwd (or effective cwd of the command for bash rules). */
  cwd: string;

  /** Which pi tool is being gated. */
  tool: "bash" | "write" | "edit";

  /** Tool input — evaluator populates whichever fields apply to tool. */
  input: PredicateToolInput;

  /**
   * Engine-maintained agent-loop counter.
   * Bumped on each pi agent_start event.
   */
  agentLoopIndex: number;

  /** Run a shell command. Memoized per (cmd, args, cwd) within a single tool_call. */
  exec: (cmd: string, args: string[], opts?: ExecOpts) => Promise<ExecResult>;

  /** Append a typed entry into pi's session JSONL. */
  appendEntry: <T>(customType: string, data?: T) => void;

  /** Read all prior typed entries of the given custom type. */
  findEntries: <T>(customType: string) => Array<{ data: T; timestamp: number }>;

  /**
   * Walker state snapshot for the command being evaluated.
   * Populated only for bash rules. Undefined for write / edit rules.
   */
  walkerState?: Readonly<WhenWalkerState>;
}

WhenWalkerState

Shape of the walker-state snapshot on PredicateContext.walkerState. The type is open-ended — plugins register new tracker dimensions that appear as additional keys.
interface WhenWalkerState {
  /** Effective cwd at this ref, per cwdTracker. "unknown" when unresolvable. */
  readonly cwd: string;

  /** Env map at this ref, per envTracker. */
  readonly env: EnvState;

  /** Additional tracker and reserved keys (e.g. branch, events). */
  readonly [key: string]: unknown;
}

TopLevelWhenClause

The composable predicate block attached to Rule.when. Each plugin-registered predicate (filtered for reserved keys) gets a typed leaf field. The not?: operator allows one level of negation. Key built-in leaves:
// cwd — matches the command's effective cwd
when: { cwd: /\/workspace\// }
when: { cwd: { pattern: /\/workspace\//, onUnknown: "allow" } }

// happened — fires when an event has NOT occurred in scope
when: { happened: { event: "sync-done", in: "agent_loop" } }

// condition — escape-hatch arbitrary logic
when: { condition: (ctx) => ctx.input.args?.length === 0 }

// not — logical negation of a sub-clause
when: { not: { cwd: /\/personal\//, onUnknown: "block" } }
TopLevelWhenClause is generic over Writes (the union of declared writes literals in scope), which narrows happened.event and happened.since to known event strings inside defineConfig.

Pattern and Patterns

/** A plain string (treated as regex source) or a RegExp. */
type Pattern = string | RegExp;

/** A single Pattern or an OR-of-patterns array (any match counts). */
type Patterns = Pattern | Pattern[];

PredicateVerdict

/** Trinary verdict returned by predicate handlers. */
type PredicateVerdict = boolean | "unknown";
"unknown" signals the handler could not resolve its value. The engine applies the onUnknown policy ("block" by default) to project back to a definite boolean.

PredicateFn

Escape-hatch predicate — arbitrary user logic evaluated with a PredicateContext. Used as the value of when.condition.
type PredicateFn = (ctx: PredicateContext) => boolean | Promise<boolean>;

PredicateHandler<T>

Plugin-registered predicate. Differs from PredicateFn in that the first argument is the structured value the user supplied under their when.<key> slot.
type PredicateHandler<A = unknown> = (
  args: A,
  ctx: PredicateContext,
) => PredicateVerdict | Promise<PredicateVerdict>;
Use definePredicate<T> to declare typed handlers without casting. See also AnyPredicateHandler (= PredicateHandler<any>) — the type-erased alias used at Plugin.predicates registry boundaries.

ReasonFn

Dynamic block-reason function. When Rule.reason is a function, the evaluator invokes it with the same PredicateContext the predicates saw, then prefixes the result with [steering:<rule>@<source>].
type ReasonFn = (ctx: PredicateContext) => string | Promise<string>;
Use the function form when the reason text depends on runtime state — for example, the walker’s effective cwd or a resolved branch name. If the function throws, the evaluator logs to console.warn and emits a fallback reason; the block verdict still lands.

Walker types

These types are re-exported from unbash-walker for plugin authors. See the unbash-walker API reference for full documentation on the functions that produce and consume them.

CommandRef

type CommandRef = {
  node: Command;         // The unbash AST Command node
  source: string;        // The original bash source string
  group: number;         // Group ID — commands in the same && / || chain share a group
  joiner?: "|" | "&&" | "||" | ";"; // Operator connecting this command to the next
};

Word

An unbash word with structured access to its value:
  • .value — the lexical value (quote-stripped, normalized)
  • .text — the raw source token (preserves quoting)
PredicateContext.input.args carries Word[] for bash rules, enabling quote-aware argument inspection without splitting on whitespace.

Tracker<T>

Interface plugin authors implement to introduce new walker state dimensions:
interface Tracker<T> {
  /** Starting value. Overridden per-call via walk()'s initialState parameter. */
  initial: T;

  /** Sentinel value emitted when a modifier returns undefined (unresolvable). */
  unknown: T;

  /**
   * Modifiers keyed by command basename.
   * An array value applies modifiers left-to-right on the same ref.
   */
  modifiers: Record<string, Modifier<T> | Modifier<T>[]>;

  /**
   * How subshell boundaries affect this dimension.
   * Defaults to "isolated" (changes inside a subshell don't escape).
   */
  subshellSemantics?: "isolated" | "global";
}

Modifier<T, TAll>

The two modifier scope variants:
// "sequential" — propagates to this command AND all subsequent siblings
type Modifier<
  T,
  TAll extends Record<string, unknown> = Record<string, unknown>
> =
  | {
      scope: "sequential";
      apply(args: readonly Word[], current: T, allState: Readonly<TAll>): T | undefined;
    }
  | {
      scope: "per-command";
      apply(args: readonly Word[], current: T, allState: Readonly<TAll>): T | undefined;
    };
  • "sequential" — use for shell-level state changes (cd, git checkout). Updates the threaded value that propagates to subsequent commands.
  • "per-command" — use for per-invocation overrides (git -C DIR, make -C DIR). Updates only the snapshot recorded for this command; the threaded value is unchanged.
Return undefined from apply to signal “can’t resolve statically.” The walker substitutes the tracker’s unknown sentinel.

EnvState

type EnvState = ReadonlyMap<string, string>;
The shape envTracker produces per ref. Read via ctx.walkerState.env.get("NAME"). Returns undefined for names the walker hasn’t seen.

Testing types

These types are exported from pi-steering/testing (and re-exported from the package root).

Harness

Returned by loadHarness. Build-once, invoke-many handle for driving the engine against a scenario in tests.
interface Harness {
  evaluate: EvaluatorRuntime["evaluate"];
  dispatch: ObserverDispatcher["dispatch"];
  readonly config: SteeringConfig;
  readonly resolved: ResolvedPluginState;
  readonly diagnostics: readonly SteeringDiagnostic[];
}
Unlike production, loadHarness does NOT throw on error-class diagnostics — tests assert directly on harness.diagnostics. Check harness.diagnostics before treating the harness verdict as production-faithful.

LoadHarnessOptions

interface LoadHarnessOptions {
  /** The config under test. */
  readonly config: SteeringConfig;

  /**
   * Prepend DEFAULT_PLUGINS and DEFAULT_RULES to the config.
   * Default: false.
   */
  readonly includeDefaults?: boolean;

  /**
   * Host for exec / appendEntry. Defaults to an in-memory stub
   * whose exec rejects with a clear error.
   */
  readonly host?: EvaluatorHost;
}

MatrixCase and MatrixResult

Used by runMatrix for adversarial batch evaluation:
interface MatrixCase {
  readonly name: string;
  readonly event: ToolCallEvent | ToolCallShorthand;
  readonly expect:
    | "block"
    | "allow"
    | { readonly block: true; readonly rule?: string };
  readonly cwd?: string;
}

interface MatrixResult {
  readonly total: number;
  readonly passed: number;
  readonly failed: number;
  readonly cases: ReadonlyArray<MatrixCaseResult>;
}
runMatrix never throws — failures surface in result.cases. Pair with formatMatrix to render a human-readable ASCII report.

MockContextOptions

Knobs for mockContext and (a subset for) testPredicate:
interface MockContextOptions {
  cwd?: string;                   // Defaults to "/tmp/test"
  agentLoopIndex?: number;        // Defaults to 0
  tool?: "bash" | "write" | "edit"; // Defaults to "bash"
  input?: PredicateToolInput;
  walkerState?: Partial<WhenWalkerState> & Record<string, unknown>;
  exec?: (cmd: string, args: readonly string[], opts?: ExecOpts) => ExecResult | Promise<ExecResult>;
  entries?: ReadonlyArray<MockEntry>;
  toolCallEvents?: Readonly<Record<string, readonly SyntheticEntry[]>>;
}
Build entries for when.happened tests via priorEntry(customType, data, { agentLoopIndex }) — this stamps the reserved _agentLoopIndex tag in the exact shape the live engine produces.

Diagnostic types

SteeringDiagnostic

Structured issue surfaced while loading a steering config. The shape is stable — tooling and tests dispatch on kind without parsing message.
interface SteeringDiagnostic {
  /** "warning" — safe to ignore in fail-soft mode; "error" — always throws. */
  type: "warning" | "error";

  /** Discriminator for programmatic dispatch. */
  kind: SteeringDiagnosticKind;

  /** Human-readable message with context. */
  message: string;

  /** Source file path, when applicable. */
  path?: string;
}

SteeringDiagnosticKind

Stable enum of diagnostic categories:
KindSurfaceMeaning
"layer-form-coexistence"LoaderBoth .pi/steering.ts and .pi/steering/index.ts exist at the same directory
"layer-import-failed"LoaderA config layer’s dynamic import threw
"layer-stray-file"LoaderA non-.ts file lives under .pi/steering/
"plugin-name-collision"LoaderTwo layers register a plugin with the same name
"rule-name-collision"LoaderA single layer declares two rules with the same name
"observer-name-collision"LoaderA single layer declares two observers with the same name
"tracker-name-collision"LoaderTwo plugins register a tracker under the same name (hard error)
"predicate-collision"Plugin mergerTwo plugins register a predicate under the same when.<key>
"observer-collision"Plugin mergerTwo plugins ship an observer with the same name
"rule-collision"Plugin mergerTwo plugins ship a rule with the same name
"extension-orphan"Plugin mergertrackerExtensions references an unregistered tracker
"reserved-tracker-name"Plugin mergerPlugin attempts to register a tracker under a reserved name
"reserved-predicate-key"Plugin mergerPlugin attempts to register a predicate under not, onUnknown, etc.
"invalid-name"Plugin mergerA plugin / rule / observer name contains disallowed characters

Constants

DEFAULT_RULES

The four rules the engine ships by default:
NameWhat it blocks
"no-force-push"git push --force / -f (allows --force-with-lease)
"no-hard-reset"git reset --hard
"no-rm-rf-slash"rm -rf / and equivalent flag orderings
"no-long-running-commands"Dev servers and watch-mode processes (npm/yarn/pnpm dev, vite, next dev, etc.)
Disable individual defaults via disabledRules. Disable all defaults via disableDefaults: true.

DEFAULT_PLUGINS

const DEFAULT_PLUGINS = [gitPlugin] as const satisfies readonly Plugin[];
Ships the git plugin (pi-steering/plugins/git), which contributes the branch, upstream, commitsAhead, hasStagedChanges, isClean, and remote predicates, the no-main-commit rule, a branch walker tracker, and cwd tracker extensions for --git-dir / --work-tree flags.

AGENT_LOOP_INDEX_KEY

const AGENT_LOOP_INDEX_KEY = "_agentLoopIndex" as const;
The reserved session-entry payload key the engine stamps on every appendEntry write. Exposed so plugin authors inspecting raw session entries via findEntries can reference the constant instead of hardcoding the underscore-prefixed string literal. Also used by priorEntry in the testing surface.

Build docs developers (and LLMs) love