TheDocumentation 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.
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
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 targets — cd "$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 targets — cd $(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.
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_agentLoopIndextag on everyappendEntrywrite, so plugin authors do not need to tag manually. This is the most common scope."session"— no scope filter. Any entry ofeventpresent 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.
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.
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.
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.
condition for genuinely local checks.
Plugin-registered predicates
ThePiSteeringPredicates 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.
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 cwdwhen.upstream— matches the branch’s configured upstreamwhen.commitsAhead— comparator (gt,eq,lt) against the commit count ahead of the upstreamwhen.hasStagedChanges— boolean check for staged changeswhen.isClean— boolean check for a clean working treewhen.remote— matches the remote URL
pi-steering-flags plugin (see Flags Plugin):
when.requiresFlag— rule fires only if the command includes a specific flagwhen.allowlistedFlagsOnly— rule fires if any flag not in the allowlist is present
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:
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:
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.