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.

This guide walks you through installing pi-steering, creating your first rule file, and verifying that the guardrail behaves correctly — blocking the dangerous command while leaving safe look-alike strings untouched. By the end you will have a working no-force-push rule enforced at the AST level, plus the default git plugin’s no-main-commit protection active automatically.
1
Install pi-steering
2
The package installs like any other pi extension:
3
pi install pi-steering
4
Then restart pi for the extension to load.
5
During the PoC period, pi-steering has not yet been published to npm. Install from a local clone instead:
6
git clone https://github.com/cad0p/pi-steering-hooks.git
cd pi-steering-hooks

pnpm install
pnpm --filter pi-steering build   # dist/ is gitignored — build first

pi install ./packages/pi-steering
7
Then restart pi.
8
After code changes to the pi-steering source itself, rebuild before restarting:
pnpm --filter pi-steering build
pi install <local-path> only registers the path — it does not run a build. The package is compiled ("main": "./dist/index.js") and dist/ is gitignored, so edits to src/ take effect only after a rebuild and a full pi restart. /reload alone is not sufficient for compiled extension code.
9
Create .pi/steering/index.ts
10
Create the file at the root of your project. This is where your project-local rules live. The loader walks from the launch directory up to $HOME and merges every .pi/steering/index.ts it finds, so project-level rules compose with any user-level rules you define higher up.
11
Here is a minimal config with one rule:
12
import { defineConfig } from "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.",
    },
  ],
});
13
defineConfig is the recommended entry point. It threads generics through your config so that rule names, plugin names, observer names, and when.happened.event references are all type-checked at tsc --noEmit time — typos become compile errors rather than silent misconfiguration.
14
Restart pi
15
Restart pi to load the new extension config. The bridge factory runs at startup, walks up from your launch directory, finds .pi/steering/index.ts, and wires the evaluator and observer dispatcher onto pi’s lifecycle events.
16
# exit and relaunch pi from your project root
pi
17
Test the rule
18
With pi running, ask your agent to run a force push and observe the block:
19
Blocked — the rule fires on a real git push --force invocation:
20
Agent: git push --force

[steering:no-force-push@user] Force-push rewrites history. Use --force-with-lease if needed.
21
Also blocked — AST-backed evaluation sees through shell wrappers:
22
Agent: sh -c 'git push --force'
Agent: cd /repo && git push --force
Agent: sudo git push --force
23
All three trigger the same no-force-push rule because the wrapper commands are expanded during the AST walk, exposing the inner git push --force ref.
24
Not blocked — the pattern anchors on the command basename, so echo is never matched:
25
Agent: echo 'git push --force'
26
Not blocked — the negative lookahead (?!-) correctly excludes --force-with-lease:
27
Agent: git push --force-with-lease

What the Defaults Add Automatically

Two built-in bundles are layered onto every config automatically, unless you opt out: DEFAULT_RULES — four domain-agnostic safety rails active on every pi session:
RuleWhat it blocks
no-force-pushgit push --force / -f (allows --force-with-lease)
no-hard-resetgit reset --hard
no-rm-rf-slashrm -rf / in all flag permutations
no-long-running-commandsDev servers, watchers, nodemon, vite dev, etc.
DEFAULT_PLUGINS — the git plugin (pi-steering/plugins/git), which contributes:
  • Predicates: branch, upstream, commitsAhead, hasStagedChanges, isClean, remote
  • Rules: no-main-commit (blocks commits directly to main, master, mainline, trunk)
  • Trackers: branch (in-chain git checkout / git switch awareness)
The no-main-commit rule is overridable per commit with an inline comment:
git commit -m "chore: release" # steering-override: no-main-commit — emergency hotfix
To opt out of specific defaults:
import { defineConfig } from "pi-steering";
import gitPlugin from "pi-steering/plugins/git";

// Keep the git plugin's predicates and tracker, but disable its rule:
export default defineConfig({
  plugins: [gitPlugin],
  disabledRules: ["no-main-commit"],
});

// Drop the git plugin entirely:
export default defineConfig({
  plugins: [gitPlugin],
  disabledPlugins: ["git"],
});

// Drop DEFAULT_RULES and DEFAULT_PLUGINS entirely:
export default defineConfig({ disableDefaults: true });
Hot reload. Editing .pi/steering/index.ts and running /reload inside pi picks up your config changes without a pi restart. The loader cache-busts the dynamic import so Node’s ESM module map cannot serve a stale copy.What /reload does not pick up: edits to a plugin’s compiled dist/index.js. Compiled modules in node_modules are cached for the process lifetime. Plugin authors who want hot-reload during development should ship .ts source as the package entry (Node 22+ native type-stripping: set "main": "./src/index.ts", allowImportingTsExtensions: true, noEmit: true).
The examples/ directory in the monorepo ships four copy-paste rule packs — force-push-strict, no-amend, draft-prs-only, and combined-git-discipline — each with its own README and smoke tests. See /examples/overview for a guided tour.

Build docs developers (and LLMs) love