Skip to content
Back to skills

Prep Pr Close Keyword Auto Closes Issue

ASecurity

Diagnose and prevent the trap where a scaffolding/prep/planning/handoff PR (one that ships a paste-ready prompt, an ADR, an implementation plan, or any docs-only artefact describing FUTURE work) contains a close-keyword like `closes #N` / `fixes #N` / `resolves #N` in its TITLE or BODY, which GitHub auto-applies at merge time — closing issue #N BEFORE the actual implementation work has been done. Use when: (1) `gh issue view <N>` reports the issue CLOSED but you know the work hasn't run, (2) ...

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 6, 2026
ai-agentspythongobashsqlgitapi

Works with

  • claude code
  • cli
  • api

Security analysis

A100/100

Scanned September 6, 2026

npx -y skills add wan-huiyan/agent-traffic-control --skill prep-pr-close-keyword-auto-closes-issue --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Prep Pr Close Keyword Auto Closes Issue?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Prep Pr Close Keyword Auto Closes Issue
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/wan-huiyan-prep-pr-close-keyword-auto-closes-issue/badge)](https://www.skillsdirectory.com/skills/wan-huiyan-prep-pr-close-keyword-auto-closes-issue)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: prep-pr-close-keyword-auto-closes-issue
description: |
  Diagnose and prevent the trap where a scaffolding/prep/planning/handoff PR
  (one that ships a paste-ready prompt, an ADR, an implementation plan, or
  any docs-only artefact describing FUTURE work) contains a close-keyword
  like `closes #N` / `fixes #N` / `resolves #N` in its TITLE or BODY, which
  GitHub auto-applies at merge time — closing issue #N BEFORE the actual
  implementation work has been done. Use when: (1) `gh issue view <N>` reports
  the issue CLOSED but you know the work hasn't run, (2) you're about to open
  a PR shipping a "next-session prompt" / "implementation plan" / "ADR
  proposal" / "scaffolding" and the title or body references an issue,
  (3) `gh api repos/<O>/<R>/issues/<N>/timeline` shows the closing commit is
  a docs-only PR (only `docs/handoffs/`, `docs/plans/`, `docs/decisions/`,
  or `docs/specs/` paths changed), (4) a fresh session is asked to execute
  an issue that already shows as CLOSED. Different from
  `gh-squash-merge-closes-only-one-issue` (THERE: one of many issues closed
  on merge; HERE: an issue closed correctly per its keyword but at the wrong
  TIME — during prep, not during implementation). Includes a paste-ready
  diagnostic snippet, a reopen-comment template, an audit recipe for scanning
  closed issues for false-positives across a repo, and a prevention
  convention (use bare `#N` references in non-implementation PRs). v1.2.0 adds
  the NEGATED-KEYWORD variant: writing `Does not close #N` / `not resolving #N`
  / `partial — closes #N later` in a commit or PR body STILL auto-closes #N
  (GitHub's parser ignores the negation), and this fires for CODE PRs too (not
  just docs/prep) — and squash-merge reads the COMMIT body, which can differ
  from the PR description. Use when an issue closed despite a "does not close"
  hedge, or a partial-implementation PR closed a multi-part issue.
author: Claude Code
version: 1.2.0
date: 2026-05-29
disable-model-invocation: true
---

# Prep PR close-keyword auto-closes issue prematurely

## Problem

You wrote a docs-only PR shipping a paste-ready prompt / implementation plan /
ADR proposal / scaffolding for future work on issue #N. The PR title looked
like:

> `docs(s171): next-session prompt — backfill historical sf_converted (closes #672)`

or the PR body opened with:

> Paste-ready S171 prompt for the historical `sf_converted` backfill that
> pre-empts Q3 2026 calibration retrain. Closes-ready for issue #672 (P1).

The PR squash-merged cleanly. Hours or days later, someone (you or the next
session) starts executing the prompt. They run `gh issue view 672` and find:

```json
{"closed":true,"closedAt":"2026-05-11T11:27:12Z","stateReason":"COMPLETED"}
```

But the work hasn't run yet. The issue closed because GitHub's keyword parser
treated `closes #672` in the prompt PR's title/body as a close-keyword and
fired at merge time — even though the merged diff was a docs-only handoff
file, not the implementation.

This silently misrepresents repo state. Stakeholders asking "what's left?"
get a falsely-clean issue list. Future audits ("we shipped #672 — where's
the postmortem?") chase ghosts.

## Context / Trigger Conditions

Use this skill when ANY of these hold:

1. **Discovery (after the fact)**: `gh issue view <N> --json state,closedAt,stateReason`
   shows CLOSED, but you have direct evidence (a worktree branch, a prompt
   doc, a session handoff) that the implementation hasn't run.

2. **Pre-flight check at execution time**: A fresh session is asked to
   execute the work for issue #N, and the issue already shows CLOSED. The
   prompt itself is what closed it.

3. **Pattern-matching the closing PR**: `gh api repos/<O>/<R>/issues/<N>/timeline --jq '.[] | select(.event=="closed") | .commit_id'`
   resolves to a commit whose PR (`gh pr view <M> --json files,title`)
   touches only `docs/handoffs/`, `docs/plans/`, `docs/decisions/`,
   `docs/specs/`, or `docs/proposals/` paths.

4. **Authoring a non-implementation PR**: You're ABOUT to open a PR shipping
   a prompt, plan, ADR, handoff, or scaffolding doc, and the title or body
   currently contains a close-keyword (`closes`, `fixes`, `resolves` +
   tense variants: `closed`, `closing`, `fixed`, `fixing`, `resolved`,
   `resolving`) followed by an issue reference. STOP — this is the trap.

5. **Repo-wide audit**: You suspect the trap has fired across multiple
   issues over time and want a mechanical scan.

Don't use this skill when:

- The closing PR's diff actually implements the issue's acceptance criteria
  (true-positive close — different problem).
- The issue is closed but should be reopened for an unrelated reason (drift
  from spec, regression discovered later) — different cause class.
- Multiple issues referenced in one PR and only some closed — that's
  `gh-squash-merge-closes-only-one-issue`'s domain.

## Solution

### Step 1 — Confirm the trap fired

Run the timeline diagnostic on the suspect issue:

```bash
gh api repos/<OWNER>/<REPO>/issues/<N>/timeline \
  --jq '.[] | select(.event=="closed") | {created_at, commit_id, source: (.source.issue.number // .source.pull_request.number // null)}'
```

If `commit_id` is non-null, find the PR that produced it:

```bash
gh api repos/<OWNER>/<REPO>/commits/<COMMIT_ID>/pulls --jq '.[] | {number, title, mergedAt}'
```

Then inspect that PR's diff:

```bash
gh pr view <M> --json files,title,body --jq '{title, files: [.files[].path]}'
```

**Verdict criteria**:

| Files touched | Title/body has close-keyword? | Verdict |
|---|---|---|
| Only `docs/handoffs/`, `docs/plans/`, `docs/decisions/`, `docs/specs/`, `docs/proposals/` | YES | **Trap fired — false-positive close** |
| Any production code (`*.py`, `*.sqlx`, `*.html`, `*.css`, `*.ts`, `*.go`, etc.) | YES | True-positive — implementation shipped |
| Anything | NO | Issue was closed manually or by another path — different cause |

### Step 2 — Reopen the issue with a transparent comment

```bash
gh issue reopen <N> --comment "Re-opened: PR #<M> (the <prompt|handoff|plan|ADR-proposal> doc) contained \`closes #<N>\` in its <title|body>, which GitHub interpreted as a close-keyword on merge. However, the PR's diff is <scaffolding/prep/plan/handoff doc> only — it does not satisfy this issue's acceptance criteria. The actual implementation work has not yet been done. Will re-close when the implementation PR lands with real verification numbers."
```

### Step 3 — Re-close on real implementation

When the implementation PR ships, write its title/body so GitHub's parser
re-fires correctly:

```
fix(<area>): <what shipped> (closes #N)
```

Include verification numbers / acceptance-criteria checkboxes in the body so
the close trail has provenance.

### Step 4 — Prevention going forward

When authoring a non-implementation PR, **never** use close-keywords for
issues the PR doesn't actually resolve. Use bare `#N` references instead:

| Don't | Do |
|---|---|
| `docs: prompt for backfill (closes #672)` | `docs: prompt for backfill #672` |
| `Closes #672, #673` | `Tracks #672, #673` or `References #672, #673` |
| `Fixes #500 (next-session work)` | `Plans fix for #500` |

The close-keyword should appear ONLY in the PR that contains the
implementing diff. Save it for last.

> **⚠️ Negation does NOT save you.** GitHub's keyword parser is a flat
> substring scan — it is **not** negation- or context-aware. Writing
> `Does not close #N`, `partial — does not resolve #N`, `not closing #N yet`,
> or `closes #N later` in a commit/PR body **still closes #N** on
> default-branch merge, because the parser sees the literal `close #N` and
> ignores every surrounding word. If you must mention the issue in a hedged
> way, use a NON-keyword verb: `Scopes only part of #N`, `Partial for #N`,
> `Defers the rest of #N`. **Never put any close-keyword (`close/closes/closed/
> fix/fixes/fixed/resolve/resolves/resolved`) adjacent to `#N` in prose you
> don't intend to act on — even negated.** (This is also why the audit recipe's
> "negated reference" row in §"Regex false-match patterns" is a false match for
> *your audit* but a TRUE close for *GitHub* — the issue really did close.)

### Step 5 — Audit recipe (repo-wide scan)

To find historical false-positives, run a mechanical scan on merged PRs in
a window:

```bash
mkdir -p /tmp/audit
gh pr list --state merged --limit 500 \
  --search 'merged:>2026-02-15 (closes OR fixes OR resolves)' \
  --json number,title,body,mergedAt,files \
  > /tmp/audit/merged_with_keywords.json

# Extract (pr, issue) pairs via close-keyword regex
jq -r '.[] | "\(.number)\t\(.title)\t\(.body // "")\t\(.mergedAt)"' \
  /tmp/audit/merged_with_keywords.json \
  | python3 -c '
import sys, re
PAT = re.compile(r"\b(close[sd]?|closing|fix(?:e[sd]|ing)?|resolve[sd]?|resolving)\b[\s:]*#(\d+)", re.IGNORECASE)
for line in sys.stdin:
    parts = line.rstrip("\n").split("\t", 3)
    if len(parts) < 4: continue
    pr, title, body, merged = parts
    issues = {int(m.group(2)) for blob in (title, body) for m in PAT.finditer(blob)}
    for iss in sorted(issues):
        print(f"{pr}\t{iss}\t{merged}\t{title[:80]}")
' > /tmp/audit/pr_closes_issue.tsv

# Filter to prep-style PRs (docs-only paths OR prep-keyword title)
jq -r '.[] |
  select(
    (.title | test("(?i)(prompt|handoff|plan|spec|proposal|next-session|prep|scaffolding|kickoff)"))
    or
    ((.files // []) | map(.path) | all(startswith("docs/")))
  )
  | "\(.number)\t\(.title)"
' /tmp/audit/merged_with_keywords.json > /tmp/audit/suspect_prs.tsv

# Cross-reference: PRs that both look prep-style AND closed an issue
join -t$'\t' -1 1 -2 1 \
  <(sort -k1 /tmp/audit/suspect_prs.tsv) \
  <(sort -k1 /tmp/audit/pr_closes_issue.tsv | cut -f1,2)
```

Each row in the join output is a candidate false-positive: investigate per
Step 1's verdict criteria, then reopen per Step 2 if confirmed.

## Verification

After Step 2 reopen:

```bash
gh issue view <N> --json state --jq .state
# Should print: OPEN
```

After Step 3 re-close (real implementation merged):

```bash
gh issue view <N> --json state,closedAt,stateReason --jq .
# Should print: state=CLOSED, closedAt = recent timestamp, stateReason=COMPLETED
# AND the closing PR's diff should touch implementation paths, not just docs
```

After Step 5 audit on a clean repo: zero rows in the join output (or only
rows that pass Step 1's verdict as true-positive closes).

## Example

**Concrete instance (the client, 2026-05-11, S171b)**:

- Issue #672 (P1): "S166 follow-up — backfill historical sf_converted
  partitions for Q3 2026 calibration retrain"
- PR #712 (merged 2026-05-11T11:27:13Z): title
  `docs(s171): next-session prompt — backfill historical sf_converted (closes #672)`
  — diff was only `docs/handoffs/session_171_672_backfill_prompt.md`
  + `docs/generate_roadmap_backlog.py` + regen of `docs/site/*.html`
- Closing keyword fired on merge → #672 CLOSED at 11:27:12Z
- Discovery: S171b session opened, asked to execute the backfill prompt,
  ran `gh issue view 672` and saw CLOSED
- Diagnosis: ~3 min via `gh api timeline` → found PR #712 was the closer →
  inspected its files → all `docs/` → trap confirmed
- Mitigation: reopened #672 with transparent comment; ran the backfill
  (67 partitions, 21,774 rows, 2.77% post-backfill miss rate); merged
  PR #716 with `Closes #672` in body → #672 re-closed at 11:50:17Z with
  the real verification numbers

Cost of trap if not caught: a fresh session months later asking "is the
backfill done?" would see #672 CLOSED and skip it. The Q3 2026 calibration
retrain would run against 96%-wrong labels for partitions written before
2026-05-10.

## Notes

- **Why GitHub does this**: GitHub's auto-close feature is convenient when
  the PR is the implementation. It has no way to distinguish "this PR ships
  the work for #N" from "this PR ships a PLAN for the work for #N." Both
  are valid uses of `#N`; only the former should use a close-keyword.

- **The trap is silent — it doesn't error or warn.** The PR merges, the
  issue closes, and the close trail looks normal in the issue's timeline.
  Only direct knowledge that the work hasn't been done surfaces the bug.

- **Auto-merge label workflows amplify the risk.** If your repo has a
  "merge on green" automation that triggers off label + CI, a prep PR with
  a close-keyword + auto-merge label will close the issue without any
  human reading the title.

- **Half-measures don't work**: lowercase `closes` and uppercase `Closes`
  both fire. So do `Close`, `closed`, `closing`, `closes:`, and embeddings
  in larger phrases like `"closes-ready for #672"`. GitHub's parser is
  surprisingly permissive. The only safe way to mention an issue
  non-closingly is to drop the keyword entirely.

- **CI lint guardrail (recommended for high-traffic repos)**: add a
  workflow that flags PRs where (a) title or body contains a close-keyword
  + `#N` reference AND (b) the diff only touches `docs/` paths. Either
  block the merge or post a comment requiring human review. Sample rule:

  ```yaml
  # .github/workflows/prep-pr-close-keyword-lint.yml
  on: pull_request
  jobs:
    lint:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v5
        - run: |
            CLOSING=$(echo "${{ github.event.pull_request.title }} ${{ github.event.pull_request.body }}" \
              | grep -Eio '\b(close[sd]?|closing|fix(e[sd]|ing)?|resolve[sd]?|resolving)\b[ \t:]*#[0-9]+' || true)
            FILES=$(gh pr view ${{ github.event.pull_request.number }} --json files --jq '[.files[].path] | join(" ")')
            DOCS_ONLY=$(echo "$FILES" | tr ' ' '\n' | grep -v '^docs/' | wc -l)
            if [[ -n "$CLOSING" && "$DOCS_ONLY" -eq 0 ]]; then
              echo "::error::PR has close-keyword '$CLOSING' but diff is docs-only — likely the prep-PR trap. Use bare #N reference instead."
              exit 1
            fi
  ```

- **Reciprocal: an implementation PR WITHOUT a close-keyword leaves the
  issue OPEN.** Less harmful (issue triagers can manually close), but
  worth catching too. The same lint can flag the inverse: implementation
  diff (touches non-docs paths) + references `#N` without a close-keyword.

## Empirical finding (v1.1.0 addendum, 2026-05-11 S172b audit)

Running the audit recipe across an 85-day window on the source repo
(449 merged PRs, 122 with close-keywords, 31 unique closed issues
suspect-bucketed) returned **0 false-positives**. Reframed risk profile:

1. **In ~22 of 26 suspect rows the docs PR auto-close was a SECOND
   fire on an issue already closed by an earlier implementation PR
   (no-op).** GitHub registers the new closing-PR ref in
   `closedByPullRequestsReferences`, but the issue's state didn't change.
   So the audit's high false-positive *signal* count overstates the real
   defect count by ~6×.

2. **The trap materially fires only when the prompt/handoff/plan PR
   is the FIRST PR to land in the issue's life cycle.** That was
   exactly the original #672 case. Everywhere else, the implementation
   PR closes the issue first (intentionally), and the handoff doc is
   redundant attribution.

3. **Practical priority adjustment**: the CI lint guardrail in this
   skill is belt-and-suspenders, not load-bearing. The bare-`#N`
   convention in non-implementation PRs is the load-bearing fix. Repos
   without the lint installed are not in active danger; the trap is
   genuinely rare in practice.

## Empirical finding (v1.2.0 addendum, 2026-05-29 — negated-keyword authoring trap)

A **code** PR (not a prep/docs PR) auto-closed an issue the author explicitly
meant to leave open, via a **negated** close-keyword.

- The PR implemented only a *partial* slice of a multi-part drift issue (#190).
  The author deliberately did NOT use a close keyword in the title (`(#190 partial / #193)`)
  and dropped a covered/deferred breakdown comment on the issue, intending it to
  stay open.
- But the squash-merge **commit body** contained the sentence **`Does not close #190.`**
  On default-branch merge, GitHub parsed the literal `close #190`, ignored
  `Does not`, and closed #190 as COMPLETED — orphaning the deferred items inside
  a closed issue.
- Detection: `gh issue view 190 --json closedAt,stateReason` + the timeline
  (`gh api repos/<O>/<R>/issues/190/timeline`) showed the closing `commit_id`
  was the author's own merge commit. Fix: `gh issue reopen 190` with a comment
  explaining the auto-close and listing the still-open work.

**Takeaways added to this skill:**
1. The trap is **not limited to prep/docs PRs** — any PR (including code) whose
   commit/PR body contains a close-keyword adjacent to `#N` will close `#N`.
2. **Negation/hedging does not protect you** — see the ⚠️ callout in Step 4.
   The "Negated reference" row in the false-match table was corrected: it is a
   false match for *your audit* but a *real close* by GitHub.
3. Squash-merge uses the **commit body**, which may differ from the PR
   description — scan the actual merge-commit message, not just the PR UI body,
   when both authoring and auditing.

### Regex false-match patterns to suppress

The keyword regex
`\b(close[sd]?|closing|fix(?:e[sd]|ing)?|resolve[sd]?|resolving)\b[\s:]*#(\d+)`
will also match these *non-auto-close* shapes — filter them out before
escalating to a real auto-close audit:

| Pattern | Example | Why it's not an auto-close |
|---------|---------|-----------------------------|
| List-item count | `Fix #1 (literal-pin tests), …` | "#1" is a list count, not an issue ref. GitHub's parser does link it, but if `#1` is a merged PR (not an issue), no auto-close occurs. |
| Past-tense narrative in body | `Closed #115 (stale memory hygiene)` | GitHub's `closedByPullRequestsReferences` empirically returns empty for past-tense `Closed` in body context (no `: #N` immediately after a keyword in the title). Manual closure was the real path. |
| Quoted/code-fenced reference | `` `closes #123` `` inside a code-fence | GitHub does **not** parse keywords inside code fences. |
| Negated reference | `does NOT close #N — out of scope` | **FALSE-MATCH FOR YOUR AUDIT, BUT A REAL AUTO-CLOSE BY GITHUB.** The author didn't *intend* to close (so it's noise when auditing intent), but GitHub's parser ignores the negation and **closes #N anyway**. So when *authoring*, treat this as dangerous (see v1.2.0 addendum); when *auditing others'*, flag it as a likely-unintended close that still fired. |

### Verification gate for the audit recipe

When the recipe surfaces a "suspect" (PR, issue) pair, run this gate
before reopening:

```bash
# Is there ANY merged non-docs PR that mentions the issue?
gh pr list --repo "$REPO" --state merged --limit 30 \
  --search "in:title,body $ISSUE_NUM" \
  --json number,title,files \
  | jq '[.[] | select((.files // []) | map(.path) | any(startswith("docs/") | not))] | .[] | .number'
```

If any non-docs PR is returned, the implementation probably shipped via
that PR; mark the audit row TRUE-POSITIVE (correctly closed in spirit)
instead of FALSE-POSITIVE. Reserve the reopen action for pairs where
**only** docs PRs ever touched the issue **and** the issue's deliverable
is not itself a docs artifact (runbook, probe-analysis, ADR).

### Pragmatic audit cadence

Quarterly is sufficient unless the repo has shipped >100 PRs/month or
has had a recent incident. Even on the originating repo at ~200 PRs/month
through April-May 2026, a 90-day audit found 0 material false-positives
once the verification gate was applied. Burning daily/weekly compute on
this audit is not the right priority surface.

## References

- Sister skill `gh-squash-merge-closes-only-one-issue` v1.0.0 — different
  failure mode (multi-issue keyword binding on one PR).
- Sister skill `stacked-pr-base-branch-deletion-auto-closes-dependent`
  v1.0.0 — different auto-close trap (stacked PR base deletion side-effect).
- GitHub docs on linking PRs to issues:
  https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue
  (notes the keyword list but does not warn about the prep-PR misuse pattern).
- Discovery context: the-project-repo S171b session
  (2026-05-11), issue #672, PR #712 (the closing PR), PR #716 (the actual
  implementation PR). Audit prompt template at
  `docs/handoffs/session_172b_closed_issue_audit_prompt.md` in that repo
  (filename uses `172b` suffix because a sibling S172 session in the same
  repo claimed `session_172_*` for an /actions revalidation plan handoff).

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.