Hooks

Four hooks turn flow discipline into a kernel-level concern. Three PreToolUse hooks block bad actions before they happen; one Stop hook catches the agent walking away without opening a PR.

The hook protocol

xCoder hooks are Claude-Code-format: standalone Node scripts that receive JSON on stdin and signal decisions via exit code:

Exit codeMeaning
0Allow — the tool call proceeds normally.
2Block — the tool call is refused; stderr contains the reason.
otherHook error — fails open (the tool call proceeds), event logged.
json
{
  "tool_name": "Bash",
  "tool_input": { "command": "git commit -m \"fix: thing #42\"" },
  "cwd": "/Users/.../my-project"
}

Fail-open by default

Every shipped hook treats unexpected errors as exit-0 (allow). A broken hook should never brick a session — the cost of a false-positive block is much higher than the cost of a false-negative pass.

no-edit-on-integration

PreToolUse — covers I-1 and I-2.

Blocks Edit, Write, MultiEdit, NotebookEdit and Bash git commit/push when HEAD is on the configured integration branch.

What the agent sees on block

text
BLOCKED: refusing git commit/push on integration branch `main`.

Flow discipline (xc boot → ON START OF WORK):
  1. Create or find a tracking issue:  gh issue create ...
  2. Create a feature branch:          git checkout -b feat/<slug>
  3. Re-run the git commit/push

If this IS a hotfix that genuinely belongs on main:
  export XCODER_ALLOW_INTEGRATION_EDIT=1

If this repo really does want direct commits to main:
  set workflow.commitDirectly: true in .claude/settings.json

Bypass paths

  • env XCODER_ALLOW_INTEGRATION_EDIT=1 — single shell invocation only. Right for hotfixes.
  • config workflow.commitDirectly: true in .claude/settings.json — repo-wide opt-in. Right for solo / throwaway repos.

A note on the regex

The hook strips top-level quoted strings from the command before matching, so an echo with literal text like echo "git commit ..." doesn't trigger. The match is anchored to command-segment start (after ;, &&, ||, etc.).

typecheck-must-pass-before-commit

PreToolUse — covers I-6.

Blocks git commit when the project's TypeScript compilation fails. Runs npx tsc --noEmit with a 30-second budget.

Skipped when

  • The Bash tool isn't being invoked.
  • The command isn't a real git commit.
  • The repo has no tsconfig.json.
  • --no-verify or --amend is in the command.

Bypass paths

  • env XCODER_SKIP_QA_GATE=1 — for intentional WIP commits.
  • flag git commit --no-verify — standard git escape hatch.

commit-must-reference-issue

PreToolUse — covers I-8.

Blocks git commit -m "msg" when the message doesn't reference a tracking issue.

Accepted patterns

  • #42 — bare reference, anywhere in the message.
  • closes #42, fixes #42, resolves #42 (case-insensitive).
  • issue 42, issues/42 (literal "issue").

Skipped when

  • The command lacks -m (interactive editor mode).
  • --no-verify, --amend, or --allow-empty is present.

Bypass paths

  • env XCODER_SKIP_ISSUE_REF=1 — for chore commits with no tracking issue.
  • flag git commit --no-verify.

session-end-pr-backstop

Stop — covers I-9.

Fires when the agent ends its turn. Notifies (or auto-opens) when HEAD is on a feature branch ahead of integration but no PR exists. A corrective layer rather than preventive — the equivalent of Open SWE's open_pr_if_needed middleware.

Behavior modes

ModeTriggerAction
defaultAgent ends with N commits ahead, clean tree, no PREmits a notice to stderr (visible in next session start).
XCODER_AUTO_PR=1Same triggerPushes branch and runs gh pr create --fill automatically.

Skipped when

  • HEAD is on the integration branch.
  • HEAD is detached.
  • 0 commits ahead of integration.
  • Working tree has uncommitted changes (don't surprise mid-work).
  • A PR already exists for the branch.
  • gh CLI is not available.

Installing the hooks

Automatic

bash
xc hooks deploy

Detects your runtime (Claude Code today, others as drivers ship) and writes .claude/settings.json in place.

Manual

Add the following to your repo's .claude/settings.json:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR\"/node_modules/@xcoder/xcoder/dist/hooks/branch-guard.js",
            "timeout": 5
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR\"/node_modules/@xcoder/xcoder/dist/hooks/qa-before-commit.js",
            "timeout": 35
          },
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR\"/node_modules/@xcoder/xcoder/dist/hooks/issue-ref-guard.js",
            "timeout": 5
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR\"/node_modules/@xcoder/xcoder/dist/hooks/session-end-pr-backstop.js",
            "timeout": 60
          }
        ]
      }
    ]
  }
}

Path conventions

The example above uses node_modules/@xcoder/xcoder/dist/hooks/... — correct for repos consuming xCoder via npm. If you're working inside the xCoder monorepo itself, use packages/xcoder/dist/hooks/... directly.

Next