Hooks
The hook surface — what they are, how they fire, the conventions every one obeys, and why there are no skip env vars.
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.
| Code | Meaning |
|---|---|
0 | Pass through. The tool call continues. |
2 | Block 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:
| Mode | Behavior |
|---|---|
stdout | Inject as context (interactive default) |
stderr | Write to stderr (visible, not injected) |
silent | Write only to ~/.cx/session-start-last.log — stdout/stderr stay clean |
auto | stdout 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
| Hook | Lifecycle | Matcher | Purpose |
|---|---|---|---|
session-start.mjs | SessionStart | * | Tiered context injection (capability-aware output mode) + .env.example ↔ .env notice |
comment-lint.mjs | PostToolUse | Write|Edit|MultiEdit | Block banned comment patterns and missing required headers |
artifact-release-gate.mjs | PostToolUse | Write|Edit|MultiEdit | Advisory: manifest structure/visual gaps on typed docs |
audit-trail.mjs | PostToolUse | Edit|Write|MultiEdit|NotebookEdit|Bash | Tamper-evident JSONL audit chain (project-scoped) |
audit-reads.mjs | PostToolUse | Read | Always-on file-hash store for edit-guard staleness; opt-in tamper-evident read audit |
agent-tracker.mjs | PostToolUse | Task | Records dispatched subagent; emits handoff.received on next:cx-\<role> results |
edit-guard.mjs | PreToolUse | Edit | Validates old_string presence and file-hash freshness before allowing the edit |
edit-accumulator.mjs | PostToolUse | Write|Edit|MultiEdit | Edit counter + TS/JS pending-typecheck queue |
scan-secrets.mjs | PostToolUse | Write|Edit|MultiEdit | Blocks edits containing high-signal secret patterns |
config-protection.mjs | PreToolUse | Write|Edit|MultiEdit | Blocks edits to protected config files (eslint/prettier/tsconfig/biome/stylelint) |
guard-bash.mjs | PreToolUse | Bash | Blocks destructive shell patterns |
block-no-verify.mjs | PreToolUse | Bash | Blocks git commit/push/merge --no-verify |
pre-push-gate.mjs | PreToolUse | Bash | Refuses claude/* pushes; blocks re-push of the SHA CI rejected; lints PR body on gh pr create/edit |
policy-engine.mjs | PreToolUse, Stop, UserPromptSubmit | * | Bootstrap gate; session-end red-CI / open-beads / drive-criteria blocks |
mcp-audit.mjs | PostToolUse | mcp__.* | Logs every MCP tool call; emits OTel span when configured |
mcp-health-check.mjs | PreToolUse | mcp__.* | Skips recently-failed MCP servers |
bash-output-logger.mjs | PostToolUse | Bash | Saves long Bash stdout to ~/.cx/bash-logs/ |
dep-audit.mjs | PostToolUse | Write|Edit | Runs vulnerability audit after dependency manifest edits |
test-watch.mjs | PostToolUse | Bash | Emits test.fail / test.flake events on non-zero test exits |
adaptive-lint.mjs | PostToolUse | Write|Edit | Auto-runs linter/formatter on edited file; flags debug logging |
doc-coupling-check.mjs | PostToolUse | Write|Edit | Counts code edits without doc updates; advisory nudge |
post-merge-docs-check.mjs | PostToolUse | Bash | Emits pr.merged.no-docs / changelog.missing events on merges without doc updates |
readme-age-check.mjs | UserPromptSubmit | * | Emits readme.stale for READMEs not touched in 90+ days |
ci-status-check.mjs | UserPromptSubmit | * | Injects current-branch CI status into the agent's context |
context-watch.mjs | UserPromptSubmit | * | Token usage monitoring; compaction recommendation |
registry-sync.mjs | PostToolUse | Write|Edit | Reminder to run construct sync after specialists/org edits |
edit-error-recovery.mjs | PostToolUseFailure | * | Targeted recovery guidance on Edit failure |
context-window-recovery.mjs | PostToolUseFailure | * | Detects context-limit errors; saves recovery snapshot |
model-fallback.mjs | PostToolUseFailure | * | Detects rate-limit / provider failures; selects fallback model |
pre-compact.mjs | PreCompact | * | Saves context state before compaction; preserves hand-written sections |
stop-typecheck.mjs | Stop | * | Runs tsc --noEmit at session end on the pending-typecheck queue |
stop-notify.mjs | Stop | * | Session summary: files changed, TS results, cost |
session-reflect.mjs | Stop | * | Auto-extracts session summary to observations |
session-optimize.mjs | Stop | * | Triggers agent optimization for low performers |
session-tracking-refresh.mjs | Stop | * | Archives plan.md when landed; syncs the bead-status table; refreshes .construct/context.{md,json} Active Work / Recent Decisions / Architecture Notes |
post-merge-tracking.mjs | PostToolUse | Bash | After 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.json—session-tracking-refresh.mjs(Stop) rewrites the managed sections (## Active Work,## Recent Decisions,## Architecture Notes) from recent observations, commits, and bead state. The## Open Questionssection and any user-authored sections are preserved.plan.md—session-tracking-refresh.mjsalso syncs the bead-status table against currentbd showtruth. When every referenced bead isclosedAND the plan has been idle ≥1 h, the plan is archived into.construct/handoffs/\<date>-plan-landed.mdand reset to the template.- Beads —
post-merge-tracking.mjs(PostToolUse / Bash) closes the beads named in a just-merged PR's body. The drift detector inlib/beads/drift.mjssurfaces residual drift inconstruct 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
- Write the file in
lib/hooks/\<name>.mjswith the full header schema. - Wire it into
platforms/claude/settings.template.jsonunder the appropriate lifecycle. - If it writes to a long-lived file: register the channel in
lib/logging/rotate.mjsLIMITS. - If it records per-project state: route through
resolveProjectScopedPathso it doesn't leak across projects. - If it reads hook input: use
readHookInput()fromlib/hooks/_lib/input.mjs. - Run
npm run release:check—tests/hooks-budget.test.mjswill fail if any contract is broken.
Reference
tests/hooks-budget.test.mjs: the enforced contracts.tests/hooks/no-skip-vars.test.mjs: the no-skip-vars regression.lib/logging/rotate.mjs: the bounded-write registry.lib/project-root.mjs: project-scope helpers.- Concepts → Gates and enforcement: how hooks fit into the wider enforcement model.