Hooks reference¶
steer's hooks are POSIX-sh scripts under plugins/steer/hooks/, wired in
hooks.json. They inject the always-on rules and gate risky actions. All hook
commands are invoked with an explicit sh prefix, so the executable bit is
irrelevant (marketplace install does not chmod). No jq dependency. Every hook
declares an explicit timeout in hooks.json (10s for the rule injection,
orientation, and PreToolUse hooks; 30s for the consolidated session checks, the
PostToolUse formatter, and the Stop reconcile) so a slow hook can never stall
a session.
Hook scripts must check out as LF — a CRLF copy breaks all of them at once
Every hook here is a POSIX sh script, and a CRLF shell script does not
warn, it fails to parse: the shell reads the trailing \r as part of the
token, so steer_repo_root() { becomes
syntax error near unexpected token $'{\r' and the script never runs. Because
the hooks share hooks/lib/*.sh, one CRLF checkout takes out the whole set
simultaneously — this was the v5.0.0 fault. The plugin therefore ships a
.gitattributes pinning * text=auto eol=lf, so the bundled scripts check
out as LF regardless of the host's core.autocrlf (the Git for Windows
default is true). If you see every hook broken at once, run
/steer:doctor — its §0 plugin-integrity check greps the
installed hooks/ and scripts/ for CR before anything else and reports it
as an install fault rather than a missing prerequisite. See
Windows setup → Line endings.
Hooks are a Claude Code lifecycle feature — don't assume they ran
Everything below hangs off Claude Code's hook lifecycle (SessionStart,
PreToolUse, PostToolUse, Stop). Note what each tier actually does: the
SessionStart hook injects the rules (and one session check, check-worktree-trust, also writes — it marks this worktree's path trusted in mise's local trust store); most PreToolUse checks are
advisory nudges that let the write proceed (the two dimensions of
check-write-nudges, the issue-create contract guard in check-bash-actions);
only check-version-pins issues a hard deny, and the trunk-push gate in
check-bash-actions surfaces a permission ask (the human can
approve and continue); the PostToolUse formatter runs after a write and
only reformats it. On surfaces where hooks don't fire — the Desktop Chat tab and
claude.ai web chat — none of this runs, so load the rules manually with
/steer:standards and lean on human review. See
Surfaces without hooks below and
Known limitations.
flowchart TD
subgraph SessionStart
inject[inject-standards.sh<br/>injects rules/*.md]
checks[session-checks.sh<br/>orchestrates the six session checks]
orient[orient-session.sh]
checks --> drift[check-template-drift.sh]
checks --> oq[check-open-questions.sh]
checks --> unmanaged[check-unmanaged-repo.sh]
checks --> faults[surface-faults.sh]
checks --> grad[check-graduation.sh]
checks --> wt[check-worktree-trust.sh]
end
subgraph PreToolUse
pins[check-version-pins.sh]
wn[check-write-nudges.sh<br/>spec/scaffold + issue-first]
ba[check-bash-actions.sh<br/>trunk-push gate + issue-create guard]
end
subgraph PostToolUse
fmt[format-on-write.sh<br/>format the just-written file]
end
subgraph Stop
reconcile[reconcile-issue-first.sh]
end
SessionStart¶
Since the session-checks consolidation, hooks.json carries three
SessionStart registrations: the rule injection, one session-checks.sh
orchestrator, and the orientation hook. The session checks are no longer
registered individually — session-checks.sh runs them in the order below,
failure-isolated (a crashing check never blocks the rest) and always exiting
0; each check keeps its own contract (read the payload from stdin, print a
notice or nothing) and stays individually testable.
| Hook | Matcher | Role |
|---|---|---|
inject-standards.sh |
startup\|resume\|clear\|compact |
Concatenates rules/*.md (lexical order) into session context. It is not once-per-session: there is no guard, and compact can fire repeatedly within one session — deliberately, since a compaction can drop the injected rules and re-injecting is what puts them back. A rule carrying a first-line <!-- steer:inject-when=… --> marker is injected only when its scope applies — code-project for the code-loop rules (a git work tree, or any code/config marker within maxdepth 2), issue-first on GitHub-tracked repos, deployment when the repo deploys (has-iac or has-apps — IaC meaning an /infra dir, root *.tf/*.hcl, ansible.cfg, site.yml, Pulumi.yaml, or roles/ + playbooks/; apps meaning an apps/ dir, a package.json, or a pnpm-workspace.yaml) — and the marker line is stripped. In knowledge-work mode (a confidently non-code folder — the typical Cowork product-owner case), it injects only the lean always-on PO core and skips every inject-when-marked rule (see Knowledge-work mode). Fail-soft: if its rules directory is missing it still emits a fallback banner (the hook always exits 0, so the notice reaches the session) and records a self-fault for /steer:report. |
session-checks.sh |
startup\|resume\|clear |
Consolidated orchestrator for the six session checks below (one hooks.json registration; five of them were once registered individually, and check-worktree-trust.sh was added inside the roster). Captures the SessionStart payload once and re-feeds it to each check unchanged, in registration order; failure-isolated; always exits 0. Contains no check logic of its own. |
check-template-drift.sh |
via session-checks.sh |
Warns when the materialized spine/scaffold lags the plugin templates — diffs the ##/### headings of each instantiated spec file (PRODUCTIONIZATION.md, BUILD-STATUS.md, feature intent.md/contract.md) against the current bundled template and names any section the template adds that the file lacks. Headings carrying <!-- steer:placeholder --> (the seed ### Q-001 — … open-question block) are skipped, since those are rewritten or deleted as a feature is specced — matching check-open-questions.sh, which ignores the same marker — so a correctly-completed file is never falsely flagged. Resolves the work-tree root from the session cwd, so it still finds drift when Claude Code starts in a subdirectory (e.g. apps/web). |
check-open-questions.sh |
via session-checks.sh |
Surfaces unresolved spec open questions as a four-bucket count — block work now vs block a later transition (split by comparing each question's required_before: gate against the feature's cleared Status, via lib/lifecycle.sh), plus non-blocking backlog and malformed (a Q-NNN block missing status/impact, surfaced rather than dropped). Only status: open and status: investigating count; <!-- steer:placeholder --> seeds are skipped and legacy - [ ] items count as backlog only where they sit inside ## Open questions and outside any ### Q-NNN block, with a bracketed [placeholder] rest skipped. It also warns when a retired spec/SPEC-QUESTIONS.md is still present — the standalone file v1.25.0 replaced; /steer:questions heals it as a hard gate before its sweep. It also escalates stale ones — a blocking, un-promoted question open more than 14 days (from its created: date, or git blame when absent) gets a loud line naming the feature, question, owner, and age. |
check-unmanaged-repo.sh |
via session-checks.sh |
On a repo whose spine is not yet managed, prints a compact plain-language onboarding card (a foreign spine gets a shorter adopt offer instead, and a damaged one a repair notice — so creating /spec swaps the message rather than clearing it; only a complete, version-stamped spine is silent): the user can just say what they want — think an idea through (/steer:spec lite mode, works with no bootstrap), build an app (/steer:build, non-technical owner), or set the repo up (/steer:init greenfield / /steer:adopt existing code, with /steer:setup picking between them). Feature code still requires the bootstrap first — spec-only work is the one sanctioned exception. Resolves the work-tree root from the session cwd in the hook payload, so it anchors correctly from a subdirectory. |
surface-faults.sh |
via session-checks.sh |
Raises any unreported steer self-faults recorded by other hooks (via lib/report-fault.sh) into session context, once each, so /steer:report can file them upstream. Silent when there are none and inside the plugin's own tree. |
check-graduation.sh |
via session-checks.sh |
Only in solo-trunk mode: when a local graduation signal is present (a prod/production branch, a deploy workflow, or an infra/ tree — detected by the shared lib/graduation.sh, the same detector the check-bash-actions.sh trunk-push gate uses), nudges the owner to graduate to PR flow via /steer:protect and notes that trunk pushes are gated until then. Offline (the collaborator-count signal is left to /steer:audit//steer:protect); silent in pr-flow, with no signal, or once graduated. |
check-worktree-trust.sh |
via session-checks.sh |
Only in a linked worktree: inherits the primary checkout's mise trust so mise run … works there immediately. mise trust is path-based, so a new worktree is untrusted and the whole scaffolded dev loop fails on trust rather than on the task — triggered by the scaffold's own [env] _.source = "scripts/worktree-env.sh", which mise refuses to load untrusted. Inheriting grants nothing new: mise keys trust by path, not by content, so the primary checkout already trusts every future edit of that config. It never creates trust: an untrusted primary checkout means the repo was never set up (it names mise trust && mise install), and a primary checkout with no mise config at all means the worktree's branch introduced one, so no prior decision exists anywhere (it names mise trust here) — either way it changes nothing and leaves the call to the user. A third notice covers mise trust itself failing. Silent in a plain checkout (gated before mise is ever invoked), outside any work tree, without mise on PATH, when the worktree is already trusted, and when it has no mise config. |
orient-session.sh |
startup\|resume\|clear\|compact |
Two audiences. In a non-code knowledge-work folder (no git work tree) it emits a one-time, plain-language confirmation that the lean standards are loaded and that the user need not learn any /steer:* names — gated to source: startup so it does not re-greet after a /clear, resume or compaction. Otherwise, on a fully managed spine only: if an in-progress PO build exists (a spec/BUILD-STATUS.md with an open handoff gate), steers deterministically back into /steer:build to resume from its current step; once the build is handed off (every gate box checked) it falls back to reminding the model to surface the "describe what you want in plain language" affordance — so a non-technical user need not know skill names. Also emits a short polyrepo topology note in a repo carrying spec/workspace.yml (workspace host) or spec/PRODUCT.md (member), role-specific — this hook is the sole automatic delivery path for the topology, which is deliberately not an always-on rule (/steer:reference polyrepo is the on-demand path). The topology note is emitted before the PO-build branch, which exits early, so a workspace or member with an open handoff gate still receives it; and it carries the same startup|resume|clear|compact matcher as the ruleset it substitutes for, so a /clear, a resume or auto-compaction does not silently drop it. Silent on unmanaged/foreign/damaged spines (owned by check-unmanaged-repo.sh). |
PreToolUse¶
| Hook | Matcher | Role |
|---|---|---|
check-version-pins.sh |
Write\|Edit\|MultiEdit\|NotebookEdit |
Enforces the EOL floor in policy/versions.yml (deterministic, no network, no jq): a pin below minimum_supported or in the denied list is denied; anything at or above the floor is silent. It is a floor, not a chooser — there is no advisory "behind the target" tier; what to pin (current stable) is decided live per the versioning rule (/steer:reference conventions). A scheduled workflow (version-policy-refresh.yml) keeps the floor current by opening a human-reviewed PR when it falls behind upstream end-of-life — the only place endoflife.date is consulted. A deliberately older pin (deploy-target parity, vendor LTS) bypasses the deny by appending # steer:allow-pin <reason> on the same line plus an ADR (legacy alias: # pin-ok:). |
check-write-nudges.sh |
Write\|Edit\|MultiEdit\|NotebookEdit |
The two write-path advisory nudges (not gates) in one process — they share the same matcher, root resolution, and path classification, so they run as one hook. Spec/scaffold dimension: the spine reminder fires once per session+repo when code is about to be written before a /spec spine exists; the scaffold reminder is sticky — it re-fires on each new feature file while the repo has no root mise.toml (dedups per file, self-clears once a mise.toml lands or the spine is managed). Issue-first dimension: a one-per-session reminder to work issue-first above Tiny (the message names the carve-out: not spec, docs, lockfiles, or a change under ~20 lines with no behavior change, where the PR is the evidence anchor), only in GitHub-tracked repos — it cannot know whether an issue exists. In solo-trunk mode (the steer:delivery-mode=solo-trunk marker in CLAUDE.md) it still nudges — issue-first holds — but rewords to "close the issue from the trunk commit," not "open a PR / branch." Stays silent on the /steer:sync plugin-maintenance branch (feat/sync), whose scaffold reconciliation is structural, not feature work — unless the write is app source, which sync must not touch. Non-blocking — the write always proceeds; when both dimensions are due on one write their messages are emitted together. |
check-bash-actions.sh |
Bash\|mcp__.*[Ii]ssue.* |
The two Bash-path checks in one process (this is the hottest PreToolUse path — every Bash call matches). Trunk-push graduation gate (Bash only): in a solo-trunk repo that shows a local graduation signal (a deploy workflow, an infra/ tree, or a prod/production branch — the shared lib/graduation.sh detector), a Bash git push surfaces as a permission ask naming /steer:protect — never a hard deny, so the human can approve the push and keep working. The ask fires once per session+repo; repeat pushes in the same session downgrade to a non-blocking reminder (still not silent), so an autonomous run is not stalled on every push. Silent everywhere else: pr-flow repos (branch pushes are autonomous; the server-side merge review is the gate), signal-free solo-trunk repos (trunk autonomy holds), non-push commands, and anything outside a work tree. The gate judges the repo being pushed: a git -C <dir> push resolves its root from <dir> (steer_action_root), not from the session cwd, so a nested work tree is never judged by its parent's delivery mode. Registered for Copilot CLI too (flat ask envelope; repeats are silent there — the Copilot envelope carries decisions only). Issue-create contract guard (Bash + MCP): a one-per-session advisory nudge, only in GitHub-tracked repos, when an agent opens an issue with a raw create that bypasses the machine-readable contract — gh issue create, gh api … POST …/issues, a gh api graphql createIssue mutation, or an MCP create-issue tool (including the hosted GitHub MCP's renamed issue_write method; sub-issue linkers like add_sub_issue/sub_issue_write are excluded — they attach a relationship to an existing issue and carry no body). Points at /steer:tracker-sync create, which renders the steer markers, the derived source:* label, the GitHub Issue Type, and native relationship edges (with find-before-create dedup). Stays silent when the payload already carries steer: markers (the contract-render path) and in the plugin's own source repo. The complementary after-the-fact recovery path is /steer:issues reconcile --all, which flags contract-less issues. |
PostToolUse¶
| Hook | Matcher | Role |
|---|---|---|
format-on-write.sh |
Write\|Edit\|MultiEdit |
Formats the single file a write just touched with the repo's own formatter, removing the formatting-only CI round-trip. Strictly opt-in: it runs only when the repo has declared a formatter this hook knows — a root biome.json/biome.jsonc (biome, for .ts/.tsx/.js/.jsx/.mjs/.cjs/.json/.jsonc/.css) or a root pyproject.toml (ruff, for .py) — and the formatter binary is already on PATH. No config, an unknown extension, or a missing binary → silent no-op; it never installs a tool, introduces a formatter, or sweeps the tree. Best-effort and always exits 0 (a formatter error on mid-refactor, unparseable code never fails the hook), and the write has already happened, so there is no decision to influence. Exempt in the plugin's own source repo, whose pre-commit owns formatting. Not ported to Copilot — Copilot ports only the blocking PreToolUse gates. |
Stop¶
| Hook | Role |
|---|---|
reconcile-issue-first.sh |
End-of-turn reconciliation of issue-first bookkeeping — it ties every implementation-affecting mutation above Tiny (under ~20 lines with no behavior change) to a GitHub issue. In solo-trunk mode it skips the branch-name check (main is expected) and rewords its advisory to "reference the issue in the trunk commit" rather than steering to an issue/<N> branch — issue-first still holds. Exempts the /steer:sync branch (feat/sync) the same way the point-of-action nudge does — silent unless app source also changed. Caps its per-file change classification scan (fail-soft — nothing governed found by the cap → silent), so a huge first-turn dirty tree cannot approach the 30s Stop timeout. |
Shared input extraction (lib/json.sh)¶
The PreToolUse/Stop hooks read their JSON payload from stdin through one
shared helper, hooks/lib/json.sh — deterministic, dependency-free, and with
no jq requirement (it uses jq only as a fast path when present, and falls
back to a narrow POSIX grep/sed extractor otherwise). The two paths agree on
the same contract:
- A field resolves to
tool_input.<name>in preference to a top-level.<name>, so a same-named field elsewhere in the payload cannot be mistaken for the tool's real argument (e.g. thefile_pathaWriteis about). - Within that scope the first match wins, so a repeated key buried in a later
contentvalue cannot shadow the real field, and escaped quotes/backslashes in values are tolerated.
This is best-effort extraction for the exact PreToolUse shapes — not a general JSON parser — and every consuming hook is fail-open, so an unparseable payload degrades to a missed nudge, never a wrongful block.
Which repo a hook is judging (lib/repo-root.sh)¶
Hooks receive the session cwd, but the thing being acted on is not always in
the same repository. When a git repo is nested inside another work tree — a
vendored or gitignored clone, a tools/ checkout, a polyrepo member cloned
inside its workspace — an upward .git walk from cwd stops at the outer
repo while the tool writes to, or pushes from, the inner one. Every marker read
off that root (delivery mode, profile, graduation signals, tracker) then
describes the wrong repository.
steer_action_root <cwd> [action_path] resolves from the acted-on path
instead, falling back to cwd's root when there is none:
- editor writes pass
tool_input.file_path/.notebook_path(check-write-nudges,check-version-pins,format-on-write); - the trunk-push gate passes the
-C <dir>target of the git command (steer_git_c_target, used bycheck-bash-actions).
A path that does not exist yet resolves via its nearest existing ancestor, so a
Write creating a new file — or a new directory — is attributed to the repo that
will contain it. No path, an unresolvable path, or a path outside any work tree
all fall back to cwd, leaving the single-repo case (the overwhelmingly common
one) exactly as it was.
There is no equivalent handling for cd <dir> && git push: only -C states its
target in the command line. That case still resolves from cwd.
steer_primary_worktree <root> answers a different question: which checkout a
relative marker path should be resolved against. A linked worktree
(.claude/worktrees/<name>) is a different work-tree root than the checkout the
marker was written in, so a relative path resolved against it silently points
somewhere else. It returns <root> unchanged for a primary checkout and the
primary's root for a linked worktree, reading the gitdir: pointer out of the
worktree's .git file rather than shelling out to git (this file is sourced
on the PreToolUse hot path). Anything it cannot read with certainty — an
unparseable .git, a relative gitdir:, a --separate-git-dir layout — returns
<root> unchanged. Two consumers: steer_workspace_root (below) anchors relative
marker paths with it, and check-worktree-trust.sh uses it as the linked-worktree
detector — a returned root that differs from the session's root is the signal
that this checkout is a linked worktree.
Is this repo spine-managed, and is the spine intact (lib/spine.sh)¶
Three hooks need to know whether the repo in front of them has a steer-managed
/spec spine before they say anything: check-unmanaged-repo.sh (the bootstrap
nudge), orient-session.sh (the SessionStart orientation line) and
check-write-nudges.sh. They all get the answer from one helper,
hooks/lib/spine.sh — also sourced by the bundled scripts/scan-spine-state.sh
and scripts/workspace-snapshot.sh, which is how the skills that need the state
(/steer:setup, /steer:sync, /steer:next, …) reach it: a skill dot-sourcing
this file directly cannot pre-approve the call, and check_skill_helper_sourcing
now fails the build on it. Its only dependency is lib/repo-root.sh, so it stays
usable on the hook hot path.
steer_spine_state <repo_root> prints exactly one of four words:
| State | Means | Consequence |
|---|---|---|
unmanaged |
no spec/ directory |
nudge toward /steer:init / /steer:adopt |
foreign |
spec/ exists but no spec/.version |
not a recognized steer spine — a shorter /steer:adopt offer instead of the full card, not silence |
damaged |
spec/.version present, a required artifact missing |
nudge toward repair / /steer:sync |
managed |
spec/.version + every required artifact present |
silent |
Two things about that classification are load-bearing:
spec/.version, notspec/, is the ownership marker. A barespec/directory proves nothing — an empty folder, or a foreign OpenAPIspec/, would otherwise silence the bootstrap nudges in a repo that was never bootstrapped.- A polyrepo member's spine is partial by design. Product-level artifacts live
once in the workspace repo, so a member (detected by
spec/PRODUCT.md) is checked againstSTEER_SPINE_REQUIRED_MEMBER— just the pointer — instead of the fullSTEER_SPINE_REQUIRED. Without that split every member would reportdamagedand/steer:syncwould "repair" it by reinstalling the very product-level files the topology exists to de-duplicate, recreating the split-brain spine.
The action history is deliberately absent from STEER_SPINE_REQUIRED. It is a
directory (spec/history/) whose legacy single-file shape is still valid, so it needs
an either-or presence test rather than a file-existence check: a repo that predates
that migration is structurally fine, just older, and reporting damaged would fire
the repair nudge on every such repo. A migrated repo has both — the directory plus the
frozen archive — which also passes. The migration itself is carried by the
MIGRATIONS.md ledger, not by the spine check.
Version drift — a spine older or newer than the installed plugin — is
intentionally not decided here. /steer:sync and /steer:next own that semver
comparison; this helper answers only the structural question so the always-on hooks
stay fast and dependency-free.
Which rules apply, and to which repo (lib/scope.sh)¶
Every scope decision a hook makes — which always-on rules to inject, whether the
issue-first nudges apply, which repo a tracker write belongs to — comes from
hooks/lib/scope.sh. It is sourced by every SessionStart and PreToolUse hook,
so it stays POSIX sh with no jq and no network.
steer_inject_when_ok <token> <root> is the entry point for rule scoping: a rule
whose first line is <!-- steer:inject-when=<token> --> is injected only when the
predicate holds. Tokens compose with | for OR — the rule injects when any
listed predicate holds (the one shipped composite is 52-deployment's
has-iac|has-apps). The predicates are
tracker-github, has-infra, has-iac, has-apps, has-compose,
code-project, polyrepo, has-workspace-manifest and has-product-pointer.
An unknown token fails open (injects), so a typo'd marker can never silently
drop a rule from the always-on context.
Four helpers resolve polyrepo topology:
steer_polyrepo_role <root>— printsworkspacewhenspec/workspace.ymlis present,memberwhenspec/PRODUCT.mdis, and nothing in a single repo.steer_workspace_path <root>— prints the optional relative path to a local workspace checkout, read fromspec/PRODUCT.md'sworkspace.path. It is scoped to theworkspace:block, so an unrelatedpath:elsewhere in the pointer is never mistaken for it, and an unresolved[...]placeholder counts as absent. This is the raw reader; callers wanting a usable checkout use the next one.steer_workspace_root <root>— the local workspace checkout a member can actually read the spine from, or non-zero meaning "use the GitHub gateway". It adds the two tests the raw path lacks, both of which were silent failures: a relative path is anchored onsteer_primary_worktree(so it survives a linked worktree), andspec/workspace.ymlmust be present at the resolved path — a directory that merely exists is not a workspace. Without the second test thepath: ..the member template recommends resolved, inside a worktree, to a real but empty.claude/worktreesdirectory, which satisfied an existence check and made every product-level spec read as absent.steer_tracker_repo <root>— the tracker's declaredrepository:value. In a member this is deliberately never that member's own repo, which is why closing refs across repos need the cross-repo form.
A fifth helper, steer_tracker_is_github, is the behaviourally largest — it decides
whether the issue-first rules and nudges apply. In a member there is no local
spec/tracker.md, so it resolves the workspace's through steer_workspace_root —
and when no local checkout is declared it fails open to inject. That fail-open is
what turns issue-first on in a polyrepo member (rule 36-issue-first, plus
check-write-nudges.sh, check-bash-actions.sh and reconcile-issue-first.sh):
the alternative, treating an unreachable tracker as "no tracker", silently
disabled the tracker discipline in exactly the repos where all the code lives.
steer_work_mode <root> separates a code project from a knowledge-work folder;
in knowledge mode inject-standards.sh skips every conditional rule and keeps only
the unmarked always-on core.
hooks/lib/ is exempt from the docs-impact gate
check_docs_impact.py does not flag changes under hooks/lib/, so the four
lib/*.sh sections above (json.sh, repo-root.sh, spine.sh, scope.sh) are
maintained by hand — a behavioural change to any of them will not be caught by a
gate. Update them in the same PR as the change; see
Documentation.
Surfaces without hooks¶
Claude Code (CLI, IDE extensions, Desktop Code tab) and Cowork run hooks;
the Desktop Chat tab and claude.ai web chat do not. On those chat-only
surfaces, load the rules manually with /steer:standards. See
Installation and
Known limitations.
GitHub Copilot CLI has its own hook harness; the plugin ships a parallel
hooks/copilot-hooks.json for it — see
Copilot support.