Constructdocs
section · guides

Hooks

The hook surface — what they are, how they fire, the conventions every one obeys, and why there are no skip env vars.

7 min read·guides / concepts / hooks

A hook is a small Node script that Claude Code invokes at a specific point in the tool-call lifecycle. Hooks live in lib/hooks/*.mjs and are wired into Claude Code via platforms/claude/settings.template.json. They are the only piece of Construct that runs unconditionally on every session, every tool call, every commit, every push.

Principles

Hooks fire unconditionally. No CONSTRUCT_SKIP_*, CONSTRUCT_ALLOW_*, or CONSTRUCT_QUIET_* env var sits inline with any quality check. The principle: a skip env var on a quality gate is an admission the policy is wrong, the gate is over-scoped, or the team will route around it. If a check fires wrong, repair the check — do not re-add the bypass at the call site. Pinned by tests/hooks/no-skip-vars.test.mjs.

Advisory notices auto-suppress in non-interactive contexts. Reminders and nudges (registry-sync, doc-coupling) detect CI=true / NODE_ENV=test and stay silent. The wrong-context detection is the right mechanism; an explicit opt-out flag is not.

Hot paths stay narrow. PostToolUse fires on every tool call. Every hook there carries an @p95ms budget in its header, enforced by tests/hooks-budget.test.mjs. The current ceiling is 20 PostToolUse entries; add a 21st only after consolidating two.

Writes are bounded. Every hook that appends to a long-lived file uses appendBounded(channel, path, line) from lib/logging/rotate.mjs, which looks up the channel's cap, segment count, and gzip flag in a registered LIMITS table. Direct appendFileSync to user-scope logs is forbidden; bounded writes are what stop a stuck loop from filling the disk.

Project state is per-project. Hooks that record per-project facts (audit-trail, agent-log, audit-reads, file-hashes) write through resolveProjectScopedPath('\<file>') so project A's mutations don't mix into project B's chain.

Hook surface is exclusive to Claude Code. Codex, OpenCode, Copilot, VS Code, and Cursor sync agents and skills but have no hook layer. A project switching to those hosts loses every gate listed below — that's a known constraint, not an oversight.

Header convention

Every lib/hooks/*.mjs carries a docstring at file top with these annotations. Asserted by tests/hooks-budget.test.mjs.

/**
 * lib/hooks/<name>.mjs — <one-line purpose>
 *
 * <multi-line description of behavior, side effects, and constraints>
 *
 * @lifecycle PreToolUse | PostToolUse | SessionStart | Stop | UserPromptSubmit | PostToolUseFailure | PreCompact
 * @matcher  Bash | Write|Edit|MultiEdit | mcp__ | *
 * @p95ms    <number>
 * @maxBlockingScope  none | this-call | session
 * @exits   0 = pass | 2 = block tool call
 */

Hooks present on disk but not referenced from platforms/claude/settings.template.json carry @unwired instead of @lifecycle and @matcher. The budget test rejects any .mjs in lib/hooks/ that is neither referenced nor explicitly marked.

Exit codes

Two codes are used. Both are documented in every header via @exits.

CodeMeaning
0Pass through. The tool call continues.
2Block the tool call. Claude Code surfaces the hook's stderr to the agent and refuses the operation.

Hooks that document @exits 0 = pass | 2 = block tool call must actually have a process.exit(2) in their source. Hooks that document @exits 0 = pass must not. Drift is rejected by tests/hooks-budget.test.mjs.

Capability-aware SessionStart output

A SessionStart hook's stdout is injected into the model's context. In a non-interactive / SDK / claude -p one-shot that verbose payload pollutes the caller's machine-oriented stdout and can spill into editors and logs. session-start.mjs resolves an output mode and routes its payload accordingly:

ModeBehavior
stdoutInject as context (interactive default)
stderrWrite to stderr (visible, not injected)
silentWrite only to ~/.cx/session-start-last.log — stdout/stderr stay clean
autostdout when interactive, silent when non-interactive

Precedence is env (CONSTRUCT_HOOK_OUTPUT_MODE) > config (hooks.outputMode in construct.config.json) > default auto. Because Claude Code exposes no reliable in-hook signal for interactive vs print/SDK mode (CLAUDECODE=1 is set in both; a hook's isTTY is false even interactively), auto detects non-interactive only from signals that ARE reliable — CI=true, NODE_ENV=test, and an explicit CONSTRUCT_NONINTERACTIVE=1. SDK and host-adapter callers set CONSTRUCT_HOOK_OUTPUT_MODE=silent (or CONSTRUCT_NONINTERACTIVE=1) when they invoke Construct non-interactively; auto never suppresses on an ambiguous signal, so interactive sessions keep their rich startup context. Resolver + router live in lib/hooks/_lib/output-mode.mjs.

Bounded-write channels

Every hook that appends to a long-lived JSONL or log file routes through appendBounded(channel, path, line). Channels are registered in lib/logging/rotate.mjs LIMITS with maxBytes, maxSegments, gzip, and an env-var override for the cap.

Hooks that own audit-shaped data (audit-trail, audit-reads, mcp-audit) write via their registered channel and carry the @p95ms budget that reflects the write cost. Direct appendFileSync to a path under ~/.cx/ or ~/.construct/ is rejected by review.

Surviving hooks

HookLifecycleMatcherPurpose
session-start.mjsSessionStart*Tiered context injection (capability-aware output mode) + .env.example.env notice
comment-lint.mjsPostToolUseWrite|Edit|MultiEditBlock banned comment patterns and missing required headers
artifact-release-gate.mjsPostToolUseWrite|Edit|MultiEditAdvisory: manifest structure/visual gaps on typed docs
audit-trail.mjsPostToolUseEdit|Write|MultiEdit|NotebookEdit|BashTamper-evident JSONL audit chain (project-scoped)
audit-reads.mjsPostToolUseReadAlways-on file-hash store for edit-guard staleness; opt-in tamper-evident read audit
agent-tracker.mjsPostToolUseTaskRecords dispatched subagent; emits handoff.received on next:cx-\<role> results
edit-guard.mjsPreToolUseEditValidates old_string presence and file-hash freshness before allowing the edit
edit-accumulator.mjsPostToolUseWrite|Edit|MultiEditEdit counter + TS/JS pending-typecheck queue
scan-secrets.mjsPostToolUseWrite|Edit|MultiEditBlocks edits containing high-signal secret patterns
config-protection.mjsPreToolUseWrite|Edit|MultiEditBlocks edits to protected config files (eslint/prettier/tsconfig/biome/stylelint)
guard-bash.mjsPreToolUseBashBlocks destructive shell patterns
block-no-verify.mjsPreToolUseBashBlocks git commit/push/merge --no-verify
pre-push-gate.mjsPreToolUseBashRefuses claude/* pushes; blocks re-push of the SHA CI rejected; lints PR body on gh pr create/edit
policy-engine.mjsPreToolUse, Stop, UserPromptSubmit*Bootstrap gate; session-end red-CI / open-beads / drive-criteria blocks
mcp-audit.mjsPostToolUsemcp__.*Logs every MCP tool call; emits OTel span when configured
mcp-health-check.mjsPreToolUsemcp__.*Skips recently-failed MCP servers
bash-output-logger.mjsPostToolUseBashSaves long Bash stdout to ~/.cx/bash-logs/
dep-audit.mjsPostToolUseWrite|EditRuns vulnerability audit after dependency manifest edits
test-watch.mjsPostToolUseBashEmits test.fail / test.flake events on non-zero test exits
adaptive-lint.mjsPostToolUseWrite|EditAuto-runs linter/formatter on edited file; flags debug logging
doc-coupling-check.mjsPostToolUseWrite|EditCounts code edits without doc updates; advisory nudge
post-merge-docs-check.mjsPostToolUseBashEmits pr.merged.no-docs / changelog.missing events on merges without doc updates
readme-age-check.mjsUserPromptSubmit*Emits readme.stale for READMEs not touched in 90+ days
ci-status-check.mjsUserPromptSubmit*Injects current-branch CI status into the agent's context
context-watch.mjsUserPromptSubmit*Token usage monitoring; compaction recommendation
registry-sync.mjsPostToolUseWrite|EditReminder to run construct sync after specialists/org edits
edit-error-recovery.mjsPostToolUseFailure*Targeted recovery guidance on Edit failure
context-window-recovery.mjsPostToolUseFailure*Detects context-limit errors; saves recovery snapshot
model-fallback.mjsPostToolUseFailure*Detects rate-limit / provider failures; selects fallback model
pre-compact.mjsPreCompact*Saves context state before compaction; preserves hand-written sections
stop-typecheck.mjsStop*Runs tsc --noEmit at session end on the pending-typecheck queue
stop-notify.mjsStop*Session summary: files changed, TS results, cost
session-reflect.mjsStop*Auto-extracts session summary to observations
session-optimize.mjsStop*Triggers agent optimization for low performers
session-tracking-refresh.mjsStop*Archives plan.md when landed; syncs the bead-status table; refreshes .construct/context.{md,json} Active Work / Recent Decisions / Architecture Notes
post-merge-tracking.mjsPostToolUseBashAfter gh pr merge succeeds, parses the PR body's Refs: / Closes: / Fixes: lines and closes every referenced construct-XXX bead

Two hooks present on disk are marked @unwired (no settings.template.json entry, awaiting wiring): proactive-activation.mjs, rule-verifier.mjs.

Tracking-surface maintenance

Three project-tracking surfaces are kept current automatically:

  • .construct/context.md + .construct/context.jsonsession-tracking-refresh.mjs (Stop) rewrites the managed sections (## Active Work, ## Recent Decisions, ## Architecture Notes) from recent observations, commits, and bead state. The ## Open Questions section and any user-authored sections are preserved.
  • plan.mdsession-tracking-refresh.mjs also syncs the bead-status table against current bd show truth. When every referenced bead is closed AND the plan has been idle ≥1 h, the plan is archived into .construct/handoffs/\<date>-plan-landed.md and reset to the template.
  • Beadspost-merge-tracking.mjs (PostToolUse / Bash) closes the beads named in a just-merged PR's body. The drift detector in lib/beads/drift.mjs surfaces residual drift in construct doctor; remediation happens at the merge event.

The four pure utilities live in lib/tracking-surfaces.mjs (refreshContextMd, syncPlanFile, archivePlanIfLanded, closeBeadsFromPrRefs) and can be called from the CLI for ad-hoc curation. Hooks own when; utilities own what.

Adding a new hook

  1. Write the file in lib/hooks/\<name>.mjs with the full header schema.
  2. Wire it into platforms/claude/settings.template.json under the appropriate lifecycle.
  3. If it writes to a long-lived file: register the channel in lib/logging/rotate.mjs LIMITS.
  4. If it records per-project state: route through resolveProjectScopedPath so it doesn't leak across projects.
  5. If it reads hook input: use readHookInput() from lib/hooks/_lib/input.mjs.
  6. Run npm run release:checktests/hooks-budget.test.mjs will fail if any contract is broken.

Reference