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.

A plugin is a named bundle of predicates, rules, observers, and trackers that users opt into via plugins: [...]. Publishing your logic as a plugin rather than inlining it in a config gives you independent versioning, npm discoverability, and the ability to share predicate helpers and observer event constants with downstream consumers. The engine composes plugins at config load time — there is no plugin registry to maintain and no approval process.

Plugin shape

interface Plugin {
  name: string;
  predicates?: Record<string, AnyPredicateHandler>;
  rules?: readonly Rule[];
  observers?: readonly Observer[];
  trackers?: Record<string, Tracker<unknown>>;
  trackerExtensions?: Record<string, Record<string, Modifier<unknown> | readonly Modifier<unknown>[]>>;
}
Every field except name is optional. A plugin that only ships predicates (no rules) is valid — the predicates become available as when.<key> slots for users to write their own rules against.

Canonical file layout (ADR §13)

Separate each concern into its own file. The engine doesn’t enforce this structure, but it is the expected layout for production plugins and is the layout followed by the git plugin and the work-item-plugin example.
src/
├── index.ts                        # default export: Plugin; re-exports
├── index.test.ts
├── predicates/
│   ├── <predicate>.ts
│   └── <predicate>.test.ts
├── observers/
│   ├── <observer>.ts               # EVENT constant + mark helper + observer
│   └── <observer>.test.ts
└── rules/
    ├── <rule-or-group>.ts
    └── <rule-or-group>.test.ts
index.ts assembles the plugin object from its parts, declares the PiSteeringPredicates augmentation for TypeScript, and re-exports composable building blocks for downstream consumers. Each sub-file has its own unit test suite; an index.test.ts (or integration.test.ts) pins the end-to-end wiring.

Typed predicate handlers with definePredicate<T>

definePredicate<T> is a zero-cost type helper — pure pass-through at runtime — that lets you declare a typed argument shape for your predicate without having to cast at the plugin registration site.
import { definePredicate } from "pi-steering";

interface BranchArgs {
  pattern: RegExp;
  onUnknown?: "allow" | "block";
}

export const branch = definePredicate<BranchArgs>(async (args, ctx) => {
  return args.pattern.test(await resolveBranch(ctx));
});
The PredicateHandler<T> type narrows args to T inside the callback. Consumers of your predicate who import the handler directly get the narrow type; consumers who use it via when.<key> in a rule config get it via the registry.

Registering predicates on PiSteeringPredicates

Every predicate key a plugin introduces must be declared on the global PiSteeringPredicates interface so the engine’s mapped type (TopLevelWhenClause) accepts it in the when: slot with the correct argument shape. Without the declaration, a reference to when.myKey is rejected at the type level.
import type { Patterns, PredicateShape } from "pi-steering";

declare global {
  interface PiSteeringPredicates {
    branch: PredicateShape<Patterns>;
  }
}
PredicateShape<Bare, SpreadBase> takes two type parameters. The first is the “bare” value type (a bare when.branch: /^main$/ works when Bare is Patterns). The second is the spread form’s object shape, auto-detected from Bare when not supplied (a Patterns bare auto-detects to { pattern: Patterns }). Place the declare global block in index.ts so that import "your-plugin" pulls the registry augmentation transitively for any consumer.

Observer encapsulation convention (ADR §14)

Every observer file exports three things — an event constant, a mark helper, and the observer itself. Rules that consume the event import the constant, never the raw string. This encapsulates the session-entry shape and ensures that a future rename of the event literal is a single-site change that the compiler propagates. When no observer corresponds to an event (a self-marking rule only), the constant and helper live in the rule file instead of a dedicated observer file.
// observers/npm-test-tracker.ts

// 1. The event literal, exported as a constant.
export const TEST_PASSED_EVENT = "test-passed" as const;

// 2. A mark helper — encapsulates the shape of what gets written.
export function markTestPassed(ctx: PredicateContext): void {
  ctx.appendEntry(TEST_PASSED_EVENT, {});
}

// 3. The observer itself, using the helper.
export const npmTestTracker = {
  name: "npm-test-tracker",
  writes: [TEST_PASSED_EVENT],
  watch: {
    toolName: "bash",
    inputMatches: { command: /^npm\s+test\b/ },
    exitCode: "success",
  },
  onResult: (_event, ctx) => markTestPassed(ctx),
} as const satisfies Observer;
Rules reference TEST_PASSED_EVENT in when.happened.event rather than the raw string "test-passed". If a typo were introduced into the constant, the compiler would catch every usage site simultaneously.

Assembling the plugin object

// index.ts
import type { Plugin } from "pi-steering";
import { workItemFormat } from "./predicates/work-item-format.ts";
import { npmTestTracker, TEST_PASSED_EVENT } from "./observers/npm-test-tracker.ts";
import { retestRequiredTracker, RETEST_REQUIRED_EVENT } from "./observers/retest-required-tracker.ts";
import { commitRequiresWorkItem } from "./rules/commit-requires-work-item.ts";
import { pushRequiresTests } from "./rules/push-requires-tests.ts";
import { commitDescriptionCheck, DESCRIPTION_REVIEWED_EVENT } from "./rules/commit-description-check.ts";

export { TEST_PASSED_EVENT, RETEST_REQUIRED_EVENT, DESCRIPTION_REVIEWED_EVENT };

const workItemPlugin = {
  name: "work-item",
  predicates: { workItemFormat },
  rules: [
    commitRequiresWorkItem,
    pushRequiresTests,
    commitDescriptionCheck,
  ],
  observers: [npmTestTracker, retestRequiredTracker],
} as const satisfies Plugin;

export default workItemPlugin;
as const satisfies Plugin (rather than : Plugin) preserves the literal name: "work-item" in the inferred type, which is load-bearing for defineConfig’s disabledPlugins inference. It also preserves the writes tuples from rules and observers so defineConfig can cross-reference when.happened.event usages against this plugin’s declared writes.

Ecosystem conventions

Follow these conventions to make your plugin discoverable and interoperable with the broader pi ecosystem. Package name: pi-steering-<domain> (unscoped). Mirrors pi-steering core and the two built-in external plugins. Scoped names (@org/pi-steering-<x>) are fine for internal packages. Keywords — add both tags to package.json:
{
  "keywords": [
    "pi-package",
    "pi-steering-package"
  ]
}
pi-package surfaces your package alongside every pi extension. pi-steering-package surfaces it specifically in pi-steering plugin listings. Peer range — during v0.x, pin tightly:
{
  "peerDependencies": {
    "pi-steering": "^0.1.0"
  }
}
Switch to "^1" once pi-steering reaches v1. Ship .ts source as the package entry for hot-reload during plugin development. Set "main": "./src/index.ts" in package.json, use allowImportingTsExtensions: true and noEmit: true in tsconfig.json, and optionally add erasableSyntaxOnly: true to reject non-strippable TypeScript features at compile time. Consumers must run Node ≥ 22.6 for native type-stripping.
The examples/work-item-plugin/ directory in the pi-steering repo is the canonical authoring reference. It demonstrates every v0.1.0 pattern in one compact, domain-generic plugin — typed predicate handlers with definePredicate<T>, the ADR §14 observer encapsulation convention, when.happened gating with happened.since invalidation, the self-marking onFire pattern, and &&-chain speculative allow for npm test && git push. Read it top-to-bottom; the structure is meant to be copied.
Name collisions on Plugin.trackers are a hard error — two plugins claiming the same tracker state dimension cannot both be loaded. Modifier collisions (two plugins extending the same tracker with the same modifier key) log a WARN and keep the first-registered modifier. Both cases surface at config load time before any rules are evaluated.

Build docs developers (and LLMs) love