diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md index 3abc817a5..b9084f3a8 100644 --- a/.claude/hooks/README.md +++ b/.claude/hooks/README.md @@ -12,9 +12,9 @@ Wraps `tools/orchestrator-checks/verify-branch.ts` (PR #1585) into the Claude Co If `ZETA_EXPECTED_BRANCH` is unset, the hook is a no-op (exits 0, allow). The default-off behavior means wiring this hook does not change any commit flow unless an agent (or maintainer) explicitly sets the env var for a task. -#### Opt-in configuration +#### Configuration -Add this block to the top-level object in `.claude/settings.json`: +The hook is wired in `.claude/settings.json` under `hooks.PreToolUse` with `"matcher": "Bash"`: ```json { @@ -25,7 +25,6 @@ Add this block to the top-level object in `.claude/settings.json`: "hooks": [ { "type": "command", - "if": "Bash(git commit*)", "command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-branch-pretooluse.ts" } ] @@ -35,7 +34,7 @@ Add this block to the top-level object in `.claude/settings.json`: } ``` -The `if` clause restricts the hook to `git commit` subcommands so other Bash invocations (build, test, file ops, etc.) are unaffected. +The `matcher` fires on all Bash tool calls, but the script itself reads stdin JSON and filters to `git commit` commands only. When `ZETA_EXPECTED_BRANCH` is unset, the script exits 0 before reading stdin -- zero overhead. #### How to use after wiring diff --git a/.claude/hooks/verify-branch-pretooluse.ts b/.claude/hooks/verify-branch-pretooluse.ts index e05b35204..dccbd77e9 100644 --- a/.claude/hooks/verify-branch-pretooluse.ts +++ b/.claude/hooks/verify-branch-pretooluse.ts @@ -2,25 +2,26 @@ // verify-branch-pretooluse.ts -- Claude Code PreToolUse hook wrapper // for tools/orchestrator-checks/verify-branch.ts. // -// Per Claude Code hooks reference (https://code.claude.com/docs/en/hooks): -// the hook script reads JSON from stdin (containing tool_input + session -// metadata) and either exits 0 (allow), exits 2 (block with stderr as -// reason), or writes a hookSpecificOutput JSON to stdout with explicit -// permissionDecision. +// Reads stdin JSON per the Claude Code hooks contract +// (https://code.claude.com/docs/en/hooks-guide). Filters to +// `git commit` commands only — other Bash invocations exit 0 +// immediately with zero overhead. // -// This wrapper invokes verify-branch.ts, captures its exit code + stderr, -// and translates to the PreToolUse JSON contract. +// When ZETA_EXPECTED_BRANCH is unset, the hook is a no-op +// (exits 0 before reading stdin or spawning any child process). // -// Wired via .claude/settings.json -- see .claude/hooks/README.md for the -// opt-in configuration. The wrapper exists on disk regardless; opt-in is -// via settings.json edit, not via existence of this file. +// Wired via .claude/settings.json PreToolUse matcher:"Bash". +// See .claude/hooks/README.md for configuration. // -// Per B-0191 (PR #1571 design + PR #1585 implementation). Composes with -// memory/feedback_dst_justifies_ts_quality_over_bash_and_harness_hooks_suffice_no_git_hooks_aaron_2026_05_03.md -// (TS-over-bash harness-hooks-suffice). +// Per B-0191 (PR #1571 design + PR #1585 implementation). import { spawnSync } from "node:child_process"; +interface HookInput { + readonly tool_name?: string; + readonly tool_input?: { readonly command?: string }; +} + interface HookOutput { readonly hookSpecificOutput: { readonly hookEventName: "PreToolUse"; @@ -41,10 +42,29 @@ function emitDeny(reason: string): never { process.exit(0); } +function isGitCommitCommand(command: string): boolean { + const trimmed = command.trimStart(); + return trimmed.startsWith("git commit") || trimmed.startsWith("git -C") && trimmed.includes("commit"); +} + function main(): number { - // Run verify-branch.ts. We don't need to parse the stdin JSON because - // verify-branch.ts reads ZETA_EXPECTED_BRANCH from env + queries git - // directly -- the tool_input.command isn't needed for the check. + if (!process.env.ZETA_EXPECTED_BRANCH) { + return 0; + } + + let input: HookInput = {}; + try { + const stdin = require("fs").readFileSync(0, "utf8"); + input = JSON.parse(stdin) as HookInput; + } catch { + return 0; + } + + const command = input.tool_input?.command ?? ""; + if (!isGitCommitCommand(command)) { + return 0; + } + const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(); const result = spawnSync( "bun", @@ -56,17 +76,14 @@ function main(): number { ); if (result.status === 0) { - // Allowed -- forward any stderr (worktree-warning) and exit 0. if (result.stderr) { process.stderr.write(result.stderr); } return 0; } - // Blocked -- translate to deny JSON with the script's stderr as reason. const reason = (result.stderr || "verify-branch check failed").trim(); emitDeny(reason); - // unreachable return 0; } diff --git a/.claude/settings.json b/.claude/settings.json index e1b345cb1..a433a5d25 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,17 @@ { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-branch-pretooluse.ts" + } + ] + } + ] + }, "permissions": { "allow": [ "Bash(bun *)", @@ -65,5 +78,13 @@ "huggingface-skills@claude-plugins-official": true, "postman@claude-plugins-official": true, "security-guidance@claude-plugins-official": true + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "command": "bun --bun tools/orchestrator-checks/verify-branch.ts" + } + ] } }