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.

The work-item-plugin is the canonical reference for plugin authors. It is a domain-generic work-item tracker — the [PROJ-N] ticket format is a deliberate placeholder, not a real system — whose purpose is to demonstrate every v0.1.0 authoring pattern in one readable place. Reading the source top-to-bottom is the fastest path to understanding how to build a production plugin. The plugin enforces three rules that together require the agent to do the work in a verifiable order: reference a ticket in every commit message, prove tests pass before pushing, and re-read its own commit description before committing a second time. Each rule demonstrates a distinct engine feature; none of the rules depend on domain knowledge specific to any real project.

Plugin structure

work-item-plugin/
├── src/
│   ├── index.ts                          # Plugin export
│   ├── predicates/
│   │   └── work-item-format.ts           # definePredicate<T>, typed args
│   ├── observers/
│   │   ├── npm-test-tracker.ts           # TEST_PASSED_EVENT + observer
│   │   └── retest-required-tracker.ts    # RETEST_REQUIRED_EVENT + observer
│   └── rules/
│       ├── commit-requires-work-item.ts  # when.not + plugin predicate
│       ├── push-requires-tests.ts        # when.happened + temporal invalidation
│       └── commit-description-check.ts  # onFire self-marking

The three rules

commit-requires-work-item

Intercepts git commit ... -m <msg> and blocks unless the message carries a [PROJ-N] token. It uses the plugin-registered workItemFormat predicate under when.not — the predicate returns true when the work-item tag is present, and the not inversion means the rule fires when it is absent.
import type { Rule } from "pi-steering";

export const commitRequiresWorkItem = {
  name: "commit-requires-work-item",
  tool: "bash",
  field: "command",
  pattern: /^git\s+commit\b.*-m\s/,
  when: {
    // Invert the predicate: fire when the work-item tag is MISSING.
    not: {
      // workItemFormat is registered in the plugin's index.ts via
      // the PiSteeringPredicates global augmentation.
      workItemFormat: { pattern: /\[PROJ-\d+\]/ },
    },
  },
  reason:
    "Commit messages must reference a work item ticket, e.g., [PROJ-123].",
  noOverride: false,
} as const satisfies Rule;
noOverride: false is explicit here — this is a workflow rule, not an inherently destructive action, so override comments are permitted when genuinely warranted.

push-requires-tests

Intercepts git push and blocks unless npm test has succeeded in the current agent loop and no subsequent git pull has staled that result. This rule combines three engine features:
import type { Rule } from "pi-steering";
import { TEST_PASSED_EVENT } from "../observers/npm-test-tracker.ts";
import { RETEST_REQUIRED_EVENT } from "../observers/retest-required-tracker.ts";

export const pushRequiresTests = {
  name: "push-requires-tests",
  tool: "bash",
  field: "command",
  pattern: /^git\s+push\b/,
  when: {
    // Fires when TEST_PASSED_EVENT has NOT been written in the
    // current agent loop, OR its most-recent entry is older than
    // the most-recent RETEST_REQUIRED_EVENT (e.g. a later `git pull`
    // staled the test state).
    happened: {
      event: TEST_PASSED_EVENT,
      in: "agent_loop",
      since: RETEST_REQUIRED_EVENT,
    },
  },
  reason:
    "Run `npm test` successfully in this agent loop before pushing. " +
    "If you ran `git pull` after the last test, re-run tests.",
  noOverride: true,
} as const satisfies Rule;
noOverride: true is correct here — pushing without proof of green tests is treated as an inherently risky action.

commit-description-check

A self-marking reminder rule. The first git commit in an agent loop is blocked with a “re-read your description” message; the rule marks itself via onFire so the second git commit in the same loop is allowed. A new agent loop resets the reminder. This demonstrates the onFire side-effect hook (ADR §6) for rules that produce their own state rather than relying on a separate observer.

The npm-test-tracker observer

The observer follows the ADR §14 encapsulation convention: every observer file exports the event constant, a typed write helper, and the observer itself. This keeps the raw event string in exactly one place — downstream typos become compile errors.
import type { Observer, ObserverContext, PredicateContext } from "pi-steering";

// The session-entry type written when `npm test` succeeds.
export const TEST_PASSED_EVENT = "example-npm-test-passed" as const;

export interface TestPassedPayload {
  command: string;
}

// Write helper — callable from observers and onFire hooks alike.
export function markTestPassed(
  ctx: ObserverContext | PredicateContext,
  payload: TestPassedPayload = { command: "npm test" },
): void {
  ctx.appendEntry<TestPassedPayload>(TEST_PASSED_EVENT, payload);
}

// The observer itself.
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) => {
    const input = event.input as { command?: string } | undefined;
    markTestPassed(ctx, {
      command: input?.command ?? "npm test",
    });
  },
} as const satisfies Observer;
The writes: [TEST_PASSED_EVENT] declaration threads through to defineConfig’s compile-time cross-reference checking — rules that use happened: { event: TEST_PASSED_EVENT } are validated against the union of all declared writes across plugins and observers at authoring time.

The plugin export

import type { Plugin, PredicateShape } from "pi-steering";
import { workItemFormat } from "./predicates/work-item-format.ts";
import type { WorkItemFormatArgs } 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";

declare global {
  interface PiSteeringPredicates {
    workItemFormat: PredicateShape<WorkItemFormatArgs>;
  }
}

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;
The as const satisfies Plugin pattern (ADR §7) preserves literal types — name: "work-item" stays a string literal, not string, and the writes tuples from rules and observers stay as typed tuple literals. This is what allows defineConfig to cross-reference when.happened.event values against the plugin’s declared writes at compile time. A bare : Plugin annotation would widen those types and disable the cross-reference check.

The &&-chain speculative allow

push-requires-tests blocks git push when TEST_PASSED_EVENT has not been written in the current agent loop. Without special handling, npm test && git push would hit the same block: the engine evaluates the full command before any of it runs, and TEST_PASSED_EVENT has not been written yet. The engine resolves this through speculative allow. Because npmTestTracker declares writes: [TEST_PASSED_EVENT], and push-requires-tests gates on happened: { event: TEST_PASSED_EVENT }, the engine recognises that npm test — the && predecessor — would write the required event if it succeeds. The push is speculatively allowed pre-execution. If npm test fails, the && chain short-circuits and git push is never reached. If npm test succeeds, the observer fires, writes TEST_PASSED_EVENT, and the push proceeds. This pattern breaks the “block → agent retries same chain → block” loop without weakening the guardrail for non-chained pushes — a plain git push with no preceding npm test in the same loop is still blocked.

Consuming the plugin

// .pi/steering/index.ts
import { defineConfig } from "pi-steering";
import workItemPlugin from "@examples/work-item-plugin";

export default defineConfig({
  plugins: [workItemPlugin],
});
In a real project, replace @examples/work-item-plugin with your own published plugin package following the same layout.

Running the tests

# From the repo root:
pnpm install
pnpm --filter @examples/work-item-plugin test
The pretest script builds pi-steering first, so the example resolves the package through its emitted dist/ exports. Run pnpm -r test to execute all packages’ suites in one pass.
Read this example top-to-bottom — the structure is intentional. index.ts ties everything together; start there, then follow the imports into each rule and observer file. The layout is meant to be copied wholesale for new plugins: replace the domain specifics, keep the structural skeleton.

Build docs developers (and LLMs) love