Two PRs today wrote a closing keyword in front of an issue reference without meaning to. #710 said “Details and proposed fix: #708” and closed #708 when it merged; it had to be reopened. #743 said “This is the display fix #628 kept”, which GitHub reads as fix #628, and the session caught it only after the PR was up. The rule against this was already written down: grep the body before gh pr create. In #743’s session the grep ran beside the create instead of gating it, so the rule was there and was not followed. A guard runs before the command and in every subagent, so this became one: closing-keyword refuses a gh pr create, a gh pr edit or a git commit whose title, body or message puts a keyword directly before a reference. The CLAUDE.md practice of closing an issue with a body line naming it still works, through a named override on the command.
The pattern encodes what GitHub does, so I checked it against GitHub instead of against my memory of the docs. GraphQL’s closingIssuesReferences lists, for every PR, the issues GitHub will close. Over all 361 PRs in the repo, the pattern predicted that list for 360. The 361st, #163, was closed unmerged, and GitHub keeps no link for it. The same data says what GitHub leaves alone: “fix for #365”, “fix (#227)” and “fixed here: #360” closed nothing, so the guard lets them pass. The docs add that a colon is allowed after the keyword; the corpus had no example of that, but #710 is one. Two forms are refused on weaker grounds, GH-N and full issue URLs: GitHub autolinks both as issue references, and nothing says a keyword skips them. The repo has used neither, so the over-match costs nothing so far.
Then I ran the guard over every PR and commit command in the retained transcripts, 693 of them. 49 would be refused for a keyword. 47 are deliberate closures, which now need the override; that is the cost Richard asked for. The other two, one commit message and one PR body, quote the old “I’ve partly fixed #437” while describing it, and GitHub would act on those too. 15 more are refused because their body files have since been deleted, so the guard cannot read them now; at the time they existed and would have been read. For 182 of these PRs the result held the PR’s URL, so the verdict could be set beside GitHub’s record. The two agree except where the body changed after creation or the file is gone. The replay also found a bug in my own guard. A command split over lines with trailing backslashes, gh pr create \ with --body on the next line, reached the parser as separate commands, so its body was never read. One of those bodies held a real “Fixes #250”, and the guard passed it. The shell joins continuation lines and shlex does not, so the guard now joins them first. The shared parser has the same gap: git stash and pop on two lines get past the stash guard. That is filed as its own task.
Two smaller things. First, the override had to become stricter. A PR body that explains the override, like the one for this change, contains # guard-ok: closing-keyword .... The old parser counted any such text in the command as an override, so that body would have lifted the guard for itself. Now only a real comment on the command counts. Quoted strings and heredoc bodies are data, as they already were for every other check. Second, a test passed for the wrong reason. With the continuation fix sabotaged, the multi-line git commit -F - case still passed, because the guard refused the command anyway as “cannot read”. Refusing is the right failure mode, but the test was meant to prove the text was read. The refusal tests now require the quoted phrase in the reason. Separately, a placeholder character I had typed as an escape reached the file as a raw private-use character, and the diff showed it as _LITERAL = "". It worked, but it looked like a bug, so it is spelled chr(0xE000) now.