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, so a slow hook cannot stall a
session - with one carve-out that is not steer's to set:
A plugin cannot raise the SessionEnd budget
SessionEnd hooks share a 1.5-second budget, and "Timeouts set on
plugin-provided hooks don't raise the budget" - only a timeout in a user's
own settings file does, to at most 60s. The "timeout": 60 steer declares on
its SessionEnd registration is therefore inert, and a hook cancelled at
the budget has its output discarded and its work unfinished. That makes the
SessionEnd teardown below best-effort, not a guarantee. A dev who wants
it to fit can raise the budget themselves:
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=5000 claude. WorktreeRemove is
unaffected - it takes the ordinary command-hook timeout, so its 60s is real.
(upstream reference)
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:setup 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, plus the lifecycle events CwdChanged,
SessionEnd and WorktreeRemove). 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);
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 × N parts<br/>injects rules/*.md, each part under the 10k-char cap]
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]
cd[check-comment-density.sh<br/>comment-density notice]
end
subgraph Stop
reconcile[reconcile-issue-first.sh]
end
subgraph Lifecycle
cwd[CwdChanged<br/>check-worktree-trust.sh]
send[SessionEnd<br/>on-session-end.sh]
wtr[WorktreeRemove<br/>on-worktree-remove.sh]
end
SessionStart¶
Since the session-checks consolidation, hooks.json registers three
SessionStart scripts: the rule injection (one command per part), 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\|fork |
Delivers rules/*.md (lexical order) into session context in parts: Claude Code caps one hook command's stdout at 10,000 characters and silently replaces anything longer with an "Output too large" pointer, so hooks.json registers the script N times (inject-standards.sh <k> <N>), every invocation computes the same deterministic partition of the eligible rules and emits only part k - each under the cap, arriving as its own SessionStart block in any order (the headers say so; the numeric rule prefixes give the sequence). An unused part emits nothing. If the eligible rules do not fit the registered parts, whole rules are dropped from the tail and the last part carries an in-band RULESET INCOMPLETE notice naming them; check_context_budget.py runs every part of every profile pre-merge and fails on any drop or any part over the cap. 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, the OpenSpec backend where openspec/ carries a structural marker, the org pack's stack and commands rules where policy/org.yml says pack: e22 (which an absent file also means), its infra-stack rule where that and IaC hold (has-iac&org-e22), and autonomous loops only where policy/automation.yml declares loops: true - 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. Host-aware (steer_hook_host in lib/json.sh): when Copilot Chat in VS Code runs this hooks.json directly - recognised from its payload shape alone (snake_case SessionStart with model and timestamp, no permission_mode), since the Copilot CLI hook variant that used to declare itself with STEER_HOOK_TARGET retired with hook parity - part 1 emits the whole eligible ruleset as one JSON object carrying hookSpecificOutput.additionalContext (the key VS Code reads) plus a top-level additionalContext for any surface that prefers it, encoded losslessly by steer_json_string, and every other part stays silent, because Copilot has no per-command cap but keeps only the last hook's context. This is incidental, not a parity promise: hook enforcement is guaranteed on Claude Code only. A Claude Code payload (permission_mode) or any unrecognised shape keeps the raw parted output. |
session-checks.sh |
startup\|resume\|clear\|fork |
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 one summary line split by gate - block work now vs block a later transition (each question's required_before: gate against the feature's cleared Status, via lib/lifecycle.sh), non-blocking, malformed (a Q-NNN block missing status/impact), and questions still in the retired - [ ] format - then names at most the three most urgent questions by id, title, file, owner, and age (blocking-now first, then stale, then oldest), and ends with one remedy: /steer:setup sync when bare checkboxes remain (its migration converts them), triage in /steer:spec questions when most are unowned, /steer:spec questions bundle when most are the PO's. Never a line per file or per question, so a large backlog does not repeat in full every session. Parsing lives in lib/questions.sh, shared with the legacy-checkbox converter so both see the same scope; only status: open and status: investigating count, and <!-- steer:placeholder --> seeds are skipped. It escalates stale ones - an un-promoted question open more than 14 days if blocking, or 60 if non-blocking (14 once its feature is live), aged from its created: date or one whole-file git blame when absent. The escalation fires on every tracker, but its promotion advice is tracker-aware: the owners:-map auto-assign and the filed spec-question issue are GitHub Issues operations, so on any other tracker (Jira, Linear, none-yet, none declared) the notice names the manual path - open the work item in the declared tracker, then write its ref into the question's tracker: field. Resolution is fail-open via steer_tracker_is_github, so an ambiguous polyrepo member keeps the GitHub wording. It also warns when a retired spec/SPEC-QUESTIONS.md is still present; /steer:spec questions heals it as a hard gate before its sweep. |
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:setup - the one front door; it detects the repo state and routes to /steer:setup init for a greenfield repo or /steer:setup adopt for substantial existing code). Feature code still requires the bootstrap first - spec-only work is the one sanctioned exception. An OpenSpec repo (openspec/ with a structural marker) short-circuits all of this and gets a short OpenSpec orientation instead - see the spine-state table below. 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 either graduate to PR flow via /steer:setup protect or, when the repo deliberately stays single-dev on trunk, record a graduation waiver via /steer:setup protect waive - and notes that trunk pushes are gated until one of the two happens. Offline (the collaborator-count signal is left to /steer:audit//steer:setup protect); silent in pr-flow, with no signal, once graduated, or when a waiver (<!-- steer:graduation=waived --> in CLAUDE.md) is recorded - the shared detector reports no signals for a waived repo. |
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. It also reports mise trust itself failing, and confirms when it did inherit the trust. 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\|fork |
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|fork 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). |
SubagentStart¶
| Hook | Matcher | Role |
|---|---|---|
inject-subagent-digest.sh |
^general-purpose$\|^steer:steer-reviewer$ |
Hands a spawned subagent the ~1.6 KB digest of the standards that bind it. A subagent does not inherit the session's SessionStart context - the Agent tool starts a fresh conversation - so without this a general-purpose subagent editing code during /steer:work ran with none of the scope, testing, secrets or gate rules, and steer-reviewer audited a slice against standards nobody handed it. The context-hygiene standard steers heavy work to subagents, so that was not a marginal share of steer-governed work. A digest, not the ruleset: the full payload is ~60,000 characters and would be paid per spawn, defeating the point of delegating for a fresh window; the digest carries only what governs a bounded piece of work (stay in scope, report what you find, follow local patterns, test what you change, no secrets, never merge or deploy) and names rules/ for anything more specific. Emitted as hookSpecificOutput.additionalContext, the only output this event honours - it cannot block a spawn. The matchers are the safety boundary: Explore and Plan read and propose, pay nothing today, and are deliberately excluded; the plugin-scoped reviewer id is anchored because the colon puts it on the regular-expression path. The script re-checks agent_type itself rather than trusting the matcher, so widening one in hooks.json cannot silently start injecting into every subagent. Fail-open: a missing digest file or an empty payload is silence, never a failed spawn. A hook test caps the digest at 2,048 bytes, so it cannot quietly grow back into the ruleset. |
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 for High-risk work and the six value cases (the message names both the cases and the carve-out: a Trivial change, an untracked fix, spec, docs and lockfiles let the PR be the work record), only in GitHub-tracked repos - it fires on the first implementation-class write and cannot know whether an issue exists, or which class the change is: it names the rule and lets the agent judge. 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:setup 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:setup protect (graduate) and /steer:setup protect waive (record that single-dev trunk is deliberate) - 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), solo-trunk repos with a recorded graduation waiver (the shared detector honours the steer:graduation=waived marker), 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. 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:work 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 - it rewrites the file rather than returning anything the model reads, so there is nothing for a Copilot envelope to carry. |
check-comment-density.sh |
Write\|Edit\|MultiEdit |
Makes the Code comments rule (03-output.md § Code comments) visible at the moment it is broken: reads the just-written file from disk (files under 20 non-blank lines are exempt) and, from a fifth of its non-blank lines up, emits an advisory additionalContext carrying the ratio, once per file per session. Advisory only - there is no decision and no block: on PostToolUse the write has already landed, so a block tier bought loudness rather than enforcement, and the PR review is the wall. (It had one, above a third, re-firing while the file stayed over; it retired with the runtime style tiers in 6.5, and with it the escape hatch that had to parse every comment syntax correctly - #576.) A file-level steer:allow-comments <reason> in a # or // comment silences the notice; the reason is required, so a bare marker suppresses nothing (unlike the per-line # steer:allow-pin, density is a property of the whole file, and there is no legacy alias). Counts only a line's leading marker - # for shell/Python/TOML/YAML/Dockerfile/HCL, // and block-comment bodies for the C-family and TypeScript/JavaScript, -- for SQL/Lua - so a # inside a string never counts; Markdown, JSON, HTML and unknown types are skipped, and so is dependabot.yml, whose per-stack blocks are commented-out code by necessity. A shebang and a linter/type-checker directive (# shellcheck, # noqa, # type:, # ruff:, // eslint-disable, // biome-ignore, // @ts-expect-error, ...) are machine-read instructions rather than prose, so they count toward the line total but not as comments. Markers live in TMPDIR; the hook always exits 0 and is exempt in the plugin's own source repo. |
Stop¶
| Hook | Role |
|---|---|
reconcile-issue-first.sh |
End-of-turn reconciliation of issue-first bookkeeping - it surfaces implementation-affecting changes left at end of turn on a branch with no issue reference, restating that High-risk work and the six value cases need an issue while everything else lets the PR be the work record. Like the write nudge it reads paths, not change class. 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:setup sync branch (feat/sync) the same way the point-of-action nudge does - silent unless app source also changed. Caps its per-file path-classification scan (fail-soft - nothing governed found by the cap -> silent), so a huge first-turn dirty tree cannot approach the 30s Stop timeout. |
Lifecycle events¶
Three registrations that are not gates and not context injection: they run
because the harness has told steer that something in the session's environment
changed. None of the three carries decision control, so none of them can
block what it observes - a session ending, a worktree being removed, a directory
change. SessionEnd and WorktreeRemove discard their JSON output fields;
CwdChanged does not - it discards only continue.
What they can report differs:
| Event | On exit 2 |
JSON output | So |
|---|---|---|---|
SessionEnd |
Shows stderr to the user | Discarded | A channel exists; a teardown is not worth interrupting a shutdown for, so steer exits 0 |
WorktreeRemove |
Failures are logged in debug mode only | Discarded | Genuinely no user-facing channel |
CwdChanged |
Shows stderr to the user | systemMessage is honoured - shown as a brief terminal notification in interactive sessions; it does not reach the SDK message stream |
Channels exist; steer's notices go to stdout, which on this event goes to the debug log - see the trust-hook row below |
| Hook | Event | Role |
|---|---|---|
check-worktree-trust.sh |
CwdChanged |
The same script the SessionStart roster runs, registered a second time. At SessionStart it can only cover a session that started in a worktree; any move of the session's working directory into a worktree fires no SessionStart, so the trust step was silently skipped there - upstream's example is Claude running cd, and the EnterWorktree tool is the worktree-specific form. (Scoped deliberately: a subagent with isolation: worktree does not move the session's cwd, so it is not covered by this registration.) Re-running is free: the first cd into a worktree inherits the trust and every later one finds it already trusted and exits before changing anything; a plain checkout never reaches mise at all. Deliberately not WorktreeCreate, which looks like the precise event but runs before the worktree exists on disk - and mise trust -C <dir> refuses a directory that is not there yet. Caveat on this registration: the mise trust side effect works on both paths, but the script writes its human-facing notices to stdout, and CwdChanged is not one of the four events whose stdout the harness surfaces - so mid-session they land in the debug log. Surfacing them is an open change, not shipped. |
on-session-end.sh |
SessionEnd (logout\|prompt_input_exit\|other) |
Only in a linked worktree: attempts that worktree's docker:down (ws:docker:down in a workspace root) to stop its containers and free its ports when the session really ends. Best-effort - see the 1.5s budget above: mise tasks ls plus mise run ... docker:down will often not finish inside it, so do not rely on this to have stopped anything; WorktreeRemove is the dependable half. Volumes are kept - a session ending is not the worktree ending, and the dev may still be in that checkout from a plain terminal. Never matches clear or resume: those continue the same working session, which is why the rules are re-injected for them. Silent in a plain checkout, without a compose file, without mise/docker on PATH, in a repo that pruned the docker:* tasks, and when STEER_NO_WORKTREE_TEARDOWN is set to any non-empty value. |
on-worktree-remove.sh |
WorktreeRemove |
The full teardown - docker:clean (down + volumes + orphans, ws:docker:clean in a workspace root) - because the checkout itself is about to be deleted and its per-worktree volumes become unreachable regardless. Acts on the payload's worktree_path, never on cwd: the tree being removed is often not where the session is sitting. WorktreeRemove carries no decision control, so the hook cannot stop the removal or report a problem; it exits 0 whatever happens - steer is not the gate, least of all on someone else's cleanup. Same gating and same opt-out as on-session-end.sh. |
The two teardown modes are the same distinction rules 45-delivery § Parallel worktrees and
50-done § End-of-session checklist draw, now attempted automatically rather than requested: stop
what is running when a session ends, remove the data only when the thing that
owned it is being deleted. Both share hooks/lib/worktree-lifecycle.sh. Only the
WorktreeRemove half is dependable; the SessionEnd half is opportunistic, so
the rules still ask the agent to stop what it started.
A worktree deleted by another tool - Orca, Conductor, git worktree remove -
never raises WorktreeRemove. /steer:setup worktrees installs that tool's own
teardown hook and sweeps the stacks it already orphaned.
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,check-comment-density); - 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:setup 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 six words. Four describe
a native spec/ spine:
| State | Means | Consequence |
|---|---|---|
unmanaged |
no spec/ directory |
nudge toward /steer:setup, which routes to /steer:setup init / /steer:setup adopt |
foreign |
spec/ exists but no spec/.version |
not a recognized steer spine - a shorter /steer:setup adopt offer instead of the full card, not silence |
damaged |
spec/.version present, a required artifact missing |
nudge toward repair / /steer:setup sync |
managed |
spec/.version + every required artifact present |
silent |
Two further states cover an OpenSpec repo, and they are tested first,
before the spec/ ladder above - such a repo may have no spec/ directory at
all, which the ladder would read as unmanaged and answer with the greenfield
bootstrap card, pushing an init that would lay a competing spec/features/**
spine beside openspec/:
| State | Means | Consequence |
|---|---|---|
openspec |
an openspec/ spine and openspec/steer/tracker.md |
silent - unless pre-fold artifacts linger in spec/, which points at /steer:setup sync |
openspec-setup |
an openspec/ spine, steer's side not laid down yet |
short OpenSpec orientation naming /steer:setup; never the greenfield card or the foreign adopt offer |
The OpenSpec test is openspec/project.md, openspec/specs/ or
openspec/changes/ - a bare openspec/ directory is not enough, mirroring this
helper's refusal to read a bare spec/ as a spine. Making these states
rather than a bypass in front of one caller is what keeps /steer:setup doctor,
/steer:setup sync and /steer:audit answering the same question the same way.
Where the three steer artifacts live¶
OpenSpec models none of an ADR log, a tracker declaration or an app guide, so
steer keeps all three - on an OpenSpec repo, under openspec/steer/:
| Artifact | Native repo | OpenSpec repo |
|---|---|---|
| ADRs | spec/decisions/ |
openspec/steer/decisions/ |
| Tracker declaration | spec/tracker.md |
openspec/steer/tracker.md |
| App guide | spec/app/ |
openspec/steer/app/ |
The steer/ segment is deliberate: openspec/ is written by a third-party CLI
(openspec update regenerates openspec/AGENTS.md wholesale; archive
relocates whole change directories), so a flat openspec/decisions/ would be one
upstream release away from a collision. Both paths resolve through
steer_tracker_file / steer_decisions_dir / steer_app_docs_dir in
hooks/lib/scope.sh - the single definition every hook and script reads, so
nothing can disagree about which file declares the tracker. steer_tracker_rel
gives the repo-relative form the nudges name in their user-facing text, so a
message never points at a path the repo does not have. Repos on the pre-fold
shape are carried across by the MIGRATIONS.md ledger entry that /steer:setup sync
applies.
The app guide matters here for a reason that is not cosmetic:
scan-capabilities.sh reports app-knowledge-docs from that path and
/steer:setup sync repairs an absent capability by creating the file - left
pointing at spec/app/, sync would recreate a stray spec/ on the repo that
just moved out of it.
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:setup 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:setup 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 AND, which binds loosest, so every arm must hold -
12-stack-infra's has-iac&org-e22 is the org pack's IaC stack, delivered only
to an IaC repo that follows the pack. | for OR is also supported (the rule
injects when any listed predicate holds) but no shipped rule uses it
since the 6.6 rule diet folded the deployment rule, which carried
has-iac|has-apps, into the code-project rule 45-delivery. The predicates are
tracker-github, has-infra, has-iac, has-apps, has-compose,
has-openspec, code-project, org-e22, automation-optin, 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. automation-optin is the single
deliberate exception - it fails closed, because rule 53-autonomous-loops
governs machinery a repo only has once it declares policy/automation.yml with
loops: true, so injecting it by default would charge every consumer for a
feature almost none of them run.
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. The same applies to the libs this page does not section: classify.sh,
version-policy.sh, graduation.sh, lifecycle.sh, report-fault.sh and
worktree-lifecycle.sh. 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.
Hook enforcement is guaranteed on Claude Code only. The GitHub Copilot CLI
has its own hook harness and the plugin used to ship a parallel
hooks/copilot-hooks.json for it; that variant retired, so the CLI runs no steer
hooks and takes the standards from the committed
.github/copilot-instructions.md. Copilot Chat in VS Code runs this hooks.json
directly, as a Claude-format plugin, and so picks the hooks up incidentally - with
no parity promise - see Copilot support.