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 aDocumentation 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.
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:| Form | Path |
|---|---|
| Directory form (primary) | .pi/steering/index.ts |
| Flat shorthand | .pi/steering.ts |
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.
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 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.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.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’.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.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.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.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.
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 acrossDEFAULT_RULES, plugin-shipped rules, and your inline rules.disabledRulesentries are checked against this union.AllPluginNames<P>— the union of every plugin name acrossDEFAULT_PLUGINSand yourpluginsarray.disabledPluginsentries are checked against this union.AllObserverNames<P, Inline>— the union of observer names across plugin observers and your inlineobservers.Rule.observerstring references are checked against this union.AllWrites<P, R, Inline>— the union of allwritesliterals declared across plugin rules, plugin observers, user rules, and user observers. Bothwhen.happened.eventandwhen.happened.sinceare checked against this union.
defineConfig call must preserve their literal types. Use as const satisfies:
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):
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.