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
56 changes: 56 additions & 0 deletions .claude/hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# `.claude/hooks/` -- harness pre-tool-use hooks

Claude Code reads project-level hooks from `.claude/settings.json`. Hook scripts live here. Wiring a hook into the harness requires editing `.claude/settings.json`; the script existing on disk does NOT make it active by itself (opt-in is explicit).

Canonical Anthropic reference: <https://code.claude.com/docs/en/hooks>.

## Available hooks

### `verify-branch-pretooluse.ts`

Wraps `tools/orchestrator-checks/verify-branch.ts` (PR #1585) into the Claude Code PreToolUse JSON contract. Mechanizes the orchestrator branch-verify rule (per B-0191) -- when `ZETA_EXPECTED_BRANCH` is set in the session env and `git branch --show-current` doesn't match, the hook blocks the `git commit` Bash invocation with `permissionDecision: "deny"` and the script's stderr as the reason.

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
Comment on lines +1 to +15

Add this block to the top-level object in `.claude/settings.json`:

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit*)",
"command": "bun \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-branch-pretooluse.ts"
Comment on lines +17 to +29
}
]
}
]
}
}
Comment on lines +17 to +35
```

The `if` clause restricts the hook to `git commit` subcommands so other Bash invocations (build, test, file ops, etc.) are unaffected.

#### How to use after wiring

1. Before starting a task on a specific branch, set the env var:
```bash
export ZETA_EXPECTED_BRANCH=feature/my-task-2026-05-05
git checkout -b "$ZETA_EXPECTED_BRANCH"
```
2. Subsequent `git commit` invocations verify the branch matches before proceeding.
3. If the branch silently shifts (e.g. the orchestrator-CWD-bleed-over hazard documented in `memory/feedback_parallel_subagent_concurrency_lessons_cluster_aaron_2026_05_04.md`), the next commit attempt is blocked with a clear error rather than landing on the wrong branch.
4. To unset for a session that needs to operate on multiple branches, just `unset ZETA_EXPECTED_BRANCH`.

#### Composes with

- `tools/orchestrator-checks/verify-branch.ts` (PR #1585) -- the underlying check.
- `memory/feedback_orchestrator_pre_commit_verify_branch_rule_aaron_2026_05_04.md` (PR #1568) -- the manual discipline this mechanizes.
- `memory/feedback_dst_justifies_ts_quality_over_bash_and_harness_hooks_suffice_no_git_hooks_aaron_2026_05_03.md` -- the harness-hooks-suffice rule.
- `docs/backlog/P1/B-0191-orchestrator-branch-verify-mechanization-design-aaron-2026-05-04.md` (PR #1571) -- the design.
75 changes: 75 additions & 0 deletions .claude/hooks/verify-branch-pretooluse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env bun
// 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.
//
// This wrapper invokes verify-branch.ts, captures its exit code + stderr,
// and translates to the PreToolUse JSON contract.
//
// 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.
//
// 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).

import { spawnSync } from "node:child_process";

interface HookOutput {
readonly hookSpecificOutput: {
readonly hookEventName: "PreToolUse";
readonly permissionDecision: "allow" | "deny";
readonly permissionDecisionReason?: string;
};
}

function emitDeny(reason: string): never {
const output: HookOutput = {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: reason,
},
};
console.log(JSON.stringify(output));
process.exit(0);
}

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.
Comment on lines +45 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Parse Bash target repo before running branch check

This wrapper assumes tool_input.command is irrelevant and always runs verify-branch.ts in the hook process context, which means the check can validate the wrong repository when the commit command targets another cwd (for example git -C ../other-repo commit ... or a command that changes directory before committing). In those cases the hook may allow a commit even though the actual commit target branch does not match ZETA_EXPECTED_BRANCH, undermining the branch-safety guarantee this hook is meant to enforce.

Useful? React with 👍 / 👎.

const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
const result = spawnSync(
"bun",
[`${projectDir}/tools/orchestrator-checks/verify-branch.ts`],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
},
);

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);
Comment on lines +32 to +68
// unreachable
return 0;
}

if (import.meta.main) {
process.exit(main());
}
Loading