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.

Most plugins only need predicates. But if your rule requires per-ref state that isn’t cwd, branch, or env — custom tracker registration via Plugin.trackers adds new state dimensions to the bash AST walk, giving your predicates access to computed values that track across command refs exactly the way the built-in cwd tracker does.

The Tracker<T> and Modifier<T, TAll> API

Trackers come from unbash-walker, re-exported through pi-steering. The shape is:
interface Tracker<T> {
  initial: T;                            // starting value before any command runs
  unknown: T;                            // sentinel emitted when apply() returns undefined
  modifiers: Record<string, Modifier<T> | readonly Modifier<T>[]>;
  subshellSemantics?: "isolated" | "propagate";
}

interface Modifier<T, TAll = Record<string, unknown>> {
  scope: "sequential" | "per-command";
  apply(
    args: readonly Word[],
    current: T,
    allState: Readonly<TAll>,
  ): T | undefined;
}
Modifiers are keyed by command basename (e.g. "cd", "git", "my-tool"). Two scopes are available:
  • "sequential" — the result becomes the new tracker state for this command AND all subsequent sibling commands in the chain. Use this for state-mutating commands like cd.
  • "per-command" — the result applies to this command only. Subsequent commands see the pre-modifier state. Use this for flag overrides like git -C.
Modifier.apply receives (args, current, allState). The allState parameter is a read-only snapshot of every registered tracker’s pre-ref state, enabling cross-tracker reads. For example, the built-in cwdTracker’s cd modifier reads allState.env to expand $VAR targets. Narrow the TAll type parameter to declare exactly what you need from other trackers. Returning undefined from apply signals that the modifier cannot statically resolve the result. The walker substitutes the tracker’s unknown sentinel for that command and all that follow, and engine consumers apply an onUnknown: "allow" | "block" policy (default "block", fail-closed).

A minimal custom tracker

import type { Tracker } from "pi-steering";

const myTracker: Tracker<string> = {
  initial: "none",
  unknown: "unknown",
  modifiers: {
    "my-tool": {
      scope: "sequential",
      apply: (args, current, allState) => {
        const target = args[0];
        return target ?? "unknown";
      },
    },
  },
};

const myPlugin = {
  name: "my-plugin",
  trackers: { myState: myTracker },
  predicates: {
    myState: (args, ctx) => args.pattern.test(ctx.walkerState?.myState ?? ""),
  },
} as const satisfies Plugin;
After registration, ctx.walkerState?.myState is available in every predicate and when.condition callback evaluated for a bash tool call. The tracker key you supply under Plugin.trackers becomes the key on WhenWalkerState.

Built-in trackers

Two trackers ship with the package and are always registered: cwdTracker models the working directory of each command ref. It handles:
  • cd ABS / cd REL — replace or join with current dir
  • cd ~/x, cd (no args) — tilde and bare-cd expansion via $HOME
  • cd - — no-op (OLDPWD is not tracked)
  • cd "$VAR/pkg" — env-aware expansion via allState.env (see envTracker below)
  • git -C DIR — per-command override; composable: git -C /a -C b push records at /a/b
  • make -C DIR — per-command; scans all tokens (make parses flags interspersed with targets)
  • env -C DIR — per-command; scans the options region only
envTracker captures statically resolvable shell variable mutations from the same bash chain:
  • Bare assignments: WS_DIR=/ws
  • export NAME=VALUE
  • unset NAME
  • Seeded from process.env.{HOME, USER, PWD} at tracker initialization, so ~, $HOME, $USER, and $PWD expand out of the box
Both trackers are re-exported from the pi-steering package root.

cwdTracker known limitations

The cwd tracker is static analysis — it deliberately under- or over-approximates certain shell constructs so callers can make safe policy decisions:
  • Dynamic cd targets: cd $VAR, cd "$(pwd)", cd $UNDEFINED — the walker reads allState.env to expand $VAR / ${VAR} / ~. When any part is intractable (command substitution, arithmetic, parameter-expansion with modifiers, unknown var), the modifier returns undefined and the walker emits "unknown". Apply onUnknown: "allow" | "block" (default "block", fail-closed) on your when.cwd predicate.
  • source / . script.sh — external files are never read. source is extracted as a normal command but any cd effects the sourced script would perform at runtime are opaque to the walker.
  • pushd / popd — not treated as cd. pushd /A && y leaves y at the pre-pushd cwd. Write explicit rules against these commands if you want to catch them.
  • cd - — treated as a no-op; OLDPWD is not tracked.
  • if / case branches — exactly one branch runs at runtime, so the walker propagates a cwd forward only if ALL branches agree. Otherwise it falls back to the pre-branch cwd. Commands inside each branch still see that branch’s own cwd.
  • while / for / select — the body may iterate zero times. Body cwd never propagates forward; commands inside the body are walked from the loop’s starting cwd.
  • eval "..." — the string argument is not re-parsed. Only eval itself is extracted; commands inside the string are invisible.
  • Background & — treated like ; (cd effects propagate to subsequent commands). In real bash, cd /x & runs in a backgrounded subshell and the parent shell’s cwd is unchanged. This is a deliberate over-match: guardrail consumers see the more conservative cwd and when.cwd checks fire as expected.
  • Heredoc bodies — heredoc content is treated as data (a redirect payload on the owning command). A cd written inside a heredoc body is never extracted or walked. This is correct behavior, not over-match: heredoc bodies in real bash are stdin, not commands.
  • env -C DIR cmd wrapper-expansion interaction — the outer env ref is recorded at DIR, but the inner cmd ref surfaced by wrapper expansion has no entry in the walk result; consumers fall back to the session cwd for it. Lifting this requires wrapper expansion to consult the cwd tracker when computing inner refs.

Tracker extensions via Plugin.trackerExtensions

Plugin.trackerExtensions lets you layer additional modifiers onto an existing tracker without replacing it — useful when a tool has a -C-style flag that the built-in tracker doesn’t know about.
const myPlugin = {
  name: "my-plugin",
  trackerExtensions: {
    // Layer a --git-dir= parser onto the cwd tracker
    cwd: {
      "my-wrapper": {
        scope: "per-command",
        apply: (args, current, allState) => {
          // find --dir= flag and return the resolved path
          const dirFlag = args.find(
            (w) => (w.value ?? w.text)?.startsWith("--dir=")
          );
          if (!dirFlag) return current;
          const val = (dirFlag.value ?? dirFlag.text)?.slice("--dir=".length);
          return val ? (path.isAbsolute(val) ? val : path.join(current, val)) : current;
        },
      },
    },
  },
} as const satisfies Plugin;
The format is { trackerName: { basename: modifier } }. Name collision on the same basename within the same tracker logs a WARN and keeps the first-registered modifier. Name collision on Plugin.trackers (two plugins claiming the same tracker key) is a hard error — the engine cannot operate safely with two plugins claiming the same state dimension.

resolveWord(word, env) helper

resolveWord is re-exported from the pi-steering package root. It is the same helper cwdTracker’s cd modifier uses internally to expand dynamic targets.
import { resolveWord, type PredicateHandler } from "pi-steering";

export const matchesWorkspace: PredicateHandler = (args, ctx) => {
  const word = /* one of ctx.input.args */;
  const resolved = resolveWord(word, ctx.walkerState!.env);
  return resolved !== undefined && /workspace/.test(resolved);
};
resolveWord expands $VAR, ${VAR}, and ~ (at word start, unquoted) via the supplied env map. It returns undefined when any part of the word is statically intractable — command substitution, arithmetic, parameter-expansion with modifiers, or an unknown variable. Handle undefined with an onUnknown-style policy on your own predicate’s option shape.

envTracker + shell var expansion

The env tracker and cwd tracker compose through allState. A variable set in the same chain becomes available to the cd modifier’s expansion pass at the following cd:
// WS_DIR=/ws; cd "$WS_DIR/pkg"
// → walkerState.env.get("WS_DIR") === "/ws" at cd
// → walkerState.cwd === "/ws/pkg" at following commands
Subshell isolation applies: (FOO=/s; cd "$FOO"); cmd — the outer cmd sees neither FOO nor the subshell’s cd effect. The walker handles this generically via subshellSemantics: "isolated" on both trackers. Out of scope for v0.1.0: readonly, local, declare, typeset, source/., and function-body walking. The envTracker source (trackers/env.ts) lists the full deferred-scope inventory and graduation criteria.
Most plugin authors never need custom trackers — plugin-registered predicates cover 90% of use cases. Reach for Plugin.trackers only when your rule needs per-ref state that can’t be derived from the command’s args and the existing cwd, branch, or env tracker outputs.

Build docs developers (and LLMs) love