Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .claude/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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"
}
]
Expand All @@ -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

Expand Down
55 changes: 36 additions & 19 deletions .claude/hooks/verify-branch-pretooluse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand All @@ -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;
}

Expand Down
21 changes: 21 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-branch-pretooluse.ts"
}
]
}
]
},
Comment on lines +2 to +14
"permissions": {
"allow": [
"Bash(bun *)",
Expand Down Expand Up @@ -65,5 +78,13 @@
"huggingface-skills@claude-plugins-official": true,
"postman@claude-plugins-official": true,
"security-guidance@claude-plugins-official": true
},
"hooks": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove duplicate top-level hooks key

This change introduces a second top-level hooks object, so the file now defines hooks at both the beginning and end of the JSON. Duplicate keys are not interoperable JSON, and common parsers (including JSON.parse) keep only the last occurrence, which can silently discard the newly added PreToolUse wrapper block and make hook behavior depend on parser quirks.

Useful? React with 👍 / 👎.

"PreToolUse": [
{
"matcher": "Bash",
"command": "bun --bun tools/orchestrator-checks/verify-branch.ts"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route PreToolUse to the wrapper hook

This entry invokes tools/orchestrator-checks/verify-branch.ts directly instead of the new verify-branch-pretooluse.ts adapter. verify-branch.ts reports mismatches via exit code 1, but PreToolUse enforcement requires either exit 2 or a hookSpecificOutput.permissionDecision: "deny" payload; otherwise the tool call continues. As written, a branch mismatch can surface only as a hook error while still allowing the commit.

Useful? React with 👍 / 👎.

}
]
Comment on lines +81 to +88
}
}
Loading