ai: Claude Code Stop/PreToolUse hooks need exit code 2 to block, but `lefthook run` always exits 1
Summary
The ai: integration (2.1.10, #1448) generates .claude/settings.json entries that run lefthook run <hook>. Claude Code only treats a hook as blocking when the command exits with code 2: for Stop, exit 2 blocks the stop and feeds stderr back to the agent so it keeps working; for PreToolUse, exit 2 blocks the tool call. Any other non-zero exit is "non-blocking": stderr is shown to the user and the agent stops / the tool call proceeds.
lefthook run exits 1 whenever a job fails, even when the job itself exits 2. So a Stop: validate hook that fails lint just prints to the user after the agent has already finished; the agent never sees it.
Reproduction
# lefthook.yml
ai:
claude:
Stop: validate
validate:
jobs:
- run: exit 2$ lefthook install
$ lefthook run validate; echo $?
...
exit status 2
...
1Same with run: exit 1 → lefthook exits 1. Claude Code docs on exit codes: https://code.claude.com/docs/en/hooks#hook-output
Suggestion
When invoked from a generated agent hook (or via a flag, e.g. lefthook run --agent validate that the ai: generator emits), exit 2 on failure — or propagate the highest job exit code. Also worth noting in the docs that Claude passes stop_hook_active: true on stdin when the agent is already continuing from a Stop hook; users who lint on Stop will want to exit 0 in that case to avoid a loop.
lefthook 2.1.10, macOS (Homebrew).
Source: evilmartians/lefthook