The hook protocol
xCoder hooks are Claude-Code-format: standalone Node scripts that receive JSON on stdin and signal decisions via exit code:
| Exit code | Meaning |
|---|---|
| 0 | Allow — the tool call proceeds normally. |
| 2 | Block — the tool call is refused; stderr contains the reason. |
| other | Hook error — fails open (the tool call proceeds), event logged. |
{
"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
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.jsonBypass paths
- env
XCODER_ALLOW_INTEGRATION_EDIT=1— single shell invocation only. Right for hotfixes. - config
workflow.commitDirectly: truein.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-verifyor--amendis 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-emptyis 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
| Mode | Trigger | Action |
|---|---|---|
| default | Agent ends with N commits ahead, clean tree, no PR | Emits a notice to stderr (visible in next session start). |
| XCODER_AUTO_PR=1 | Same trigger | Pushes 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.
ghCLI is not available.
Installing the hooks
Automatic
xc hooks deployDetects 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:
{
"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.