Hooks Reference
The ca, ca-codex, and ca-pi plugins share the same Python guard core and .codearbiter/
activation state. Claude Code consumes the guard’s exit-2 block directly. Codex routes shell and write events
through pre-tool-adapter.py, which returns Codex’s structured deny result with the same gate ID and
feedback. Codex has no Read hook, statusline, or Claude-format transcript-pruning engine; see the
Claude Code + Codex evidence.
Pi composes the shared guard through a parent extension rather than Claude hook
events. Its rich footer is global to interactive parent sessions; the governance
row requires an enabled and affirmatively trusted repository, and rate-window
telemetry is omitted. Execute mode asks before governed mutations or external
side effects. Plan mode is read-only except for the current canonical spec,
plan, and plan ledger. Background jobs are session-only, never restored from Pi
session entries, and an unverified cleanup blocks later launches with a
/ca-doctor direction. These UI and job capabilities are parent-interactive
only and never enter hardened children.
The complete ca-pi adapter currently carries Feature Forge preview status.
Its documented matrix is green and real use is welcome, while broader testing
continues before stable status or a claim of 100% validation.
codeArbiter enforces its gates as Claude Code hooks under plugins/ca/hooks/. Every hook is stdlib-only Python, registered in hooks.json, and runs only in an arbiter-enabled repo (see Enforcement & Security for the activation contract and the fail-loud posture). A blocking hook exits 2; an advisory hook prints a reminder and exits 0.
Every hook is registered twice in hooks.json: once under python3, and once under a python3 -c "" || python fallback. On a stock Windows box that has only the python interpreter, the gates still fire. The two entries each receive their own stdin, so a real block is never swallowed by the fallback.
For the exact, word-for-word message text a hook prints for every gate ID — generated directly from each block()/remind() call site, so it can never drift from the narrative summaries below — see the Hook Gates reference.
Registered Hooks
Section titled “Registered Hooks”| Event | Matcher | Script |
|---|---|---|
SessionStart | (any) | session-start.py |
PreToolUse | Bash|PowerShell | pre-bash.py |
PreToolUse | Write | pre-write.py |
PreToolUse | Edit|MultiEdit | pre-edit.py |
PreToolUse | Read | pre-read.py |
PostToolUse | Write|Edit | post-write-edit.py |
UserPromptSubmit | (any) | prune-transcript.py |
PreCompact | (any) | prune-transcript.py |
session-start.py
Section titled “session-start.py”- Event:
SessionStart. - Script:
session-start.py. - What it does: Injects the orchestrator persona and the startup state.
- Clears the per-session
/ca:devmarker. If a prior session entered/ca:devand ended without/ca:arbiter, it first appends a syntheticBY: session-cleanup | DEV: exitclose line tooverrides.log, so the dev enter/exit trail is never left half-open. - Heals the statusline wiring every session, persisting only on a real change (the wired path is absolute and version-pinned, so a plugin update can leave it stale).
- Injects
ORCHESTRATOR.mdto plain stdout (the reliable injection path for a plugin-scoped hook). - Prints the startup state: stage, blocking
CONFIRM-NNopen questions, and an in-flight task summary. An uninitialized repo is routed to/ca:create-contextor/ca:decompose. - Emits a first-of-day standup briefing (working-tree state, ahead/behind annotated as possibly stale, ff-pull eligibility, prune candidates), gated by a per-day marker.
- Spawns a fully detached
git fetchthat is never awaited, to keep ahead/behind fresh without blocking the hook. - Surfaces an update-available notice (
update available X → Y) when the cached check shows a newer published release than the installed plugin, and spawns a fully detached, best-effort, once-daily refresh (update-refresh.py) that updates that cache off the hot path. The hook only ever reads the cache; the network fetch never blocks the SessionStart injection.
- Clears the per-session
- Why: One always-loaded persona, plus the project state the orchestrator needs to route the first request.
- Fail posture: Non-blocking (always exits 0). All git here is read-only and degrades per-field. A dormant or malformed repo prints a breadcrumb and exits.
pre-bash.py
Section titled “pre-bash.py”- Event:
PreToolUse, matcherBash|PowerShell. - Script:
pre-bash.py. - What it enforces:
- H-00: fail-closed backstop. If the guard itself crashes on an unexpected input, or git cannot be read to resolve the branch/diff state, the operation is blocked rather than allowed through — a guard that cannot determine whether an operation is safe treats it as unsafe.
- H-01: no direct commit/push to the default branch (
main/mastercase-insensitive), including a detached HEAD on a protected tip and protected refspecs (HEAD:main,:main,refs/heads/main,--all/--mirror). The branch is resolved against the repository the git command actually targets (agit -C <dir>composes repeated-Cthe way git does), not the session’s project dir. - H-02: no force-push (
--force,--force-with-lease,--force-if-includes,-f,+refspec). - H-03: no wildcard staging (flag forms
-A/--all/-u/.; argument forms globs, directories, pathspec magic). - H-05: append-only audit logs. Shell truncation/rewrite verbs aimed at
overrides.log/triage.log/sprint-log.md/gate-events.logare blocked. The protected name set is centralized (_hooklib.AUDIT_LOG_BASENAMES) so the shell, Write, and Edit flanks cannot drift. - H-09b / H-10b: crypto/secret commit gate. A commit introducing a sensitive line is blocked unless the
security-gate-passedmarker covers those exact lines (freshness under 30 min and per-line digest coverage). Scans the staged diff plus the worktree diff for-a, in-commandgit add, or agit commit <pathspec>. - H-11: ADRs only via
/ca:adr. Shell redirects/verbs into.codearbiter/decisions/are blocked; reads pass. - H-14: migration review. A commit staging a migration is blocked unless
migration-gate-passedcovers that file’s content digest. - H-18: the activation switch is protected. A shell write that would flip
.codearbiter/CONTEXT.mdoff (arbiter: disabledor broken frontmatter) is blocked, so the gates cannot be silenced from inside the repo they govern. - H-19: gate-pass markers are unforgeable by hand. Shell redirects/verbs naming
security-gate-passed/migration-gate-passedare blocked; only the sanctioned recorder scripts may write them. - H-20: no
--no-verifybypass. A literal--no-verify/-nongit commit(including bundled and attached-value short-flag clusters like-nm, mirroring git’s own parsing) and a literal--no-verifyongit pushare blocked, because that flag skips the.git/hooksgit-enforce backstop.
- Why: This is the load-bearing commit-time gate. The branch and force-push rules keep the default branch PR-only; the crypto/secret/migration gates keep dangerous content out of the committed artifact.
- Fail posture: Blocking (exit 2). Ambiguity resolves closed: a spelling indistinguishable from a destructive one is blocked. H-09b/H-10b and H-14 fail closed when git cannot read the diff or file list (a
Nonesentinel, distinct from an empty diff), and a crash inside the guard itself blocks rather than allows (H-00)./ca:overrideis the sanctioned escape hatch.
pre-write.py
Section titled “pre-write.py”- Event:
PreToolUse, matcherWrite. - Script:
pre-write.py. - What it enforces:
- H-05: a Write is a full overwrite, so any Write to an audit log (
overrides.log/triage.log/sprint-log.md/gate-events.log) is blocked (append with Edit or>>). - H-11: a Write to any
.mdunderdecisions/is blocked unless a freshadr-authoring-activemarker is present (set by/ca:adr). - H-18 / H-19: a Write that would disable the
CONTEXT.mdactivation switch, or a Write to a.codearbiter/.markers/gate-pass token, is blocked (the same integrity rules pre-bash enforces on the shell flank).
- H-05: a Write is a full overwrite, so any Write to an audit log (
- Why: Closes the Write flank of the audit-trail, ADR-authoring, activation-switch, and gate-marker integrity rules.
- Fail posture: Blocking (exit 2).
pre-edit.py
Section titled “pre-edit.py”- Event:
PreToolUse, matcherEdit|MultiEdit. - Script:
pre-edit.py. - What it enforces:
- H-05: on an audit log, MultiEdit is blocked outright (cannot express a verifiable append), an Edit with an empty
old_stringis blocked (it can never be a pure append), areplace_allEdit is rejected outright, and an Edit is admitted only as a strict tail append —new_stringmust equal the current content plus an appended tail, withold_stringoccurring exactly once. This closes the earlier hole where a mid-file insertion or a multi-site suffix rewrite passed as an “append”. - H-11: the same fresh
adr-authoring-activemarker requirement fordecisions/.mdfiles. - H-18 / H-19: the same activation-switch and gate-marker protections as the Write flank.
- H-05: on an audit log, MultiEdit is blocked outright (cannot express a verifiable append), an Edit with an empty
- Why: Closes the Edit/MultiEdit flank; an append-only log accepts only verifiable tail appends.
- Fail posture: Blocking (exit 2).
pre-read.py
Section titled “pre-read.py”- Event:
PreToolUse, matcherRead. - Script:
pre-read.py. - What it does: On a Read of a governed file, assembles a budgeted (150-token ceiling), freshness-gated note naming the decision, control, or spec that governs that path, and delivers it via
additionalContextwhile always allowing the Read. See Concepts: just-in-time context injection for the four-tier governing map.- Searches four tiers in priority order:
security-controls.mdfor security-classified files; an accepted ADR whosegoverns:glob matches the path; an approved spec whose**Governs:**header matches; a provenance enrichment entry whose stored hash still equals the file’s current content. - Each
(session, file)pair is injected at most once. A second Read of the same file in the same session produces no injection. - On a Read of a non-governed file, nothing fires. No git call runs; cost is a single index lookup.
- Searches four tiers in priority order:
- Why: Surfaces the governing context the moment a file opens, without requiring the agent’s session to have already loaded the full doc set.
- Fail posture: Advisory, fail-open (always exits 0). Any error in the governing-map lookup, git call, or budget computation degrades to allow-with-no-injection. A Read is never blocked.
post-write-edit.py
Section titled “post-write-edit.py”- Event:
PostToolUse, matcherWrite|Edit. - Script:
post-write-edit.py. - What it enforces (all advisory):
- H-09 / H-10: crypto/TLS or secret pattern touched; reminds that the commit will block until the gate records a pass.
- H-07: dependency manifest changed; dispatch
dependency-reviewer. - H-12: file governed by an accepted ADR (
governs:glob); route to/ca:reconcileor/ca:adr. - H-15: CI/CD workflow changed; dispatch
security-reviewerbefore merge. - H-16: deployment/IaC manifest changed; same.
- H-17: auth/authorization logic touched (narrow, high-signal patterns).
- H-13: anti-slop prose check for an em/en dash used as a prose separator in a user-facing doc.
- Why: Surfaces a sensitive touch early so the blocking commit-time gate is not a surprise.
- Fail posture: Advisory, non-blocking (
remind, always exits 0). H-12/H-15/H-16/H-17 are advisory because their trigger is non-deterministic (auth) or their damage only lands downstream at merge/apply, not in the commit; the deterministic commit block is reserved for crypto/secret (H-09b/H-10b) and migrations (H-14).
prune-transcript.py
Section titled “prune-transcript.py”- Event:
UserPromptSubmitandPreCompact. - Script:
prune-transcript.py. - What it does: Prunes transcript clutter to extend session lifetime, and emits a cold-miss nudge. The live transcript is only ever touched on the hook path; per-session prune state is recorded for the statusline. On
UserPromptSubmitit also runs an audit staleness check: a non-blocking warning when an active/sprintor/devflow has not appended its expected audit-log line within a bounded window (the completeness companion to the H-05 integrity guards; a warn, never a gate). - Why: Keeps long sessions inside the context budget, and surfaces an audit flow that has gone silent.
- Fail posture: Non-blocking (always exits 0).
Non-Event Scripts
Section titled “Non-Event Scripts”These are not registered hooks. They are invoked by skills and slash commands, or wired into settings.
statusline.py
Section titled “statusline.py”The settings-wired statusline renderer (installed by wire-statusline.py; the rendering concerns are split across thin _*lib libraries behind this entry point). Usage segments (folder, git, model, rate limits, context, tokens, cost, burn) and a compact update-available indicator render everywhere; the arbiter segments (stage, tasks, questions, overrides) render only in an enabled repo, reusing the same activation parser the hooks use. The update indicator reads the same cache the SessionStart notice does and adds no network call. Cost reflects the host’s authoritative total, with a cumulative cost ledger persisted to ~/.codearbiter/ledger.json. Never prints a traceback; every segment degrades rather than breaks. Read-only / display.
git-enforce.py
Section titled “git-enforce.py”The pre-commit / pre-push shim installed into the repo’s own .git/hooks/ (idempotently, at /ca:init and on session start; never overwriting a pre-existing foreign hook). It enforces the protected-branch, force-push, and crypto/secret/migration gates at the git operation itself, below the command spelling — so shell indirection (g=git; $g commit) that never reaches the pre-bash.py matcher is still gated. It resolves the repository it runs in via git rev-parse --show-toplevel from its own working directory, reuses the same detection primitives as pre-bash.py so the two cannot drift, and is written atomically so a torn install can never leave a sentinel-less partial shim. This backstop is what pre-bash.py’s H-20 protects (a --no-verify would skip it).
security-pass.py / migration-pass.py
Section titled “security-pass.py / migration-pass.py”These record the gate passes that pre-bash.py checks. security-pass.py is run on PASS by the crypto-compliance / secret-handling skills: it writes the line digests of every sensitive line the gate approved to security-gate-passed. migration-pass.py is run on PASS by the commit gate after migration-reviewer: it writes the content digests of every approved migration to migration-gate-passed (no freshness window, since a migration is immutable). Both write atomically, so a half-written marker can never read as a valid pass. Binding by digest is what lets H-09b/H-10b/H-14 close the time-of-check / time-of-use window.
Command Utilities
Section titled “Command Utilities”init-codearbiter.py(/ca:init). Scaffolds the root-level.codearbiter/state store (idempotent; refuses ifCONTEXT.mdalready exists).--checkreports state and writes nothing.taskwrite.py(/ca:task). The only sanctioned mutator ofopen-tasks.md(add / start / done), written atomically and rerun-safe.doctor.py(/ca:doctor). Read-only health check covering interpreter health (warns loudly if no real interpreter resolves and every gate is dormant), payload integrity, stale-cache detection, repo activation, and statusline wiring. Exits non-zero on any failure but changes nothing.wire-statusline.py(/ca:statusline). Writes or removes the absolutestatusLine.commandin~/.claude/settings.json(atomically). Therefreshaction is the SessionStart self-heal that rewrites only a stale codeArbiter-owned path; it refuses to overwrite an unparseable settings file.update-refresh.py. The thin, fully detached entry point the SessionStart hook spawns for the once-daily update check. It performs the unauthenticated HTTPSGETto the GitHub Releases API and writes the result to the user-global cache; fail-silent, off the hot path, never awaited.boardsync.py(/ca:task, commit gate). Reconciles the task-board[ ]/[~]/[x]counters with the work, so a board transition lands atomically with its commit (ADR-0008).babysit.py/metrics.py/preview.py. Thin entry points for the/ca:pr+/ca:watch,/ca:metrics, and/ca:previewcommand surfaces, each importing its_*libso the logic ispy_compile- and test-covered rather than embedded as inlinepython -cin command prose.
Shared Libraries
Section titled “Shared Libraries”The _*lib.py files are shared internal libraries imported by the hooks and utilities above: the core _hooklib; the statusline stack (_segmentslib, _gitlib, _arbiterstatelib, _subagentslib, _boxlib, _colorlib, _fmtlib, _sessionlib, _ledgerlib); and the feature/utility libs (_prunelib, _taskboardlib, _standuplib, _sloplib, _metricslib, _previewlib, _babysitlib, _provenancelib, _readinjectlib, _updatelib). _hooklib is the core: it owns the activation contract, the block/remind/warn primitives (which also best-effort append every gate decision to the durable, append-only .codearbiter/gate-events.log), the centralized crypto/secret/audit-path sets (AUDIT_LOG_BASENAMES), and the digest helpers, so the separate hooks never drift on what they enforce.