Git Merge Hazards
Read before merging a PR, merging a stacked PR chain, auditing whether a branch
really landed, or merging over red CI. The body below is the verbatim text of
the promoted ~/.claude/rules/pr-merge-hazards.md rule.
Three notes that are not part of that body:
- Two gates below — §1's merged-ness authority order and the whole of §4 — are
also reproduced verbatim in the
pr-merge-hazards.md stub, because they are
read while the decision is being made. Edit both copies together.
- §1 overlaps
git-plugin:deadbranch Step 1.5 ("Reclassify squash-merged
branches"), which carries the same three signals scoped to branch cleanup.
- §2 and §3 describe ad-hoc chains — PRs whose base is another PR's branch.
A stack registered with GitHub (
gh stack) retargets its upper PRs
itself on merge, so the auto-close hazard and the manual retarget/rebase
ordering do not apply there. See git-plugin:git-stacked-prs.
- The two encoded recipes cited in §1 and §2 below are the author's own (a
just -g recipe in laurigates/dotfiles, and a sweep script in
laurigates/claude-plugins), not commands a plugin consumer already has.
Read the authority ladder in §1 as the instruction; the recipe is a
convenience, and its REVIEW bucket measured ~90% false positives (#2268).
Four traps in GitHub's merge machinery, one law: "PR merged" says nothing about
content — and a red check is not proof of failure. Each: the trap, the
5-second check, the fix. Sibling: ~/.claude/rules/git-hazards.md (local git).
1. --merged misses squash-merged branches
A squash-merge collapses a branch into one fresh-SHA commit on main, so the
branch's own commits are never ancestors — git branch --merged (and any
ancestry check) reports it unmerged. "Files identical to main" also fails
once main drifts the same files.
- Check, in order of authority:
gh pr list --state all --head <branch> --json state → a MERGED PR is
authoritative. Reach for this first; the git-side checks below are all
one-way.
git cherry main <branch> → marks a commit - when a patch-equivalent
commit is already upstream, + when it is not. Survives squash and
cherry-pick, and does not care that main drifted.
git merge-tree --write-tree main <branch> equals git rev-parse main^{tree}
→ contained. A match proves containment; a non-match proves nothing.
- Not immune to drift (corrected 2026-07): once
main moves on over the same
files, merging an already-merged branch back would re-introduce its older
versions, so the trees differ and merge-tree reports not contained for work
that fully landed. Observed reporting three merged branches as unmerged. Same
trap as "files identical to main". Use the PR state or git cherry to decide;
keep merge-tree only as a positive-containment shortcut.
- Fix: use the encoded recipe rather than re-deriving:
just -g branch-audit
(in private_dot_config/just/git.just) prints MERGED vs REVIEW + a paste-ready delete.
- A non-match is "review", not proof of unmerged — don't force the count to zero.
2. Merging a stacked base auto-CLOSES the child PR
When PR B is based on PR A's branch, merging A and deleting its branch
auto-closes B (GitHub does not retarget it), and a closed PR whose base
branch is gone cannot be reopened.
- Fix — order matters: retarget the child first, while the base PR is open:
gh pr edit <child> --base main
gh pr merge <base> --squash --delete-branch
git rebase --onto origin/main <old-base-tip> <child-branch> (drops the
already-squashed base commits) + git push --force-with-lease
- merge the child.
- If already auto-closed: the head branch survives — rebase as above,
gh pr create fresh, comment "Superseded by #new" on the closed one.
- Nothing tells you this happened. The auto-close is silent: no failed
check, no notification, and the PR list just looks one shorter. claude-plugins
#2049 sat stranded for a day; a sweep then found 26 dead branches, two carrying
work that had never had a PR opened at all (so no event ever fired for
them either). A scheduled sweep is the only thing that finds this class —
an event handler on
pull_request: closed is too late by construction (the
base ref is already deleted, so the reopen window is gone) and is blind to
never-PR'd branches. claude-plugins scripts/check-stranded-work.sh is the
encoded audit; it takes --repo, so one run sweeps the portfolio.
- Telling an accident from a decision: a closed-unmerged PR whose base ref
404s was auto-closed; one whose base ref is still alive was closed by a
human (duplicate/superseded). That single check is the discriminator — 11 of
those 26 branches were deliberate closes and must not be resurrected.
3. Stacked-chain merges: push by SHA, never HEAD: — and expect auto-close races
Working down a stacked-PR chain (retarget child → merge base → rebase child →
force-push → merge, per #2) has several traps of its own (observed 2026-07,
claude-plugins #1979→#1987):
HEAD: in a push refspec is a race in a shared checkout. HEAD is
process-global repo state; a coworker session can move it between two of
your Bash calls. Observed: rebase left HEAD at the child's new tip; by the
next call HEAD was main's tip, so git push --force-with-lease origin HEAD:<child-branch> overwrote the branch with main. Resolve the tip to an
explicit SHA in the same command that creates it and push
git push --force-with-lease origin <sha>:refs/heads/<branch>.
Brace that variable — "$sha:<branch>" is a zsh word-modifier expansion.
Stock zsh (reproduces under zsh -f) eats the character after the colon as a
history modifier whenever it happens to be one, silently rewriting the
refspec the bullet above just told you to use:
| Written |
zsh actually sends |
|
"$sha:refs/heads/x" |
<sha>efs/heads/x |
:r |
"$sha:feat/x" |
at/x |
:f+:e |
"$sha:chore/x" |
<sha>hore/x |
:c |
"$sha:test/x" / release/ / ci/ / hotfix/ / refactor/ |
mangled |
:t :r :c :h :r |
"$sha:style/x" |
hard error bad substitution |
:s |
"$sha:fix/x" / docs/ / perf/ / build/ / main |
correct |
not modifiers |
Half the conventional-commit prefixes break and half don't, which is why it
reads as a baffling one-off instead of a rule. Always
git push --force-with-lease origin "${sha}:refs/heads/<branch>". Observed
2026-08 pushing feat/justfile-pr-triage: src refspec <sha>efs/heads/… does not match any — the error names a ref you never typed, so it looks like a
stale SHA rather than a quoting bug.
Spell the destination refs/heads/<branch>, always. A <sha>:<branch>
refspec works only when <branch> already exists on the remote: git has a
bare commit object on the left, so there is no ref namespace to infer the
destination from, and it refuses rather than guessing — error: The destination you provided is not a full refname (i.e., starting with "refs/"). git wraps that message right after (i.e.,, so triage by grepping
the not a full refname fragment, not the whole sentence. The full refname
is accepted whether or not the branch exists, so there is no branch state to
reason about. It compounds with the brace rule immediately above rather than
replacing it: "$sha:refs/heads/x" is the :r word-modifier row in that
table — unbraced, zsh silently sends <sha>efs/heads/x.
An empty-diff force-push auto-closes the PR — and a closed PR whose head
moved after closing cannot be reopened. Sibling of #2's
base-branch-deleted variant. GitHub saw the branch == main, closed the PR,
and refused gh pr reopen because the head ref had moved since closing.
A single mergeability read after a force-push is a race. GitHub
recomputes mergeable asynchronously; gh pr merge right after a push
fails with "not mergeable" on a perfectly clean PR. Poll
gh pr view <n> --json mergeable until it leaves UNKNOWN.
Waiting for CI races check registration, not just completion. A loop on
"zero pending checks" can exit immediately after a push/update-branch:
zero pending is trivially true before the jobs are registered. Observed
2026-07: state=CLEAN on a single check while three CI jobs had not yet
appeared — merging there merges untested. Gate on both nothing-pending
and --jq 'length' ≥ the expected check count. Same root cause as the
mergeability race above: an async field read once, too early.
Check before every force-push: git log --oneline origin/main..<sha> —
expect exactly the child's commits, nothing more, never empty.
Recovery when auto-closed: the rebased commits survive in local objects
(git reflog) — git push --force-with-lease origin "${sha}:refs/heads/<branch>",
open a fresh PR from the branch, comment "Superseded by #new" on the closed
one. The full refname matters most here: the branch may not exist on the
remote yet, and the short form cannot create it.
4. A red PR may still be mergeable — UNSTABLE is not BLOCKED
mergeStateStatus separates required failing checks (BLOCKED — merge
refused) from merely-present ones (UNSTABLE — plain gh pr merge works), so
--admin on an UNSTABLE PR takes a privilege you didn't need. Read it first.
Merging over red needs two checks: --json files (config/docs can't break
a compile) and the same check already failing on main. Either alone is a
guess — and a stale-green main lies, so check createdAt (2026-07: a "green"
run was 21 days old; main hadn't compiled for three weeks).
5. A negated closing keyword still closes the issue
GitHub matches close|fixes|resolves|… + an issue reference without parsing the
sentence around it, so the natural way to disclaim closure closes the issue.
Markdown emphasis between verb and number doesn't break the match either:
Does **not** close #162 → GitHub reads `close #162`, and closes it
- Check:
gh issue view <n> --json closedByPullRequestsReferences names the
closer; a closedAt one second after a merge is automation, not a decision.
- Fix: never let a closing verb precede an issue number you don't mean to
close —
Related: #162, Unblocks #162, or #162 stays open — it needs ….
- Recovery:
gh issue reopen works (nothing was deleted, unlike #2), then
comment so the next reader doesn't re-derive it.
It is not only negation — plain past tense in a follow-ups section does it
The rule above reads as "watch out for the word not". The trap is wider: any
closing verb adjacent to a reference matches, including one merely describing
what already happened, and including a reference wrapped in a markdown link.
Observed 2026-08-16 (comfyui-nodes fleet). A PR body's follow-ups section
asked for an issue to be re-opened, describing it as one this bug had
closed on a false premise. GitHub matched the closing verb against the
bracketed cross-repo link that followed it and closed the issue on merge — a
line whose entire purpose was to request a re-open. The author had cited this
very rule earlier in the same session.
Three amplifiers:
- A bracketed cross-repo link still matches. A closing verb followed by a
markdown-linked
owner/repo#N is a match, and a naive audit regex like
keyword\s+[\w./-]*#\d+ misses it on the [ — then reports the body clean.
- A shared fleet body multiplies it. The same text went into 13 sweep PRs, so
13 merges each targeted the same issue.
- The actor is you, not a bot — the timeline reads
closed by <you> seconds
after the merge. Don't rule out a closing keyword because no bot appears.
And not only assertion — a keyword you are quoting still fires
The two forms above are sentences that mean something about an issue. The
third means nothing about it at all: the keyword appears inside an example, a
fixture, or a regex you are documenting. GitHub does not care that the line is
inside backticks, a code fence, or a sentence whose subject is the audit itself.
Observed 2026-08-17 (FVH infrastructure #2213). A PR body's verification
section reported that a closing-keyword scan returned zero hits, and quoted
its two control fixtures inline as evidence. The scan it was reporting on had
genuinely passed — on the ADR file. Run against the PR body, the same regex
matched the body's own worked examples, which would have closed an unrelated
issue on merge. Caught only because the scan was re-run against the artefact
about to be published, rather than the one it was describing.
Two things generalise:
- Scan the artefact you are about to publish, not the one you were auditing.
A PR body, an issue comment, and a rules file each need their own pass; a clean
result on one says nothing about the others.
- Do not reproduce the fixture in prose that ships. Describe it — "control-
tested against a bare reference and a bracketed cross-repo link, both found" —
and keep the literal strings in the throwaway file. Documenting the trap is
the one context where you are guaranteed to type the trap.
Audit the body before merging, with a control. Scan every PR you are about to
merge for a closing verb adjacent to an issue reference, allowing an optional
[ and an optional owner/repo between them, and prove the scan works by
confirming it finds a reference you know is there. A negative from an
unvalidated regex is worth nothing — the first audit run in the case above
reported the fleet clean.
The damage is a false status report: 2026-08-05, a PR body written to
disclaim closure closed loractl #162 at merge, and the session reported it open
for two turns afterward. Like #2, GitHub does this silently — only re-reading
issue state from the API catches it.
1---2name: git-merge-hazards3description: Traps in GitHub's merge machinery. Use when merging a PR, merging a stacked PR chain, auditing whether a branch really landed, or merging over red CI.4---5
6# Git Merge Hazards
7
8Read before merging a PR, merging a stacked PR chain, auditing whether a branch
9really landed, or merging over red CI. The body below is the verbatim text of
10the promoted `~/.claude/rules/pr-merge-hazards.md` rule.
11
12Three notes that are *not* part of that body:
13
14- Two gates below — §1's merged-ness authority order and the whole of §4 — are
15 also reproduced verbatim in the `pr-merge-hazards.md` stub, because they are
16 read *while* the decision is being made. Edit both copies together.
17- §1 overlaps `git-plugin:deadbranch` Step 1.5 ("Reclassify squash-merged
18 branches"), which carries the same three signals scoped to branch cleanup.
19- §2 and §3 describe **ad-hoc** chains — PRs whose base is another PR's branch.
20 A stack **registered with GitHub** (`gh stack`) retargets its upper PRs
21 itself on merge, so the auto-close hazard and the manual retarget/rebase
22 ordering do not apply there. See `git-plugin:git-stacked-prs`.
23- The two encoded recipes cited in §1 and §2 below are the author's own (a
24 `just -g` recipe in `laurigates/dotfiles`, and a sweep script in
25 `laurigates/claude-plugins`), not commands a plugin consumer already has.
26 Read the authority ladder in §1 as the instruction; the recipe is a
27 convenience, and its REVIEW bucket measured ~90% false positives (#2268).
28
29Four traps in GitHub's merge machinery, one law: "PR merged" says nothing about
30*content* — and a red check is not proof of failure. Each: the trap, the
315-second check, the fix. Sibling: `~/.claude/rules/git-hazards.md` (local git).
32
33## 1. `--merged` misses squash-merged branches
34
35A squash-merge collapses a branch into one fresh-SHA commit on `main`, so the
36branch's own commits are never ancestors — `git branch --merged` (and any
37ancestry check) reports it **unmerged**. "Files identical to main" also fails
38once `main` drifts the same files.
39
40- **Check**, in order of authority:
41 - `gh pr list --state all --head <branch> --json state` → a MERGED PR is
42 **authoritative**. Reach for this first; the git-side checks below are all
43 one-way.
44 - `git cherry main <branch>` → marks a commit `-` when a patch-equivalent
45 commit is already upstream, `+` when it is not. Survives squash **and**
46 cherry-pick, and does not care that `main` drifted.
47 - `git merge-tree --write-tree main <branch>` equals `git rev-parse main^{tree}`
48 → contained. **A match proves containment; a non-match proves nothing.**
49- **Not immune to drift** (corrected 2026-07): once `main` moves on over the same
50 files, merging an already-merged branch back would re-introduce its older
51 versions, so the trees differ and merge-tree reports **not contained** for work
52 that fully landed. Observed reporting three merged branches as unmerged. Same
53 trap as "files identical to main". Use the PR state or `git cherry` to decide;
54 keep merge-tree only as a positive-containment shortcut.
55- **Fix**: use the encoded recipe rather than re-deriving: `just -g branch-audit`
56 (in `private_dot_config/just/git.just`) prints MERGED vs REVIEW + a paste-ready delete.
57- A non-match is "review", **not** proof of unmerged — don't force the count to zero.
58
59## 2. Merging a stacked base auto-CLOSES the child PR
60
61When PR B is based on PR A's branch, merging A and deleting its branch
62auto-closes B (GitHub does **not** retarget it), and a closed PR whose base
63branch is gone **cannot be reopened**.
64
65- **Fix — order matters**: retarget the child **first**, while the base PR is open:
66 1. `gh pr edit <child> --base main`
67 2. `gh pr merge <base> --squash --delete-branch`
68 3. `git rebase --onto origin/main <old-base-tip> <child-branch>` (drops the
69 already-squashed base commits) + `git push --force-with-lease`
70 4. merge the child.
71- **If already auto-closed**: the head branch survives — rebase as above,
72 `gh pr create` fresh, comment "Superseded by #new" on the closed one.
73- **Nothing tells you this happened.** The auto-close is silent: no failed
74 check, no notification, and the PR list just looks one shorter. claude-plugins
75 #2049 sat stranded for a day; a sweep then found 26 dead branches, two carrying
76 work that had **never had a PR opened at all** (so no event ever fired for
77 them either). A scheduled sweep is the only thing that finds this class —
78 an event handler on `pull_request: closed` is too late by construction (the
79 base ref is already deleted, so the reopen window is gone) and is blind to
80 never-PR'd branches. `claude-plugins scripts/check-stranded-work.sh` is the
81 encoded audit; it takes `--repo`, so one run sweeps the portfolio.
82- **Telling an accident from a decision**: a closed-unmerged PR whose base ref
83 **404s** was auto-closed; one whose base ref is still **alive** was closed by a
84 human (duplicate/superseded). That single check is the discriminator — 11 of
85 those 26 branches were deliberate closes and must not be resurrected.
86
87## 3. Stacked-chain merges: push by SHA, never `HEAD:` — and expect auto-close races
88
89Working down a stacked-PR chain (retarget child → merge base → rebase child →
90force-push → merge, per #2) has several traps of its own (observed 2026-07,
91claude-plugins #1979→#1987):
92
93- **`HEAD:` in a push refspec is a race in a shared checkout.** HEAD is
94 process-global repo state; a coworker session can move it *between two of
95 your Bash calls*. Observed: rebase left HEAD at the child's new tip; by the
96 next call HEAD was `main`'s tip, so `git push --force-with-lease origin
97 HEAD:<child-branch>` overwrote the branch with main. Resolve the tip to an
98 **explicit SHA in the same command that creates it** and push
99 `git push --force-with-lease origin <sha>:refs/heads/<branch>`.
100- **Brace that variable — `"$sha:<branch>"` is a zsh word-modifier expansion.**
101 Stock zsh (reproduces under `zsh -f`) eats the character after the colon as a
102 history modifier whenever it happens to be one, silently rewriting the
103 refspec the bullet above just told you to use:
104
105 | Written | zsh actually sends | |
106 |---|---|---|
107 | `"$sha:refs/heads/x"` | `<sha>efs/heads/x` | `:r` |
108 | `"$sha:feat/x"` | `at/x` | `:f`+`:e` |
109 | `"$sha:chore/x"` | `<sha>hore/x` | `:c` |
110 | `"$sha:test/x"` / `release/` / `ci/` / `hotfix/` / `refactor/` | mangled | `:t :r :c :h :r` |
111 | `"$sha:style/x"` | **hard error** `bad substitution` | `:s` |
112 | `"$sha:fix/x"` / `docs/` / `perf/` / `build/` / `main` | correct | not modifiers |
113
114 Half the conventional-commit prefixes break and half don't, which is why it
115 reads as a baffling one-off instead of a rule. Always
116 `git push --force-with-lease origin "${sha}:refs/heads/<branch>"`. Observed
117 2026-08 pushing `feat/justfile-pr-triage`: `src refspec <sha>efs/heads/… does
118 not match any` — the error names a ref you never typed, so it looks like a
119 stale SHA rather than a quoting bug.
120- **Spell the destination `refs/heads/<branch>`, always.** A `<sha>:<branch>`
121 refspec works only when `<branch>` **already exists on the remote**: git has a
122 bare commit object on the left, so there is no ref namespace to infer the
123 destination from, and it refuses rather than guessing — `error: The
124 destination you provided is not a full refname (i.e., starting with
125 "refs/")`. git wraps that message right after `(i.e.,`, so triage by grepping
126 the `not a full refname` fragment, not the whole sentence. The full refname
127 is accepted whether or not the branch exists, so there is no branch state to
128 reason about. It compounds with the brace rule immediately above rather than
129 replacing it: `"$sha:refs/heads/x"` is the `:r` word-modifier row in that
130 table — unbraced, zsh silently sends `<sha>efs/heads/x`.
131- **An empty-diff force-push auto-closes the PR — and a closed PR whose *head*
132 moved after closing cannot be reopened.** Sibling of #2's
133 base-branch-deleted variant. GitHub saw the branch == main, closed the PR,
134 and refused `gh pr reopen` because the head ref had moved since closing.
135- **A single mergeability read after a force-push is a race.** GitHub
136 recomputes `mergeable` asynchronously; `gh pr merge` right after a push
137 fails with "not mergeable" on a perfectly clean PR. Poll
138 `gh pr view <n> --json mergeable` until it leaves `UNKNOWN`.
139- **Waiting for CI races check *registration*, not just completion.** A loop on
140 "zero pending checks" can exit **immediately** after a push/`update-branch`:
141 zero pending is trivially true before the jobs are registered. Observed
142 2026-07: `state=CLEAN` on a **single** check while three CI jobs had not yet
143 appeared — merging there merges untested. Gate on **both** nothing-pending
144 **and** `--jq 'length'` ≥ the expected check count. Same root cause as the
145 mergeability race above: an async field read once, too early.
146
147- **Check** before every force-push: `git log --oneline origin/main..<sha>` —
148 expect *exactly* the child's commits, nothing more, never empty.
149- **Recovery** when auto-closed: the rebased commits survive in local objects
150 (`git reflog`) — `git push --force-with-lease origin "${sha}:refs/heads/<branch>"`,
151 open a fresh PR from the branch, comment "Superseded by #new" on the closed
152 one. The full refname matters most here: the branch may not exist on the
153 remote yet, and the short form cannot create it.
154
155## 4. A red PR may still be mergeable — `UNSTABLE` is not `BLOCKED`
156
157`mergeStateStatus` separates **required** failing checks (`BLOCKED` — merge
158refused) from merely-present ones (`UNSTABLE` — plain `gh pr merge` works), so
159`--admin` on an `UNSTABLE` PR takes a privilege you didn't need. Read it first.
160
161Merging over red needs **two** checks: `--json files` (config/docs can't break
162a compile) **and** the same check already failing on `main`. Either alone is a
163guess — and a stale-green `main` lies, so check `createdAt` (2026-07: a "green"
164run was 21 days old; main hadn't compiled for three weeks).
165
166
167## 5. A **negated** closing keyword still closes the issue
168
169GitHub matches `close|fixes|resolves|…` + an issue reference without parsing the
170sentence around it, so the natural way to *disclaim* closure closes the issue.
171Markdown emphasis between verb and number doesn't break the match either:
172
173```
174Does **not** close #162 → GitHub reads `close #162`, and closes it
175```
176
177- **Check**: `gh issue view <n> --json closedByPullRequestsReferences` names the
178 closer; a `closedAt` one second after a merge is automation, not a decision.
179- **Fix**: never let a closing verb precede an issue number you don't mean to
180 close — `Related: #162`, `Unblocks #162`, or `#162 stays open — it needs …`.
181- **Recovery**: `gh issue reopen` works (nothing was deleted, unlike #2), then
182 comment so the next reader doesn't re-derive it.
183
184### It is not only negation — plain past tense in a follow-ups section does it
185
186The rule above reads as "watch out for the word *not*". The trap is wider: **any**
187closing verb adjacent to a reference matches, including one merely *describing*
188what already happened, and including a reference wrapped in a **markdown link**.
189
190> Observed 2026-08-16 (comfyui-nodes fleet). A PR body's follow-ups section
191> asked for an issue to be re-opened, describing it as one this bug had
192> **closed** on a false premise. GitHub matched the closing verb against the
193> bracketed cross-repo link that followed it and closed the issue on merge — a
194> line whose entire purpose was to request a re-open. The author had cited this
195> very rule earlier in the same session.
196
197Three amplifiers:
198
199- **A bracketed cross-repo link still matches.** A closing verb followed by a
200 markdown-linked `owner/repo#N` is a match, and a naive audit regex like
201 `keyword\s+[\w./-]*#\d+` misses it on the `[` — then reports the body clean.
202- **A shared fleet body multiplies it.** The same text went into 13 sweep PRs, so
203 13 merges each targeted the same issue.
204- **The actor is *you*, not a bot** — the timeline reads `closed by <you>` seconds
205 after the merge. Don't rule out a closing keyword because no bot appears.
206
207### And not only assertion — a keyword you are *quoting* still fires
208
209The two forms above are sentences that *mean* something about an issue. The
210third means nothing about it at all: the keyword appears inside an **example, a
211fixture, or a regex you are documenting**. GitHub does not care that the line is
212inside backticks, a code fence, or a sentence whose subject is the audit itself.
213
214> Observed 2026-08-17 (FVH `infrastructure` #2213). A PR body's verification
215> section reported that a closing-keyword scan returned zero hits, and quoted
216> its two control fixtures inline as evidence. The scan it was reporting on had
217> genuinely passed — on the *ADR file*. Run against the PR body, the same regex
218> matched the body's own worked examples, which would have closed an unrelated
219> issue on merge. Caught only because the scan was re-run against the artefact
220> about to be published, rather than the one it was describing.
221
222Two things generalise:
223
224- **Scan the artefact you are about to publish, not the one you were auditing.**
225 A PR body, an issue comment, and a rules file each need their own pass; a clean
226 result on one says nothing about the others.
227- **Do not reproduce the fixture in prose that ships.** Describe it — "control-
228 tested against a bare reference and a bracketed cross-repo link, both found" —
229 and keep the literal strings in the throwaway file. Documenting the trap is
230 the one context where you are *guaranteed* to type the trap.
231
232**Audit the body before merging, with a control.** Scan every PR you are about to
233merge for a closing verb adjacent to an issue reference, allowing an optional
234`[` and an optional `owner/repo` between them, and prove the scan works by
235confirming it finds a reference you *know* is there. A negative from an
236unvalidated regex is worth nothing — the first audit run in the case above
237reported the fleet clean.
238
239The damage is a **false status report**: 2026-08-05, a PR body written to
240disclaim closure closed loractl #162 at merge, and the session reported it open
241for two turns afterward. Like #2, GitHub does this silently — only re-reading
242issue state from the API catches it.