PR guidance gate: force agents to read docs/pull-requests.md before touching PRs
Status summary#
Implemented and tested; PR #2484 is CI-green and awaiting review. Hook + settings wiring + 6 vitest cases + doc note all done. Nothing known missing; possible follow-up (out of scope): CI/pullfrog body check to catch non-Claude agents.
Problem#
Agents keep opening PRs that ignore docs/pull-requests.md — no screenshots/videos, no risk map, wrong body shape. Global CLAUDE.md is the wrong place to dump the guidance: agents skim it at session start and forget it by the time they open a PR, and we don't want to bloat it anyway.
Approach#
Just-in-time context injection via a Claude Code PreToolUse hook, checked into
.claude/settings.json so every worktree/session gets it:
- The hook watches
Bashtool calls. When a command mutates a PR (gh pr create,gh pr edit,gh pr merge, orgh api -X PATCH .../pulls/...), it blocks (exit 2) and prints the full contents ofdocs/pull-requests.mdto stderr — which Claude Code feeds back to the model. - The deny message ends with a hash of the doc: "re-run with
PR_GUIDANCE_HASH=<hash>prefixed to your command". The hook lets gated commands through when the current hash appears in the command string. - Hashing the doc means the gate self-invalidates whenever the guidance changes: stale hash → blocked again → fresh guidance re-injected.
- "Gaming" the hash is fine by design: the deny message is the doc, so by the time an agent can retry, the guidance is in its context. That's the whole enforcement.
Why a hook and not a gh PATH shim:
- Hooks only run under the agent harness — humans in a normal terminal are unaffected, no
isAgent()env sniffing. - Inspects the command string before execution, so it catches
ghinvoked by absolute path. - PATH shims are fragile in the sandbox (the pnpm shell wrapper already breaks there).
- Ships with the repo; no per-machine setup.
Checklist#
-
scripts/hooks/pr-guidance-gate.sh— pure bash (no jq/node so every Bash call stays fast): fast-path exit for non-PR commands,shasum-based doc hash, exit 2 + guidance on stderr for gated commands missing the current hash implemented as spec'd; matches on the raw stdin JSON rather than parsing it, which keeps it dependency-free -
.claude/settings.json— addhooks.PreToolUseentry (matcherBash) added under the proper"hooks"key; pre-existing top-levelSessionStartleft untouched, see decisions -
scripts/hooks/pr-guidance-gate.test.ts— vitest in the scripts workspace, spawns the real script: non-gh and read-onlygh pr view/gh apiGET pass; create/edit/merge/PATCH blocked without hash; blocked stderr contains the doc + hash; retry with that hash passes; stale hash blocked 6 tests, all passing - Note in
docs/pull-requests.mdmentioning the gate so humans editing the doc know blocking behavior is tied to its hash blockquote at the top of the doc
Decisions & assumptions (made while Misha reviews async)#
- Gate mutations only (create/edit/merge/PATCH). Reads (
gh pr view,gh pr checks,gh api .../pulls/<n>GETs) pass freely — gating them would nag constantly during PR monitoring, which the doc itself prescribes. - Stateless hash check (no per-session ack marker). The hash rides along as an inline
PR_GUIDANCE_HASH=<hash>prefix the hook string-matches; it never needs to be a real env var. - The existing top-level
SessionStartkey in.claude/settings.jsonis left untouched. It's not under the"hooks"key so Claude Code likely never runs it locally — and that's good:async-coding-agent-setup.shdoes sudo installs and a full test run, clearly meant for cloud agent environments. Flagged for Misha rather than "fixed". - Non-Claude agents (Cursor CLI, Codex) don't run Claude hooks. If those turn out to be the offenders too, a follow-up can add a CI/pullfrog check validating PR bodies post-hoc; out of scope here.
Implementation log#
- Hook matches against the raw PreToolUse stdin JSON with bash
caseglobs instead of parsing out.tool_input.command— avoids a jq/node dependency and startup cost on every Bash call. Accepted tradeoff: a command whose description mentionsgh pr createwould also be gated (rare, and the fix is just adding the hash prefix). gh apigating is PATCH+pullsonly. POST topulls/.../comments(review-comment replies) and all GETs stay ungated because PR monitoring — which the guidance itself prescribes — runs those constantly.- Hash is
shasum | cut -c1-8(SHA-1, present on macOS and Linux); test recomputes it with node:crypto and also round-trips the hash extracted from a real deny message. - Live-verified both paths: deny prints the full doc + hash and exits 2; hash-prefixed rerun exits 0 silently.
pnpm install && typecheck (scripts) && lint && knip && format && scripts testsall green locally; full suite left to CI.