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.

Rules are TypeScript objects that gate bash, write, and edit tool calls before pi executes them. Each rule declares which tool and input field to watch, a pattern to match, optional predicates that must pass, and the message to surface when the rule fires. The engine parses every bash command into an AST, walks it per-ref, and evaluates every rule against each extracted command — so structural variants like sh -c 'git push --force', cd /repo && git push --force, and git push "--force" all resolve to the same ref and trigger the same rule.

Rule interface

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 condition
  unless?: Pattern | PredicateFn;               // exemption
  when?: TopLevelWhenClause;                    // composable predicate clauses
  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 event types
  onFire?: (ctx: PredicateContext) => void;     // side-effect hook on block
}

type ReasonFn = (ctx: PredicateContext) => string | Promise<string>;
Rule is a discriminated union over three tool-specific variants (BashRule, WriteRule, EditRule). The tool discriminant determines which field values are legal — { tool: "bash", field: "path" } is a compile error.

Fields

name
string
required
Unique rule identifier used in the [steering:<name>@<source>] tag prepended to every block reason. Also the value you pass in disabledRules: ["your-rule-name"] to opt a rule out, and the target in override comments (# steering-override: your-rule-name). Must contain only letters, digits, underscores, and dashes, and must start with a letter or digit — names like phony] ALL CLEAR [real are rejected at load time to prevent block-reason spoofing.
tool
"bash" | "write" | "edit"
required
Which pi tool to gate. "bash" gates shell command execution; "write" gates whole-file writes; "edit" gates targeted oldText/newText patches.
field
"command" | "path" | "content"
required
Which input field the pattern tests. For bash, only "command" is valid — the evaluator runs bash rules against the AST-extracted command string per ref. For write and edit, choose "path" to gate the target path or "content" to gate the file content being written (for edit, this is the concatenated newText of every edit in the tool call).
pattern
string | RegExp
required
Main match predicate. For bash rules, the engine tests the pattern against the flattened basename + " " + args.join(" ") of each extracted command ref — for example, git push --force becomes the test string "git push --force". Anchor with ^ so substrings of arguments don’t accidentally trigger the rule: /^git\s+push.*--force(?!-)/ matches git push --force but not echo 'git push --force'. For write and edit rules, the pattern tests path or content directly.A plain string is compiled once as a regex source at load time. A RegExp is used as-is.
requires
Pattern | PredicateFn
Optional AND extra condition. The rule fires only when both pattern and requires match. Accepts the same string | RegExp shorthand as pattern, or a PredicateFn for arbitrary logic. Use when the primary pattern is broad but the rule should apply only in a narrower structural context.
unless
Pattern | PredicateFn
Exemption predicate. When provided and matching, the rule does not fire — even if pattern (and requires) would otherwise pass. Same shape as requires. Use to carve out safe variants from a broad pattern, such as exempting --force-with-lease from a --force rule.
when
TopLevelWhenClause
Composable predicate block. All predicates in when must pass (logical AND) for the rule to fire. Built-in leaves include cwd, happened, not, and condition; plugin-registered leaves like branch, upstream, and commitFormat extend the registry via TypeScript module augmentation. See When Clauses for the full reference.
reason
string | ReasonFn
required
The message shown to the agent when the rule fires. Always written for the agent — include what was blocked and what the safe alternative is. The evaluator prefixes every block reason with [steering:<rule>@<source>] where source is user or the shipping plugin name.A plain string is the common case and is preferred when the reason is static. Pass a ReasonFn when the message depends on runtime state — for example, the walker-resolved cwd or a branch name pulled from walkerState. The function is awaited; if it throws or its returned promise rejects, the engine logs the error with console.warn and emits a fail-safe fallback ((reason failed to format; see log)) so the block verdict still lands without leaking raw error text to the agent.
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`.",
noOverride
boolean
Controls whether the agent can bypass this rule with an inline # steering-override: <name> comment. When true, no override is accepted — the rule is fail-closed. When false, the agent may annotate the command to proceed; the override is recorded as a steering-override session entry for audit.Omitted: falls back to defaultNoOverride in the top-level config, which itself defaults to true (fail-closed). Rules must explicitly opt into overridability with noOverride: false.
observer
Observer | string
Observer to attach to this rule. The observer fires on matching tool_result events and writes session entries the rule can consult via when.happened. Pass an inline Observer object or a string name referencing an observer registered in a plugin or at the config’s top level. String references are typo-checked by defineConfig’s generics — an unknown name is a compile error.
writes
readonly string[]
Session-entry event type literals this rule’s onFire hook may write via ctx.appendEntry. Declaring them here adds those literals to the AllWrites union inside defineConfig, making them referenceable from when.happened.event anywhere in the same config. A typo like "ws-sync-don" in writes will cause the downstream happened.event reference to fail at compile time.Zero runtime cost — the engine never reads writes at dispatch time. Its sole purpose is compile-time cross-referencing.
onFire
(ctx: PredicateContext) => void | Promise<void>
Side-effect hook invoked after all predicates pass and before the block verdict is returned. Use for self-marking patterns where the rule’s own fire IS the event that satisfies a subsequent when.happened check.Timing guarantees:
  • Runs after pattern, requires, unless, and when have all evaluated favorably.
  • Runs only for rules that will actually block — rules suppressed by an inline override comment do not trigger onFire.
  • Fail-closed rules (noOverride omitted or true) ignore override comments entirely, so onFire runs on every fire.
If onFire throws or its promise rejects, the engine logs with console.warn and proceeds to return the block verdict. A broken self-mark never invalidates the block.

onFire self-marking pattern

The canonical use of onFire is a self-marking reminder: the rule blocks on the first occurrence per agent loop, marks itself, and then the second occurrence passes because when.happened no longer fires. Here is the complete example from the work-item plugin:
// From examples/work-item-plugin/src/rules/commit-description-check.ts
export const DESCRIPTION_REVIEWED_EVENT =
  "example-description-reviewed" as const;

export function markDescriptionReviewed(
  ctx: PredicateContext,
  payload: { command: string } = { command: "" },
): void {
  ctx.appendEntry(DESCRIPTION_REVIEWED_EVENT, payload);
}

export const commitDescriptionCheck = {
  name: "commit-description-check",
  tool: "bash",
  field: "command",
  pattern: /^git\s+commit\b/,
  when: {
    happened: { event: DESCRIPTION_REVIEWED_EVENT, in: "agent_loop" },
  },
  reason:
    "Re-read the commit description before committing. This reminder fires once per agent loop — your next commit in this loop will go through.",
  noOverride: false,
  writes: [DESCRIPTION_REVIEWED_EVENT],
  onFire: (ctx) => {
    markDescriptionReviewed(ctx, {
      command: ctx.input.command ?? "",
    });
  },
} as const satisfies Rule;
First commit per agent loop: when.happened fires (event not yet present), rule blocks, onFire writes the entry. Second commit in the same loop: when.happened no longer fires (entry now present), commit passes.
Use as const satisfies Rule on reusable rule constants, not a bare : Rule type annotation. The as const preserves literal types for name, writes, and other string-literal fields — these flow into defineConfig’s generics for compile-time cross-reference checking. A bare : Rule annotation widens writes to readonly string[], collapsing the AllWrites union to never and causing every when.happened.event reference in the config to be rejected as a typo.
noOverride defaults to true (fail-closed). Rules must explicitly set noOverride: false to be overridable. Set defaultNoOverride: false at the config top-level to flip the default if your guardrails are mostly advisory. Override comments have no effect on fail-closed rules regardless of what the agent writes.

Build docs developers (and LLMs) love