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 when field on a rule composes predicates that must all pass (logical AND) for the rule to fire. When pattern (and optionally requires / unless) match, the engine evaluates every leaf in when before returning a block verdict. All leaves are evaluated with Kleene three-valued logic — a leaf that cannot resolve its value returns "unknown", and the onUnknown policy decides whether to treat that as true (fail-closed, the default) or false (fail-open). Plugin-registered predicates extend this surface via TypeScript module augmentation.

TopLevelWhenClause shape

type TopLevelWhenClause<Writes extends string = string> = {
  // Built-in non-registry leaves:
  cwd?: Pattern | Pattern[]
      | { pattern: Pattern | Pattern[]; onUnknown?: "allow" | "block" };
  happened?: {
    event: Writes;
    in: "agent_loop" | "session" | "tool_call";
    since?: Writes;     // optional invalidation sentinel
    notIn?: "agent_loop" | "session" | "tool_call";  // scope subtraction
  };
  condition?: (ctx: PredicateContext) => boolean | Promise<boolean>;

  // One level of negation (no recursion):
  not?: TopLevelWhenClauseNoRecurse<Writes>;
} & {
  // Plugin-registered predicate leaves (branch, upstream, commitFormat, ...):
  [K in keyof PiSteeringPredicates as K extends ReservedPredicateKey
      ? never
      : K]?: OuterValue<K & PluginPredicateKey>;
};
The Writes generic threads through defineConfig so when.happened.event and when.happened.since references are compile-time-checked against the union of all declared writes arrays across plugins and observers.

when.cwd

The cwd leaf constrains a rule to commands whose effective working directory matches a pattern. For bash rules, the walker’s cwdTracker resolves the effective cwd per extracted command ref — so in cd ~/personal && git commit, the git commit ref evaluates against ~/personal, not the original session cwd. For write and edit rules, the session cwd is used directly. Dynamic targetscd "$WS_DIR/pkg", cd ~/proj — are resolved through the walker’s envTracker, which is seeded from process.env.{HOME, USER, PWD} and updated with any bare assignments, export, or unset statements in the same command chain. Intractable targetscd $(pwd), cd $UNDEFINED — cannot be resolved statically and surface as the "unknown" sentinel string. Apply onUnknown: "allow" | "block" (default "block", fail-closed) to choose how unknown cwds are handled. The fail-closed default means that when the walker cannot resolve a cwd, the predicate fires and the rule blocks.
// Bare form — matches any resolved cwd containing /personal/
when: { cwd: /\/personal\// }

// Array form — OR-of-patterns (any match counts as a hit)
when: { cwd: [/\/Goldmine\//, /\/\.cache\/napkin-distill\//] }

// Spread form — with explicit onUnknown policy
when: { cwd: { pattern: /\/personal\//, onUnknown: "allow" } }

// Array spread form
when: { cwd: { pattern: [/\.test$/, /\.spec$/], onUnknown: "allow" } }
Inside a not: block, cwd does not accept a leaf-level onUnknown. The block-level onUnknown modifier on the not: object applies to all leaves inside it.

when.happened

The happened leaf fires when a session entry of the given event type has not occurred in the given scope. This inverts the natural read: “rule fires when the event has NOT happened yet.” To express “rule fires when the event HAS happened,” wrap inside not: { happened: { event, in } }. Three scope values control which entries are considered:
  • "agent_loop" — filters entries by _agentLoopIndex === ctx.agentLoopIndex. One agent loop equals one user prompt plus every tool call it spawns. The engine auto-injects the _agentLoopIndex tag on every appendEntry write, so plugin authors do not need to tag manually. This is the most common scope.
  • "session" — no scope filter. Any entry of event present in the session JSONL satisfies the clause, regardless of which agent loop it came from.
  • "tool_call" — only considers speculative entries synthesized for this tool call’s &&-chain. Real (persisted) entries are ignored. Use when the event must be directly chained before the guarded command (e.g. sync && cr), not merely present somewhere in the current agent loop. See the &&-chain speculative allow section on the Observers page for how speculative entries are generated.
when: {
  happened: {
    event: "ws-sync-done",
    in: "agent_loop",
  },
}

since — invalidation sentinel

The optional since field adds temporal ordering. When present, the event counts as “happened” only if its most-recent entry in scope is strictly newer than the most-recent since entry. If since has never been written, the clause degrades to a simple presence check on event — adding since is safe even when the invalidator isn’t yet in play.
when: {
  happened: {
    event: "ws-sync-done",
    in: "agent_loop",
    since: "upstream-failed",
  },
}
Both event and since are constrained inside defineConfig to the union of all declared writes across the config. A typo like "upstream-faild" is a compile error.

notIn — scope subtraction

The optional notIn field subtracts a narrower scope from the in scope before the check runs. For example, { in: "agent_loop", notIn: "tool_call" } means “happened in a prior tool call in this agent loop” — same-tool_call speculative entries are excluded, preventing the chain bypass from satisfying a rule that requires real prior execution. notIn is set subtraction, distinct from the clause-level not (boolean negation). Invalid scope combinations — supersets or identicals — throw at evaluation time with the rule name prefixed.

when.not

The not operator allows one level of boolean negation over an inner predicate block. All leaves inside not: AND together with Kleene three-valued logic, and the block-level onUnknown modifier (default "block", fail-closed) projects any "unknown" verdict.
// Rule fires when the cwd does NOT contain /work/
when: { not: { cwd: /work/ } }

// With block-level onUnknown: when cwd can't be resolved,
// treat as if the not-block returned false (fail-open for the not)
when: { not: { cwd: /work/, onUnknown: "allow" } }
Inside not:, leaf-level onUnknown is forbidden at the type level — modifiers live at the not: object level, not on individual inner leaves. This prevents the silent fail-open pattern where an author writes not: { cwd: { pattern: P, onUnknown: "allow" } } thinking they’re opting into fail-open inside the negation. No not: not: recursion is allowed — the type system enforces one level only, and the runtime validateWhenClauseShape rejects nested not shapes from JSON or as any escape hatches.

when.condition

The condition leaf is an escape hatch for one-off logic that doesn’t warrant a reusable plugin predicate. It accepts a PredicateFn — a function receiving the full PredicateContext — and may be async. Throws (sync or rejected promise) are caught and treated as "unknown", which under the default "block" policy fires the rule fail-closed. Authors needing fail-open behavior wrap inside not: { condition: fn, onUnknown: "allow" }, or catch the throw inside the callback body.
when: {
  condition: async (ctx) => {
    const result = await ctx.exec("git", ["status", "--porcelain"], {
      cwd: ctx.cwd,
    });
    return result.stdout.trim().length > 0;
  },
}
Prefer plugin predicates when the logic is reusable across rules or projects. Reserve condition for genuinely local checks.

Plugin-registered predicates

The PiSteeringPredicates global interface is the plugin registry for when leaves. Plugins extend it via declare global module augmentation, adding typed predicates that appear as first-class fields on TopLevelWhenClause with autocomplete and JSDoc.
// Inside a plugin's index.ts
import type { Patterns, PredicateShape } from "pi-steering";

declare global {
  interface PiSteeringPredicates {
    // Auto-detected spreadBase: bare Pattern → spread { pattern: Pattern }
    branch: PredicateShape<Patterns>;
  }
}
Each registered key contributes a leaf-level field on TopLevelWhenClause accepting the bare form or the spread form (bare wrapped in an object plus optional onUnknown). Keys colliding with reserved names (not, onUnknown, and other modifier keys) are rejected at load time. Built-in plugin predicates from the default git plugin:
  • when.branch — matches the statically-resolved git branch at the command ref’s cwd
  • when.upstream — matches the branch’s configured upstream
  • when.commitsAhead — comparator (gt, eq, lt) against the commit count ahead of the upstream
  • when.hasStagedChanges — boolean check for staged changes
  • when.isClean — boolean check for a clean working tree
  • when.remote — matches the remote URL
From the pi-steering-flags plugin (see Flags Plugin):
  • when.requiresFlag — rule fires only if the command includes a specific flag
  • when.allowlistedFlagsOnly — rule fires if any flag not in the allowlist is present
From the pi-steering-commit-format plugin (see Commit Format Plugin):
  • when.commitFormat — checks that the commit message matches a declared format (Conventional Commits, JIRA-style, or custom)

PredicateContext

Every PredicateFn, condition callback, and plugin PredicateHandler receives a PredicateContext:
interface PredicateContext {
  cwd: string;                                  // effective cwd for this ref
  tool: "bash" | "write" | "edit";
  input: PredicateToolInput;                    // tool-shaped input
  agentLoopIndex: number;                       // current agent loop counter
  exec: (cmd: string, args: string[], opts?: ExecOpts) => Promise<ExecResult>;
  appendEntry<T>(type: string, data?: T): void;
  findEntries<T>(type: string): Array<{ data: T; timestamp: number }>;
  walkerState?: Readonly<WhenWalkerState>;      // tracker snapshot (bash only)
}

interface WhenWalkerState {
  readonly cwd: string;                         // effective cwd, or "unknown"
  readonly env: ReadonlyMap<string, string>;    // env map (HOME/USER/PWD + chain writes)
  readonly [key: string]: unknown;              // plugin trackers (e.g. branch)
}
exec runs a subprocess and returns { stdout, stderr, exitCode }. It is memoized per (cmd, args, cwd) tuple within a single tool_call evaluation — two rules reading the same git status output don’t re-fork the process. There is no cross-tool_call cache. walkerState.env carries the per-ref env map built from bare assignments (FOO=bar), export NAME=value, and unset NAME statements in the same bash chain, seeded from process.env.{HOME, USER, PWD} at session start. Use the resolveWord helper to expand $VAR, ${VAR}, and ~ through this map:
import { resolveWord } from "pi-steering";

const myPredicate: PredicateHandler = (args, ctx) => {
  const expanded = resolveWord(userWord, ctx.walkerState!.env);
  // expanded is undefined when the word is statically intractable
  // (unknown var, command substitution, arithmetic, etc.)
  return expanded !== undefined && /workspace/.test(expanded);
};
resolveWord returns undefined when any part of the word is statically intractable. Handle that the same way the built-in when.cwd does — via an onUnknown: "allow" | "block" policy on your predicate’s option shape. input.args on bash rules gives you the Word[] suffix — quote-aware structured access where .value is the lexical unwrapped value and .text is the raw source text. Use this instead of splitting input.command when the predicate needs to preserve quoted content such as -m "feat: my commit". For write and edit rules, walkerState is undefined — there is no walker invocation for file-surface tools.

Build docs developers (and LLMs) love