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
- Trigger: every prompt submission.
- What it does: "when the prompt mentions decisions/ADRs/rulings, inject the decision-skill reminder so the ADR discipline (ADR-0046/0047/0048) is applied."
- Rule: the ADR system — no decision exists outside the ADR log.
SessionStart (8)
ast-freshness-pin.sh
- What it does: pins "the AST mandate AND the evidence it is safe to obey" —
derives at emit time whether
plans/ast-ultimate.jsonis current, so an agent never routes around the toolkit on a staleness suspicion. "SessionStart fires on COMPACT as well, so this survives compaction — and because every number is derived at emit time rather than read from a committed file, it survives compaction true." - Rule: the 2026-08-02 user mandate "ZERO hand-reading of Python source."
inject-codebase-map.sh
- What it does: regenerates the two-tier codebase map (
docs/CODEBASE_MAP.md@import tier +docs/CODEBASE_MAP_FULL.mdon-demand tier) so structure is always in context. The two-tier split exists because "with the full skeleton in the @import, every subagent inherited ~250-300K tokens before its first action — a haiku agent died instantly ('prompt too long') and three sonnet agents died of autocompact thrash." - Rule: never assert a symbol/script does or doesn't exist without the map.
start-ast-daemon.sh
- What it does: warms the per-repo AST daemon so toolkit reads answer from a warm in-memory process. "NEVER blocks session start… The daemon is pure acceleration — serialize_codebase_ast.py always falls back to a local, authoritative computation."
start-architecture-sync.sh
- What it does: kicks off a full architecture sync into the operational KG
(
sync_architecture_to_graph.py --seed --adrs— reference catalog, this project's own architecture, and the wiki ADR corpus), then best-effort syncs the Memgraph semantic twin. Backgrounded; loud SKIP lines, never a failure. - Rule: ruling 8 (as amended) + wave ontology-phase2 ruling 4.
session-overview.sh
- What it does: runs
session overviewsynchronously "so the digest lands in the first-turn context" — a digest, not a gate; enforces its own <30s budget and always exits 0.
worktree-gc.sh
- What it does: garbage-collects finished worktrees (
scripts/worktrees.py gc --apply --quiet). Exists as a file (rather than an inline settings command) because the plugin dispatcher "runs FILES from its own directory and deliberately refuses anything with a slash in it" — a file makes the plugin derivation total.
error-report.sh
- What it does: prints
scripts/error_report.py"so the current red list is in the FIRST turn's context, before anything gets reported as green." - Why (incident): "on 2026-08-09 the spoken answer to 'how many tests are red' stayed at three for most of a session while the artifact on disk held eight, four of them regressions introduced that same day. Remembering to look is exactly the thing that failed, so looking is no longer optional."
reap-orphan-waiters.sh
- What it does: kills polling loops orphaned by finished agents.
"
no-unbounded-wait.shstops new ones being created. This clears the ones already leaked… Measured on this box 2026-08-09: five alive at once, oldest 3 days 4 hours." Deliberately conservative — only acts on positively identified processes.
PreToolUse — Bash (9, all can DENY)
no-xdist.sh
- Blocks: any pytest-xdist invocation (
-n/--numprocesses/--dist). - Rule: "pytest-xdist … deadlocks this suite (~78% stall). Use the threadpool
runner instead." One of the four enforcement layers over
.claude/banned-libraries.txt.
force-ast-toolkit.sh (also registered on PreToolUse Read)
- Blocks: grep/sed/head/cat/Read aimed at a
.pyfile undersrc/,scripts/, ortests/. - Rule: the AST mandate. "Until now the only enforcement was ast-toolkit-hint.sh, which is ADVISORY… Measured across session 260: that hint fired repeatedly and was ignored every time… A hint that is never obeyed is not a mechanism (RUL-014)… So this one DENIES."
single-gate-entrypoint.sh
- Blocks: invoking the battery's gate scripts individually.
- Rule: "the review battery has ONE entry point" —
python scripts/gates.py. "Invoking the gate scripts individually is how a ~25s parallel battery turns into a ~50s serial one, and how gates get silently dropped from a 'full' review because they were typed from memory." Escape hatch: a# gate-debugmarker for genuinely debugging one gate.
force-test-observation.sh
- Blocks: (1) direct pytest without the live-observer plugin; (2) ANY test invocation launched in the foreground.
- Rule: user ruling 2026-07-10, "do not run tests without it — only by force"
(memory rule S201). Background it, then attach:
scripts/observe_pytest.py list/attach <session_id> --follow. "No escape hatch by design."
no-unbounded-wait.sh
- Blocks: polling loops with no termination path
(
until grep -q DONE …; do sleep 5; done). - Why (incident): "Measured 2026-08-09 on this box: five such loops alive at once, the oldest having waited 3 days 4 hours… Two more were spin loops with an EMPTY body burning a core each, which drove load average to 27.6 on a 6C/12T box."
force-trace-intel.sh
- Blocks: hand-rolled
rglob+ regex sweeps for requirement ids. - Why (incident): "renumbering SPEC-001's 280 requirement ids was attempted THREE
times with hand-rolled sweeps, and the first two passes had to be reversed
wholesale… Cost: two full-suite runs (~20 min) plus the reversals" — traceability
is a query (
scripts/trace_intel.py), not a file sweep.
proportionate-test-scope.sh
- Blocks: reflex full-suite runs for changes whose blast radius the oracle
(
scripts/affected_tests.py) can bound. "It defers to the oracle" — including the oracle's ownescalate: trueverdict when it CANNOT bound a change. - Drift with the oracle (derived from the code, not measured by a run). The hook
(last changed
f4b65936e, 2026-09-23) still allows the full suite only whenresult.get("escalate")is truthy. Since2669f4882(2026-10-21, "the escalate arm dies") the oracle printsjson.dumps(asdict(result))of anAffectedResultwhose fields areverdict,reasons,tests,untested,rowsandchanged— noescalatekey. So the hook's "full suite is SANCTIONED" arm can no longer fire; a change the oracle names as needing the full suite falls through to the deny paths.
force-code-intel.sh
- Blocks: recursive greps with structural patterns aimed at governed trees ("who imports this", "who calls this").
- Rule: "structural questions are deterministic QUERIES, not recursive greps" — a grep "misses re-exports, aliased imports, conditional imports and dynamic references, and it cannot distinguish a mention in a docstring from a real edge."
force-graph-query.sh
- Blocks: ad-hoc bolt-driver one-liners against the graphs.
- Why (incident): each one-liner re-decides endpoint/database/MVCC-retry/routing
"and gets at least one wrong: … the 2026-08-05 'blank message_id' P1 was exactly
this, an ad-hoc query hitting the prod kernel on localhost while the suite used the
test fleet." Use the seams (
scripts/graph_query.py, the repo adapters).
PreToolUse — Write|Edit|MultiEdit (3)
no-doubles-py.sh
- Blocks: writing a
.pyfile that introduces a test double (mock/fake/stub). Reconstructs the full post-edit file, strips strings/comments via tokenize, reuses the repo's_MOCK_PATTERNS. Fail-closed: "Measured 2026-08-08: this guard was the last of the PreToolUse fleet still failing open." - Rule: RUL-002 — real infrastructure only.
no-banned-deps.sh
- Blocks: adding a banned dependency to
pyproject.toml(quoted requirement strings only — a banned name in a TOML comment is allowed). - Rule:
.claude/banned-libraries.txtis "the single source of truth for forbidden dependencies, and four layers read it."
force-session-cli.sh
- Blocks: direct writes to
context.json/context.md. - Rule: "session state is written by the session CLI, never by hand" — the CLI maintains invariants (ids, ordering, goal linkage, close checklist); "a hand-write updates one file, skips the invariants, and leaves the mirror stale… the damage surfaces a session later, attributed to nothing."
PreToolUse — Agent (1)
require-design-before-build.sh
- Blocks: dispatching
builder/wirer/refactorersubagents with no ratified design (no contract, noDESIGN_WAIVER: <reason>line). - Rule: RUL-022 "design before build" — "implementation subagents … were being dispatched straight from prose briefs with no ratified design, hand-writing structure that generation would have produced correctly." Analyst/auditor/reviewer/ test-writer/Explore/Plan are exempt ("gating them would deadlock the workflow").
PreToolUse — Workflow (1)
workflow-default-sonnet.sh
- Blocks: workflow
agent()calls that silently inherit the main-loop model. - Why (incident): "Measured 2026-08-05 (session 260): two workflow scripts were
authored with no
modelon any agent() call. Result: 58 agents, 100% claude-opus-5[1m], 10.4M subagent tokens, for fan-out work … that is Sonnet-shaped."
Stop (5 — audit the turn that just ended)
forbidden-doubles-stop.sh
- Blocks: a turn that wrote a test double into Python source (unconditionally)
or named one in prose outside the closed descriptive forms. Fires at most once per
turn (
stop_hook_activeloop protection).
no-guessing-stop.sh
- Blocks: hedged turns — "likely", "probably", "seems to", "I assume" — and
instructs: "go RESOLVE the question instead of guessing." Word list:
.claude/weasel-words.txt. Rule: RUL-017. Fenced code and quoted lines are stripped first (tool output legitimately prints "Likely cause:").
no-preexisting-excuse.sh
- Blocks: disposing of a defect by labelling it "pre-existing" / "unrelated" / "already failing". Rule: RUL-024 — "'pre-existing' is a diagnosis, not a disposition." Demands fix it, name it as a blocker with a recommendation, or prove it unreachable.
no-offer-instead-of-fix.sh
- Blocks: offering to fix a named defect instead of fixing it ("I'll fold them
in if you want"), and self-authorised deferral ("I'll fix the failing test in a
separate PR"). Rule: RUL-006 fix-on-sight — the ruling text is embedded verbatim in
the hook so
rulings_lintcan verify the pairing; "the offer WAS the violation."
stop-timestamp.sh
- What it does: stamps every turn-end with wall-clock time (Mohamed's 2026-08-05 requirement: reports must be "auditable against real wall time without asking"). Never blocks.
PostToolUse — Write|Edit|MultiEdit (2)
ast-incremental-refresh.sh
- What it does: a
.pychange refreshes the AST snapshot incrementally, immediately — "the event that invalidates the snapshot is the same event that refreshes it." - Since ADR-0097 (
c7f5b2a18, 2026-10-13, "post-edit hooks stop re-serializing"): when a liveorchestrata-ast-daemon@owns the snapshot, the hook only tells it "this file changed" over the socket and exits — "no serializer process, no 64s of CPU, no second writer." The incrementalast_live_sync.py --oncepath is the fallback when the daemon does not answer with a 2xx. - Why (incident): "Measured 2026-08-23, minutes after the guard fleet went live: verdict: stale… Every structural query after that point was served from the old parse, silently. The refresh existed and was HEALTHY — ast_live_sync.py was running against … a DIFFERENT checkout."
typecheck-on-write.sh
- What it does: runs full-scope mypy on write — "the EARLIEST mypy layer."
- Why (incident): "Until 2026-08-07 mypy ran in exactly one place: CI's lint job…
a stale
.mypy_cachecould report 0 while the tree was red — which is precisely how 16 errors across two files reached CI while the local run said 'Success: no issues found'. There was never a cost reason for that. Measured on this repo:mypy --cache-dir .cache/mypy src/→ 1.166s (all 899 files, warm)."
Wiring and tests
- Registration:
.claude/settings.json(31.shregistrations, 30 distinct guards, across 5 events — re-counted 2026-10-23; the 2026-08-29 count read "30 across 6 events"). Thewiringgate's W1 check fails a hook file added here without a settings registration; theplugin-driftgate fails a registered hook not mirrored into the plugin. - Tests: each guard has a dedicated test module under
tests/scripts/ortests/claude/— e.g.test_no_xdist.py,test_force_ast_toolkit.py,test_force_test_observation.py,test_no_unbounded_wait.py,test_no_doubles_py.py,test_no_banned_deps.py,test_no_guessing_stop.py,test_no_preexisting_excuse.py,test_no_offer_instead_of_fix.py,test_forbidden_doubles_stop.py,test_require_design_before_build.py,test_workflow_default_sonnet.py,test_single_gate_entrypoint.py,test_proportionate_test_scope.py,test_force_code_intel/test_force_graph_query(seetests/scripts/),test_reap_orphan_waiters.py,test_session_start_hooks.py,test_ast_freshness_pin.py.
Typical failures and how to fix them
- A PreToolUse deny you did not expect. Read the hook's message: most name the
sanctioned tool (the AST toolkit for
.pyreads,scripts/gates.pyfor the battery,observe_pytest.pyfor tests,trace_intel.pyfor requirement ids). Use that tool; do not reword the command to slip past the pattern. - A guard that fires on text inside a quoted string or heredoc. The Bash guards match the command line, so a forbidden token inside a commit message or a heredoc trips them too. Move the text into a file written with the Write tool.
- A Stop block for a hedge word or "pre-existing". Resolve the question or fix the defect, then end the turn again (RUL-017, RUL-024, RUL-006).
- A guard that never fires. In a session launched outside the repo, the repo's
.claude/settings.jsonis not loaded (the_dispatch.shincident above); the guards then exist only if the plugin is installed. Check the plugin before assuming a guard is active. - A builder dispatch refused.
require-design-before-build.shwants a contract or a standaloneDESIGN_WAIVER: <reason>line in the brief (RUL-022). - A full-suite run denied although the oracle cannot bound the change. See the
proportionate-test-scope.shdrift note above.
Related
- Gates master index
- Development workflow
- dispatch-brief and test-phase-cap
- RUL-014 — A ruling that exists only in prose is incomplete work
- RUL-035 — hooks at user level only and ADR-0070
- ADR-0097
- RUL-022 — Design before build
- RUL-040 — Scope measured at symbol granularity, never full suite
- wiring — the battery gate that refuses an unregistered hook
- plugin-drift — the battery gate that refuses a stale plugin mirror of these hooks
- Process-level gates — RUL-022 and the session-honesty Stop hooks viewed as process law