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 looks for a TypeScript config file at your project root and loads it at extension factory time — before pi starts processing any prompts. The file is a standard ESM module that default-exports a defineConfig(...) call. Every option is type-checked at tsc --noEmit time, so typos in rule names, plugin names, and session-entry event references become compiler errors before you ever run pi.

File location

The loader checks two locations in each directory, in priority order:
FormPath
Directory form (primary).pi/steering/index.ts
Flat shorthand.pi/steering.ts
The directory form wins when both exist in the same directory — the loader emits a layer-form-coexistence warning and ignores .pi/steering.ts. Delete the flat file to silence it.
The loader walks up from the launch cwd toward $HOME, importing every .pi/steering/ layer it finds. This means multiple configs can coexist — one in your project root, one in your home directory, one per monorepo package. See Walk-Up Merge for the full discovery and merging rules.

defineConfig options

defineConfig accepts a single config object. All fields are optional.
rules
Rule[]
User-defined rules added on top of the defaults. Rules are evaluated in declaration order within a layer; inner layers (closer to cwd) beat outer layers on same-name collisions. Each rule must set name, tool, field, pattern, and reason at minimum.
plugins
Plugin[]
Plugins to register. A plugin bundles predicates, rules, observers, and walker trackers under a single name. Passing a plugin in plugins is also what gives defineConfig’s generics the concrete type information needed for cross-reference checking — without an explicit plugins entry, typo detection on disabledPlugins falls back to the default-plugin name union only.
observers
Observer[]
Standalone observers not tied to a plugin. Observers watch tool_result events and write typed entries to pi’s session JSONL, which rules later consult via when.happened. Passing observers here is what makes their writes declarations contribute to AllWrites — the union that constrains when.happened.event for typo detection.
disabledRules
string[]
Rule names to disable. The union of DEFAULT_RULES names, plugin-shipped rule names, and user rule names is inferred from the config and checked at compile time — a typo like "no-forc-push" is a tsc error. disabledRules values are union’d across walk-up layers; an outer layer’s disables are preserved alongside inner layers’.
disabledPlugins
string[]
Plugin names to disable. A disabled plugin contributes nothing at runtime — no rules, no observers, no predicates, no trackers. Like disabledRules, values are typo-checked against AllPluginNames (default plugin names plus any user-registered plugin names) and union’d across layers.
disableDefaults
boolean
When true, drops all DEFAULT_RULES and DEFAULT_PLUGINS. Use this for isolated test harnesses or strict minimal configs where you want only the rules you explicitly declare. Inner layer wins on walk-up merge.
defaultNoOverride
boolean
default:"true"
Override policy applied to rules that don’t set their own noOverride field. When true (the default), every such rule is a hard block — the agent cannot bypass it with an inline # steering-override: comment. Set to false to flip the default and make untagged rules advisory. Rules with an explicit noOverride: true (like no-rm-rf-slash) are always hard blocks regardless of this setting.
failOnWarnings
boolean
default:"true"
Strict-mode flag. When true (the default), any warning-class diagnostic produced while loading the config — cross-layer plugin name collision, within-layer rule/observer collision, predicate-key collision — escalates to a thrown error that surfaces in pi’s [Extension issues] diagnostic block at startup. Error-class diagnostics (tracker name collision, reserved name violation) always throw. Set to false to let warnings fall through to console.warn while the bridge keeps running.

Full example

The example below registers two plugins, adds a custom bash rule that fires only when the --profile flag is absent, and disables the git plugin’s default no-main-commit rule.
import { defineConfig } from "pi-steering";
import gitPlugin from "pi-steering/plugins/git";
import flagsPlugin from "pi-steering-flags";

export default defineConfig({
  plugins: [gitPlugin, flagsPlugin],
  rules: [
    {
      name: "aws-requires-profile",
      tool: "bash",
      field: "command",
      pattern: /^aws\s+[a-z]/,
      unless: /^aws\s+(sts\s+get-caller-identity|configure)\b/,
      when: {
        requiresFlag: { flag: "--profile", env: "AWS_PROFILE" },
      },
      reason: "Always specify --profile — never rely on the default profile.",
    },
  ],
  disabledRules: ["no-main-commit"],
});

Compile-time safety

defineConfig is a generic function. When you pass plugins, rules, and observers, TypeScript infers:
  • AllRuleNames<P, R> — the union of every rule name across DEFAULT_RULES, plugin-shipped rules, and your inline rules. disabledRules entries are checked against this union.
  • AllPluginNames<P> — the union of every plugin name across DEFAULT_PLUGINS and your plugins array. disabledPlugins entries are checked against this union.
  • AllObserverNames<P, Inline> — the union of observer names across plugin observers and your inline observers. Rule.observer string references are checked against this union.
  • AllWrites<P, R, Inline> — the union of all writes literals declared across plugin rules, plugin observers, user rules, and user observers. Both when.happened.event and when.happened.since are checked against this union.
For the inference to work, rule and observer constants declared outside the defineConfig call must preserve their literal types. Use as const satisfies:
// ✅ Literal name survives — disabledRules typo detection works
const myRule = {
  name: "aws-requires-profile",
  writes: ["aws-profile-checked"],
  // ...
} as const satisfies Rule;

// ❌ name widens to `string` — typo detection silently disabled
const myRule: Rule = {
  name: "aws-requires-profile",
  // ...
};
A deliberate typo produces a compiler error:
// @ts-expect-error — "wrong-name" is not a registered rule
disabledRules: ["wrong-name"],

failOnWarnings and strict mode

By default, failOnWarnings: true means any warning-class loader diagnostic escalates to a thrown error at extension factory time. Pi surfaces this in the [Extension issues] block at startup (yellow banner):
[Extension issues]
  - pi-steering failed to load: 1 config issue:
    - [warning] duplicate plugin "git"; keeping first-registered entry.
Pi then disables the extension for the session and continues unsteered. To opt out of this escalation for warnings, set failOnWarnings: false on any layer of your config — warnings will fall through to console.warn ([pi-steering] [warning] <message> on stderr) and the bridge keeps running with the merged config. Error-class diagnostics — tracker name collision, reserved name violation — always throw regardless of failOnWarnings. The engine cannot operate safely with those issues present.
Hot-reload via /reload picks up edits to .pi/steering/index.ts without a pi restart. The loader cache-busts the dynamic import on every reload using a ?t=<hrtime-bigint> query string so Node’s ESM module map never serves a stale copy. However, compiled plugin dist/ files are not hot-reloaded — only .ts source entries go through jiti’s re-evaluation path. Plugin authors should ship .ts source as the package entry ("main": "./src/index.ts") to enable hot-reload during development.

Build docs developers (and LLMs) love