Orchestrata Ultimate › Gates › .claude session hooks

Gate family: .claude session hooks

At a glance

Purpose Refuse or redirect an action inside a Claude Code session at the moment it happens: before a tool call (PreToolUse), after a write (PostToolUse), at session start, at turn end (Stop) and on prompt submission.
Where it runs The Claude Code harness, from .claude/hooks/*.sh as registered in the repo's .claude/settings.json, and from the same files re-emitted into the plugins/orchestrata plugin through _dispatch.sh.
Trigger The hook event and matcher of each registration (table below).
Exact command Each registration runs one .sh file; the plugin runs it through _dispatch.sh <name>.
Blocks PreToolUse guards deny the tool call; Stop guards block the stop and send an instruction back; SessionStart, UserPromptSubmit and PostToolUse hooks inject context or refresh state and do not block.
Ruling / ADR RUL-014 (a hint never obeyed is not a mechanism) and the rulings named per hook below; placement: ADR-0070 / RUL-035; AST refresh: ADR-0097.

Census, re-counted on wt-MAP-PRECOMMIT on 2026-10-23: 32 .sh files under .claude/hooks/ (30 guards plus _dispatch.sh and _payload.sh) and two .py files. .claude/settings.json (last changed 21c8d6e47, 2026-08-29) holds 31 .sh registrations in 9 matcher groups across 5 events — UserPromptSubmit 1, SessionStart 8, PreToolUse 15 (Bash 9, Read 1, Write|Edit|MultiEdit 3, Agent 1, Workflow 1), Stop 5, PostToolUse 2 — that is, 30 distinct guards with force-ast-toolkit.sh registered twice. The earlier count of "6 events" below was a miscount of matcher groups.

Placement tension. ADR-0070 / RUL-035 (2026-10-06) say hooks are registered at the user level (~/.claude or the orchestrata plugin) and never in a repo's .claude/settings.json. On this branch the repo file still carries all 31 registrations, and ~/.claude/settings.json registers only Stop and SubagentStop hooks. Whether the plugin is installed and enabled in the running harness was not measured in this run.

The repo carries 32 .sh files under .claude/hooks/ (verified 2026-08-29): 30 registered guards plus 2 shared plumbing files (_dispatch.sh, _payload.sh). Two additional Python hooks live beside them (install_secret_guard.py, permission_no_hang.py) — they are outside this page's .sh census but part of the same fleet.

These are the gates that fire at the moment of action inside a Claude Code session: before a tool call runs (PreToolUse — these can DENY), after a write lands (PostToolUse), when a session starts (SessionStart), when a turn ends (Stop — these can block the stop and send an instruction back), and when a prompt is submitted (UserPromptSubmit). Registration lives in .claude/settings.json; the same guards are re-emitted into the plugins/orchestrata plugin so they hold in sessions launched outside this repo (see the plumbing section).

The recurring theme, in the hooks' own words: "a hint that is never obeyed is not a mechanism (RUL-014)" — most of these exist because an advisory version was measured being ignored.


Shared plumbing (2 files, not themselves gates)

_dispatch.sh — repo-scoping entry point for plugin delivery

Header, verbatim incident: "These guards used to be registered only in <repo>/.claude/settings.json. Claude Code loads PROJECT settings for the session's own project directory and nothing else, so a session launched from $HOME never read that file and NONE of its 25 registrations existed. Measured 2026-08-23 with a sentinel hook… 25 of 25 guards — the AST mandate, no-xdist, no-doubles-py, no-banned-deps, the gate battery, require-design-before-build, all five Stop guards — had been dark in every $HOME-rooted session while still looking installed." The dispatcher runs guards from its own directory (refusing any name with a slash) so the plugin can carry them everywhere.

_payload.sh — fail-closed payload reader

Header: every guard used to read its JSON fields with a jq … || true construction that "fails OPEN. On a host without jq the substitution yields an EMPTY field, so no pattern matches, the guard permits everything, and it still exits 0 and looks healthy. Measured 2026-08-08 on the self-hosted GitLab runner, whose job image carries no jq: all five DENY cases in test_force_test_observation.py came back 'allow'." This shared reader makes "cannot parse the payload" a block, not a pass.


UserPromptSubmit (1)

decision-adr-trigger.sh

SessionStart (8)

ast-freshness-pin.sh

inject-codebase-map.sh

start-ast-daemon.sh

start-architecture-sync.sh

session-overview.sh

worktree-gc.sh

error-report.sh

reap-orphan-waiters.sh

PreToolUse — Bash (9, all can DENY)

no-xdist.sh

force-ast-toolkit.sh (also registered on PreToolUse Read)

single-gate-entrypoint.sh

force-test-observation.sh

no-unbounded-wait.sh

force-trace-intel.sh

proportionate-test-scope.sh

force-code-intel.sh

force-graph-query.sh

PreToolUse — Write|Edit|MultiEdit (3)

no-doubles-py.sh

no-banned-deps.sh

force-session-cli.sh

PreToolUse — Agent (1)

require-design-before-build.sh

PreToolUse — Workflow (1)

workflow-default-sonnet.sh

Stop (5 — audit the turn that just ended)

forbidden-doubles-stop.sh

no-guessing-stop.sh

no-preexisting-excuse.sh

no-offer-instead-of-fix.sh

stop-timestamp.sh

PostToolUse — Write|Edit|MultiEdit (2)

ast-incremental-refresh.sh

typecheck-on-write.sh


Wiring and tests

Typical failures and how to fix them