# Lisa Drive Pr To Merge

> This skill should be used to drive a pull request all the way to MERGED, handling ANYTHING that blocks the merge. It enables auto-merge when the repo supports it (direct-merge fallback otherwise), keeps the branch rebased/synced and resolves merge conflicts, fixes failing CI/deploy checks, addresses and resolves every human and bot review comment (CodeRabbit, etc.) — implementing valid feedback and replying-then-resolving invalid feedback — dismisses stale CHANGES_REQUESTED gates, and verifies the fix actually shipped — both merge ancestry and that a deploy/release run fired for the merge SHA (auto-merge race + zero-deploy-run check). Composable and inline — invoked by other skills (e.g. git-submit-pr, implement, sync-down) via the Skill tool, never as a standalone user command.

- Skill: `codyswanngt/lisa-drive-pr-to-merge` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add codyswanngt/lisa-drive-pr-to-merge`
- Raw SKILL.md: https://api.skillmd.com/api/skills/codyswanngt/lisa-drive-pr-to-merge/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: codyswanngt (https://skillmd.com/u/codyswanngt)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/codyswanngt/lisa-drive-pr-to-merge

---


# Drive PR to Merge

Single source of truth for the "watch a PR and clear every blocker until it
merges" loop. Other skills delegate here instead of re-implementing it. Runs
**inline** (the current agent does the fixes — it does not require an agent team).

## Inputs (`$ARGUMENTS`, all optional)

- `pr=<number|url>` — the PR to drive. Default: the PR for the current branch
  (`gh pr view --json number,url,baseRefName,headRefName,state`).
- `merge_method=<merge>` — strategy for both auto-merge and the direct-merge
  fallback. Default and only accepted value: `merge`.
  - **Never squash** — squashing flattens `chore(release): X.Y.Z [skip ci]`
    commits and breaks release promotion detection.
  - **Never rebase.** REJECT `merge_method=rebase` up front with a clear message
    rather than accepting it and verifying it wrongly. GitHub's rebase-and-merge
    rewrites the commits — new SHAs — and creates no merge commit, so the
    shipped-verification in section 3 cannot succeed: `verify_commit` is the
    pre-rebase SHA and is not an ancestor of the base branch afterwards, and the
    merge-parent assertion has nothing to assert against. A successful merge
    would then report as a FAILED verification and drive a false fix-forward,
    which is worse than refusing the input. See CodySwannGT/lisa#2316.
- `verify_commit=<sha>` — the commit that MUST end up in the merged base (for the
  ancestry check). Default: the PR head at the time this skill starts.
- `auto_merge=<true|false>` — whether this skill is allowed to merge the PR at
  all. Default `true`. `true` is permission to merge, **not** an instruction to
  arm the latch immediately: arming is gated on the review context having done
  work (section 1's arm gate), and under a vacuous context the merge is made
  here, in the open, or not at all. With `auto_merge=false` the PR is
  deliberately left for a human:
  skip the **entire** "## 1. Enable auto-merge" step — including its
  direct-merge capability fallback — and never run any `gh pr merge` variant.
  Still drive every blocker per `on_blocker` (green checks, resolved reviews,
  synced branch), then stop at the `awaiting-human` terminal state below. A
  green, open, un-merged PR is the *success* outcome of this mode, not a hang.
  Used by learning-persistence flows whose low-confidence PRs must wait for a
  human (`lisa-persist-learning`).

  **`auto_merge` is caller-time and nothing re-reads it.** That is what the hold
  label (section 0) exists for: the same behaviour, raisable by anyone at any
  point while the loop runs. Both routes land in `awaiting-human`; they differ
  only in who can take them and when.
- `on_blocker=<fix|report>` — what to do when a blocker needs code or review work.
  Default `fix`.
  - **`fix`** (the full loop): resolve conflicts, fix failing checks, address +
    resolve review comments, dismiss stale review gates — drive until merged.
  - **`report`** (diagnose & mechanically nudge only): perform just the safe,
    idempotent, non-destructive actions — ensure auto-merge is enabled (when
    `auto_merge=true` **and the arm gate cleared**; arming against a review that
    did no work is authorising a merge, which is not diagnosis) and, if the
    PR is `BEHIND` but otherwise clean, run `gh pr update-branch` only when the
    base branch requires strict up-to-date checks. For **anything** that would
    require editing code, resolving threads, or dismissing a review, **do not
    act** — stop and return a structured blocker classification
    (`merged` / `will-merge-after-resync` / `blocked:<conflict|checks|changes_requested|deploy|pending-auto-fix|unreviewed>`)
    so the caller applies its own policy. This is the mode `repair-intake` and the
    build-intake skills use to diagnose-and-route without fixing in place.

Resolve `<owner>/<repo>` from `gh repo view --json nameWithOwner` (or the PR URL).
Use plain `gh` + `git` so Claude and Codex execute identically.

## 0. Take the babysitter lease

This skill is the branch's owner while it runs. Declare that ownership so any CI
repair automation stands down instead of pushing competing fixes to the same
branch (the single-writer rule):

```bash
gh label create "lisa:babysitter-on-duty" \
  --description "A drive-pr-to-merge session is actively driving this PR; CI auto-fix must stand down" \
  --color FBCA04 || true  # tolerate only already-exists; check the next step
gh pr edit <pr> --add-label "lisa:babysitter-on-duty"
gh pr view <pr> --json labels \
  --jq '[.labels[].name] | contains(["lisa:babysitter-on-duty"])'
```

Verify the final command prints `true` before driving. If the label could not
be attached (for example, no label-write permission), retry once; if it still
fails, surface a warning that the branch is unleased — the CI auto-fix
workflow may engage in parallel — and watch for its `claude-auto-fix-*` PR
per section 2f while driving.

The auto-fix workflow reads freshness from the label's most recent `labeled`
timeline event and treats stamps older than its TTL (default 90 minutes) as
stale. **Refresh the lease** whenever more than ~30 minutes have passed since
the last stamp while the watch loop is still running — a refresh is a
remove + re-add (re-adding an existing label does not create a new timeline
event):

```bash
gh pr edit <pr> --remove-label "lisa:babysitter-on-duty"
gh pr edit <pr> --add-label "lisa:babysitter-on-duty"
```

**Release the lease** (remove the label) at every terminal state — merged,
closed, or a hard block handed to a human. A crashed session that never
releases is why the TTL exists; do not rely on it as the normal release path.

### The hold gate — a stop anyone can raise

Section 0 owns this skill's two PR labels: the lease it **writes** to declare
ownership, and the hold it **reads** to be told to stop. Same mechanism, pointed
at different things — a lease is a claim, a hold is a decision.

**The label** is `lisa:hold` by default, overridable per project at
`github.labels.merge.hold`, resolved local-over-global like every other
configured name. Resolve it ONCE at startup and reuse the resolved string; a
name re-resolved each iteration can change identity mid-loop for no reason a
reader could reconstruct afterwards.

```bash
HOLD_LABEL=$(
  jq -r 'first(.github.labels.merge.hold | strings)' \
    .lisa.config.local.json .lisa.config.json 2>/dev/null | head -n1
)
HOLD_LABEL="${HOLD_LABEL:-lisa:hold}"
```

**What it means:** exactly `auto_merge=false`, for as long as it is present. Not
a new concept and not a new terminal state — the same disarm, the same
fail-closed re-read, the same `awaiting-human` landing. The only new surface is
one label read.

**Why it exists.** `auto_merge` is decided by the caller at invocation, and
nothing re-reads it, so the only person who can stop the merge is the person who
started it. Anyone else — a reviewer who opens the PR while the loop is driving
it, someone holding a PR another session armed — has no lever and loses the
race. Measured (#3558): four explicit `gh pr merge --disable-auto` calls were
each followed by a re-arm 7–25 seconds later, and the PR merged two seconds
after its final check went green. **The hold did not fail; it lost a race.** A
signal the loop itself reads cannot be raced, because the loop is what reads it.

#### Evaluate it in two places, and re-read it every iteration

- **Before arming** (section 1) — a PR already carrying the label is never armed.
- **On every watch-loop iteration** (section 2), before acting on any blocker.

Re-reading is the entire point. A check that runs once at startup is a
caller-time signal wearing a loop's clothing and fixes nothing, because the case
that matters is a label applied **after** the loop is already running. If you
are changing this and it is convenient to read the label once, you have
reintroduced the defect.

```bash
gh pr view <pr> --json labels --jq "[.labels[].name] | index(\"$HOLD_LABEL\") != null"
```

**Match the resolved name exactly — never a prefix.** `lisa:babysitter-on-duty`
is a label this skill applies to itself and shares the `lisa:` prefix; a
prefix match would make the loop stop on its own lease, which is a deadlock
that looks like a human decision.

#### When held

1. Run the existing `auto_merge=false` disarm (section 1) — disable the latch
   once and prove with the fail-closed re-read that it took.
2. Return `awaiting-human:held` (section 4).
3. **Do not remove the label.** Removing a human's signal is unrecoverable from
   inside the loop; leaving a stale one costs a cycle and is visible. Only
   whoever applied it takes it off, and a later invocation then resumes normally.

Do not keep polling while held. Held is terminal for this run, not a pause to
wait out — the loop has been told a human is looking, and continuing to drive is
the behaviour the label exists to stop.

**The disarm is once and terminal: never re-arm the latch afterwards, and never
resume driving in the same run.** This is what keeps a hold compatible with the
armed-across-fix-pushes rule in section 1, which otherwise forbids disarming on
the `auto_merge=true` path. That rule exists to prevent a *race* — disarm, then
re-arm, repeatedly, while still driving — and a hold does neither half of it: it
turns the latch off exactly once and then stops. A hold that re-armed, or that
kept driving afterwards, would be the race that rule names, wearing a label.

#### When the read fails

**Fail toward hold.** The asymmetry decides it: a false hold costs one cycle and
is visible in a terminal report someone can act on, while a false no-hold merges
past a human's objection and cannot be undone. Where the two errors are
unequal, take the recoverable one.

But a read that keeps failing must not become a silent deadlock, so the failure
is **reported as its own outcome** rather than dressed up as a decision:

- retry the read once;
- if it still fails, disarm as above and return **`awaiting-human:hold-unknown`**,
  naming the error.

`awaiting-human:held` and `awaiting-human:hold-unknown` are different facts and
an operator needs to know which: the first says *a human stopped this*, the
second says *I could not tell whether a human stopped this.* Collapsing them
would hide a broken permission behind a human decision, and nobody would ever
look.

**A PR with no hold label is driven exactly as it is today.** This gate adds a
stop; it does not add a block. If the label is absent and the read succeeded,
nothing about the run changes.

## 1. Enable auto-merge

**Gate: the hold label is absent (section 0's hold gate), and `auto_merge=true`
(the default).** Evaluate the hold gate BEFORE anything in this section,
including the arm gate below and the direct-merge capability fallback: a held PR
is not armed, not merged directly, and not driven — it disarms once and returns
`awaiting-human:held`. Checking after arming would authorise the merge the label
exists to prevent, which is the same ordering error the arm gate itself fixes.

When `auto_merge=false`,
skip the enable step and its capability fallback — do not enable auto-merge,
and do **not** use the capability fallback below: on a repo that disallows
auto-merge, an `auto_merge=false` PR must stay OPEN for human triage, never be
silently direct-merged.

With `auto_merge=false`, also **disarm any pre-existing auto-merge latch**
before entering the watch loop — skipping the enable step is not enough when a
prior session (or `lisa-git-submit-pr`'s default path) already armed the PR,
because an armed latch would still merge the instant checks go green:

```bash
armed=$(gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest)
if [ "$armed" != "null" ] && [ -n "$armed" ]; then
  gh pr merge <pr> --disable-auto
fi
gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest   # must print null
```

**Then declare the hold**, so the deliberate case stays distinguishable from the
defect. `/lisa:queue-status`'s arming sweep (#3903) reports every open PR whose
`autoMergeRequest` is `null`, because a green unarmed PR waits forever and no
other surface says so. An undeclared deliberate hold appears there as a finding
on every run, and a report that is wrong every run is one operators learn to
ignore:

```bash
gh pr edit <pr> --add-label "lisa:auto-merge-off"
```

Where that label does not exist in the repo, put the marker in the PR body
instead — the sweep reads either spelling — and **give it a reason**, because
the sweep prints one and prints `no reason declared` when it cannot:

```text
<!-- [lisa-auto-merge-off] reason=<why a human owns this merge> -->
```

Declaring the hold **suppresses it from the findings, not from the report**: the
sweep still counts and names every held PR. That is deliberate — a label that
turned a red sweep green and left no trace would be a bypass wearing the costume
of a fix.

If the disarm fails or the re-read still shows an armed `autoMergeRequest`,
**fail closed**: treat the PR as a hard block (section 4) and report that the
`awaiting-human` state was NOT reached — never proceed to a state in which the
PR could merge without a human. Once disarmed (or already unarmed), proceed
straight to the watch loop (section 2).

### The arm gate — never arm against a review that did no work

**This gate governs the `auto_merge=true` path, and it decides WHETHER to arm.
It does not touch the rule below that a latch, once armed, stays armed.** Those
are two different questions with two different answers: arming prematurely is
what this gate prevents; turning off a live latch is what the rest of section
1 forbids.

**Scope: repositories that declare a review check.** The defect is a required
review context reporting satisfied having read nothing, so where no such
context exists there is no false green to withhold the latch from, and arming is
unchanged. A repository with no `evidence_bearing_checks` in
`.github/required-checks.json` — or none at all — passes this gate immediately.
Holding those PRs would redden a whole fleet for a gate none of them asked for,
which is how a guard gets deleted rather than adopted.

Arming is a decision to merge, made in advance and executed by GitHub without
you. Everything this skill knows about the review — step (d)'s `reviewed` /
`NOT REVIEWED` verdict, the five sole-gate conditions, the mandatory
`MERGED — NOT REVIEWED` report line — is evaluated in the watch loop, which runs
AFTER the latch is on. So an armed latch never merges on that reasoning. It
merges on green checks, and whatever the loop would have concluded is simply
never consulted.

Measured (#3439): `auto_merge` defaults to `true` and `lisa-git-submit-pr` armed
the latch at submit time, before any review existed. Step (d)'s exception —
five live-verified conditions and a mandatory report line — could not fire on
any pull request, because GitHub had already merged it the moment CI went green.
A guard that cannot be reached is not a guard: the merge happened on the latch,
not on the reasoning.

**So the latch may only ever be armed against a review context that did work.**
The consequence is the whole point. An unreviewed merge then has exactly one
route left — step (d), which verifies its five conditions against a live poll
and is *required* to write `MERGED — NOT REVIEWED`. This does not forbid the
unreviewed merge; the owner's ruling on #3221 is explicit that a vendor
entitlement state must not redden every pull request. It makes the unreviewed
merge VISIBLE, by leaving it only one path that can produce it, and that path
reports itself.

#### Classify before arming

**Vacuity is the prover's answer, not a string you match here.** The repository
already ships the detector; consult it on the merge path instead of re-deriving
its vocabulary, which is how the two drift apart:

```bash
node scripts/check-skipped-required-checks.mjs --vacuity --pr=<pr> --json
```

When `scripts/` carries no installed copy, try the in-repo template at
`typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs` — the same
second address `review-evidence.yml` resolves. `--vacuity` is the mode that WAITS
for the declared checks to settle, which is exactly what "before arming" needs:
a pull request carries several intermediate statuses on one head SHA, and
judging the first one seen would classify every PR as vacuous.

Read `violations[].kind` and `inspected`, **never the exit code** — a named
entitlement waiver is report-only under every flag by the #3221 ruling, so a
zero exit is not evidence that a review happened:

| reading | what it means | arm? |
| --- | --- | --- |
| `inspected: true`, no review-related violation | the check did work | **arm** |
| `review_evidence_unsatisfied` | a review RAN AND OBJECTED | no — a real blocker; steps (d)/(e) |
| `review_evidence_waived` | a named vendor entitlement | no — vacuous |
| `vacuous_required_check` | success carrying a no-work description | no — vacuous |
| `unproven_required_check` | a description proving nothing either way, or no status at all | no — vacuous |
| `inspected: false` with `vacuity_none_declared` | this repository declares no review check | **arm** — out of scope, see above |
| `inspected: false`, any other refusal | something was supposed to be read and was not | no — an empty inspection is not a pass |

The last two rows are the same field and opposite answers, so read the refusal
kind rather than the `inspected` flag. `vacuity_none_declared` means there is
nothing to inspect; `vacuity_pr_unresolved`, `vacuity_checks_unreadable` and
`vacuity_no_checks_reported` mean there is, and it was not inspected — most
often a missing `actions: read`, which makes `gh` exit non-zero with empty
stdout and never says the word "permission". Collapsing those into one answer is
this file's own thesis about false greens, aimed at itself.

If the prover cannot be run at all, fall back to reading the check yourself and
require a POSITIVE reading before arming:

```bash
gh pr checks <pr> --json name,state,description,bucket \
  --jq '.[] | select(.name == "<review-context>")'
```

`<review-context>` is whatever `.github/required-checks.json` declares under
`evidence_bearing_checks` — do not assume `CodeRabbit`, and if nothing is
declared, arm normally (the scope rule above). Where one IS declared, absent a
description that positively states a review ran, treat the context as vacuous.
Fail closed: absence of evidence is not evidence of a review.

#### Vacuous and transient are different properties

A vacuous context is not automatically something to wait out, and this is the
distinction a naive wait-and-arm gets wrong — it waits forever on a repository
where the state never clears. **Only the rate limit has both properties.**

| description | vacuous? | clears on its own? |
| --- | --- | --- |
| `Review rate limited` | yes | **yes** — a throughput window |
| `Review skipped: manual review required for this OSS repository` | yes | **no** — a standing vendor policy for public repositories |
| `Review skipped: N files exceed the limit of M` | yes | **no** — not without splitting the pull request |
| `Review skipped` (bare, no stated reason) | yes | **unknown — treat as standing** |

A bare skip carries no machine-readable reason, so nothing distinguishes it from
the standing forms. Requiring a stated reason before treating a skip as vacuous
is how the commonest form gets missed.

- **Transient (the rate limit, and only the rate limit)** — do not arm yet. Wait
  the window out and re-request the review **at most once**, then re-classify.
  If it now proves work, arm. If it does not, stop and treat it as standing.
  **Never loop**: a retry loop re-triggers the very limit it is escaping, and
  measured on #3220 a single explicit re-request in this repository was
  acknowledged, went `Review in progress`, and settled back at
  `Review rate limited` two seconds later having reviewed nothing — minting a
  second hollow green. One attempt, then report.
- **Standing** — do not wait at all, and do not re-request. There is no window
  to wait out, so waiting produces a stall that reads as progress. Go straight
  to the watch loop and let step (d) adjudicate the merge in the open.

#### A latch somebody else armed

Not arming is not enough. `lisa-git-submit-pr`, an older Lisa version, or a
previous session may have left the latch on, and an armed latch merges the
instant checks go green no matter what this gate concluded. So when the gate
says do not arm, turn any existing latch off and prove it took — the same
mechanism and the same fail-closed re-read the `auto_merge=false` contract above
uses:

```bash
armed=$(gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest)
if [ "$armed" != "null" ] && [ -n "$armed" ]; then
  gh pr merge <pr> --disable-auto
fi
gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest   # must print null
```

If the re-read still shows an armed `autoMergeRequest`, **fail closed**: report a
hard block (section 4) rather than proceeding into a state where the PR can
merge with neither a review nor step (d)'s recorded decision.

**What this costs, stated plainly.** While the review context is vacuous the PR
cannot merge unattended, so a run that ends early leaves it open. That is the
opposite trade from the armed-across-fix-pushes rule below, and it is deliberate:
there, the thing at risk was a PR sitting unmerged; here, the thing at risk is
code shipping that nothing read. The cost is bounded — the moment the context
proves work, the latch is armed and the unattended behaviour returns — and the
open PR is REPORTED (`blocked:unreviewed`, section 4), not silently abandoned.

#### A nightly-E2E waiver is RE-DERIVED here, never replayed

Everything above reads STORED check results. That is fine for a gate whose
input is the code, and weaker than it looks for one whose input is *mutable
pull-request state a human can edit* — which is exactly what a nightly-E2E
bypass waiver is. A stored `bypassed` says what was true when the gate ran, and
the merge happens later.

Every way a waiver can change fires a pull-request event the gate subscribes to
— applying the label, removing it, editing the body — **except one**. A waiver
that runs out of hours produces no event at all, so no amount of re-running
sees it, and the stored green goes on saying green. Expiry is only visible to
re-deriving.

So before arming, ask the guard what is true NOW:

```bash
NIGHTLY_PR_NUMBER=<pr> node scripts/check-nightly-e2e-health.mjs --waiver-verdict --json
```

The same second address applies as above:
`typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs`. The mode needs
only a token, `GITHUB_REPOSITORY` and the pull request number — no suite table,
because the question here is "is the waiver still good?", not "is the nightly
green?".

Read `state`, and treat the exit code as its shorthand rather than its source:

| `state` | what it means | arm? |
| --- | --- | --- |
| `none` | nobody asked for a waiver; the gate stands on suite evidence | **arm** |
| `waived` | a waiver is valid *at this moment* | **arm** — the escape hatch working |
| `refused` | a waiver was requested and no longer holds | no — a stored `bypassed` must not carry this merge |
| `not_determined` | the live pull request could not be read | no — nothing was established |

`refused` and `not_determined` are kept apart on purpose. The first names a
lapsed waiver, what it covered and a remedy; the second says the question could
not be answered. Collapsing the second into "fine" is the failure the whole mode
exists against, and collapsing it into "the waiver is bad" invents a fact.

**Do not weaken the gate to get past this.** A genuine, still-valid waiver keeps
merging — that is the `waived` row, and it is the row to protect. If the waiver
has lapsed, either fix the red suite or re-apply a fresh waiver so a
maintainer's grant is dated from now; report `blocked:nightly-waiver-lapsed`
(section 4) rather than merging on the earlier green.

If the repository ships no nightly-E2E gate at all, the guard is absent and this
subsection does not apply — the same scope rule the vacuity gate uses.

Before enabling auto-merge, capture the live PR head and compare it to
`verify_commit`:

```bash
head_sha=$(gh pr view <pr> --json headRefOid -q .headRefOid)
test "$head_sha" = "<verify_commit>"
```

If they differ, reset `verify_commit` to the live head only after confirming the
new head contains the intended fix, or stop and report the mismatch. Never enable
auto-merge against a stale head you have not verified.

`gh pr merge <pr> --auto --<merge_method>`. Enabling auto-merge is **not terminal**
— continue the loop below until the PR is actually `MERGED` or `CLOSED`.

**With `auto_merge=true`, leave the latch ARMED — never disable auto-merge.**
(Under `auto_merge=false` the deliberate disarm above still applies: that mode
must leave the PR open for a human, so a pre-existing latch is removed on
purpose. Everything below is the `auto_merge=true` path.)

Once a fix is PUSHED the latch is safe: GitHub evaluates required checks against
the PR's current head, so a new commit whose checks have not reported leaves the
PR blocked. Auto-merge cannot ship a commit nothing has verified.

The only window a disarm ever protected is the gap between deciding to fix
something and that fix landing — during which the PR is genuinely green and
genuinely mergeable, and auto-merge firing is GitHub behaving correctly. Two
merges in this repo's history are attributed to that window (#1392, and the
release that shipped the `./hooks/` Cursor bug). Both were fixed forward within
minutes; one was a one-line docs inconsistency.

That evidence is also weaker than it looks: **auto-merge attributes the merge to
whoever enabled it**, so an auto-merge and a human pressing Merge are
indistinguishable in the timeline. The record cannot tell us those PRs were not
simply merged by hand while the latch happened to be armed.

Against that, disarming costs something certain. Disabling is a durable state
change on GitHub; re-enabling is one more step the run has to reach. When a run
ends in between — turns exhausted, job timeout, or you concluding the work while
checks are still pending — the latch stays off and nothing restores it. The PR is
left WORSE OFF THAN IF THIS SKILL HAD NEVER RUN: it has lost the mechanism that
merges it while no agent is watching, and the run reports success. Measured on
`acmeorgc/frontend#282`, the latch went off 14s before the fix commit and the
PR sat 26 minutes after going green, against ~3 minutes for PRs this skill never
touched.

So the trade is a rare, unproven miss that costs a fix-forward PR, against a
frequent, silent stall on every PR this skill repairs. Take the rare one.

What still applies on a push: immediately re-read `headRefOid` and reset
`verify_commit` to the pushed head, so the shipped-verification in step 3 checks
what you actually pushed rather than the commit you replaced. That ancestry check
is what CATCHES a raced merge — it fails loudly when the fix SHA is not an
ancestor of the base branch — so the rare miss is detected rather than silent.

**Invariant:** never terminate having left auto-merge OFF on an open PR when
`auto_merge=true` **and the arm gate cleared**. If some future path does turn it
off, restoring it is a terminal obligation on EVERY exit — including give-up,
budget-exhausted and error paths — not a later step in a sequence. A PR the arm
gate held unarmed is outside this invariant: holding it is the gate's purpose,
and `blocked:unreviewed` (section 4) is how that exit is reported rather than
passed off as success.

- **Capability fallback** (`auto_merge=true` only): if the repo disallows
  auto-merge, do not fail. Keep watching; once checks are green, the arm gate
  clears — a review context that proved work, not merely a green one — and
  `mergeable == MERGEABLE`, run `gh pr merge <pr> --<merge_method>` directly.
  A green review context that did no work does NOT clear the gate here either:
  on a repo without auto-merge this fallback is the merge, so exempting it would
  reinstate the whole defect one layer down. Under a vacuous context the merge
  belongs to step (d), with its conditions and its report line, or to
  `blocked:unreviewed`. This fallback lives inside the gated section above —
  with `auto_merge=false` it never fires; the PR remains open awaiting a human.

### The mergeability gate — never arm a PR that cannot merge

The arm gate above asks *did the review context do work?* This one asks a
different question — *is there a verdict standing in the way?* — and they are
not the same. A `CHANGES_REQUESTED` review unambiguously **did work**, so it
sails through the arm gate and the latch goes on over the top of it.

**Read `reviewDecision` explicitly. Never infer it from a check count.**

```bash
gh pr view <pr> --json reviewDecision,reviewThreads \
  --jq '{decision: .reviewDecision, unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
```

`reviewDecision` is **not part of `statusCheckRollup`**. That is the whole reason
this stayed invisible: a failing-check count reads **zero** on a PR that can
never merge, and the checks tab is entirely green. Any rule keyed on "no red
checks" is satisfied by exactly the PR this gate exists to catch (#3720).

**Do not arm while `reviewDecision == CHANGES_REQUESTED`.** Arming is a claim
that this PR will merge, and that claim is false here. Clear the verdict through
step (e) first — which discriminates a live objection from a stranded one by
unresolved thread count, not by `reviewDecision` — and arm afterwards.

**Two ruleset settings decide whether a standing verdict ever clears itself, and
in this repository both say no.** Verify them for the repo you are in rather
than assuming, with `gh api repos/<owner>/<repo>/rulesets`:

- `dismiss_stale_reviews_on_push: false` — the review stays attached to the
  commit it was made on, so **pushing the fix never clears it**. The intuition
  that a new head resets the verdict is wrong here.
- `required_approving_review_count: 0` — no approval is required to merge, so a
  standing `CHANGES_REQUESTED` is not holding a slot for a review that would
  otherwise arrive. Nothing is scheduled to clear it.

Together those mean the block is **indefinite, not slow**. Waiting is not a
strategy; something has to act.

**Unresolved threads block independently of the verdict.** This repository sets
`required_review_thread_resolution: true`, so a PR can have a clear
`reviewDecision` and still be unmergeable on threads alone — a second blocker
that is equally absent from the check rollup. Read both, and name whichever one
is standing; reporting the wrong one sends the operator to the wrong place.

## 2. The watch loop

Poll the live state each iteration:

```bash
gh pr view <pr> --json state,mergeStateStatus,mergeable,reviewDecision,statusCheckRollup,headRefName,baseRefName
```

**Re-read the hold label on every iteration, before handling any blocker**
(section 0's hold gate). It is polled separately from the line above rather than
folded into it, because `labels` is not part of the blocker state: the hold is a
question about whether to keep going at all, asked before the blocker questions,
and a run that answers it once at startup has not implemented it.

Handle every blocker class; after any fix, re-poll and continue. Do not stop while
the PR is still open and progress is possible. On each iteration, refresh the
babysitter lease if its last stamp is older than ~30 minutes (section 0).

**Poll by PR number, and never discover the PR set from "what is currently open".**
A watcher whose target list comes from `gh pr list --state open` **cannot observe a
merge**: the moment the PR merges it leaves that list, so the branch that would
report `MERGED` is unreachable and the watch ends in silence that looks like
"still running". The same hole hides `CLOSED`. This is why the poll above names
`<pr>` explicitly.

The general shape, worth recognising anywhere a watcher is built: **a set defined
by a current state cannot witness a member leaving that state.** If a watcher must
discover its targets dynamically, it has to *remember* what it discovered and keep
inspecting each one after it drops out of the discovery query — discovery and
inspection are separate lists.

Prefer this skill over a hand-rolled multi-PR watcher for exactly that reason. If
you do build one, give it the same coverage this loop has: not just `MERGED` and
failing checks, but `BEHIND`/`DIRTY` and unresolved review threads — a PR stalled
on any of those is indistinguishable from one still running, and silence is not
evidence of progress.

With **`auto_merge=false`**, the loop's goal changes from "merged" to "clean and
waiting": drive blockers exactly the same, but exit successfully at
`awaiting-human` (section 4) once the PR is open with green checks, a clear
review gate, and `mergeable == MERGEABLE`. Never enable auto-merge or merge
directly in this mode.

In **`on_blocker=report`** mode, only the mechanical step (a) and auto-merge enabling
(when `auto_merge=true` **and the arm gate cleared**) apply; for any of (b)–(f) do
not act — classify the blocker
and return per the input contract above. That includes (f): adjudicating a pending
auto-fix PR (merging, closing, or deleting its branch) is destructive work, not
diagnosis — return its classification (`blocked:pending-auto-fix`) instead.

**The hold gate binds in report mode too**, and it binds first: a held PR
returns `awaiting-human:held` without arming, without the mechanical step (a)
nudge, and without an `update-branch`. Hold is not a blocker classification and
never returns as one — `blocked:*` says *this PR cannot proceed yet*, while a
hold says *someone asked me not to proceed*, and reporting the second as the
first would file a human's decision as a defect for something else to clear.

The arm gate binds in report mode too, and this is the mode where forgetting it
does the most damage: a diagnose-only run that arms the latch has not diagnosed
anything, it has authorised a merge. Under a vacuous review context, report mode
neither arms nor takes step (d)'s exception — it returns `blocked:unreviewed`.

### a. Branch behind base (`mergeStateStatus == BEHIND`)
Before proactively syncing a clean `BEHIND` PR, check whether the base branch
actually requires up-to-date branches:

```bash
owner_repo=$(gh repo view --json nameWithOwner -q .nameWithOwner)
base=$(gh pr view <pr> --json baseRefName -q .baseRefName)
strict=$(gh api "repos/$owner_repo/rules/branches/$base" \
  --jq '[.[] | select(.type == "required_status_checks") | .parameters.strict_required_status_checks_policy // false] | any')
```

If that rules endpoint is unavailable, fall back to classic branch protection:

```bash
strict=$(gh api "repos/$owner_repo/branches/$base/protection/required_status_checks" \
  --jq '.strict // false')
```

Only when `strict == true`, once required checks are green, run
`gh pr update-branch <pr>` and keep watching the new head while checks rerun.
If `strict == false`, do **not** update the branch solely because the base moved:
continue the mergeability loop and let GitHub merge the existing head once the
checks/reviews are acceptable. This avoids cancellation storms in repos whose CI
uses `concurrency.cancel-in-progress: true`.

Still sync when it is necessary to resolve a genuine merge conflict, and it is
acceptable to perform one final sync immediately before a direct merge if the
merge attempt proves the head must be updated.

### b. Sync/merge conflict

**Check this FIRST, before waiting on any check.** A conflicted PR runs **zero**
workflows — not red ones, none — and an empty check list is indistinguishable
from an Actions outage, a slow queue, or workflows not being configured. Time
gets lost waiting for CI that was never dispatched.

The mechanism: `pull_request` workflows are evaluated against GitHub's computed
**merge ref** — "base with this PR merged in". A conflict means that ref cannot
be built, so there is nothing to dispatch against and no run is created.

The tell is two facts TOGETHER:

```sh
gh pr view <pr> --json mergeable,mergeStateStatus --jq '"\(.mergeable) \(.mergeStateStatus)"'
gh api "repos/<owner>/<repo>/actions/runs?head_sha=<head>" --jq .total_count
```

`mergeable == CONFLICTING` **and** `total_count == 0` is a conflict, not a CI
problem. If CI were merely slow you would see runs QUEUED, not absent. Resolve
the conflict and the runs appear; nothing else will make them appear.

**`<head>` must be the FULL 40-character SHA.** `head_sha=` does not match a
prefix and does not reject one: an abbreviated SHA returns a clean
`total_count: 0` — not a 404, not a validation error, an absence shaped exactly
like "no workflow ran". Measured on one pull request's own head: `77f223b9d` →
`total_count 0`, `77f223b9d8ffe7b172e9cac48b068b3bcac304c3` → `total_count 4`.
Take the SHA from `gh pr view <pr> --json headRefOid --jq .headRefOid`, never
from `git log --oneline` or a truncated line in a log or a UI. Copying a short
SHA here makes EVERY pull request read as having no CI, and the natural next
action — rebase and force-push — throws away the live run you could not see
(#3848).

**`mergeable` is computed asynchronously.** GitHub returns `null` while it is
still working it out, so a single read on a freshly-opened PR can say `null` on
a perfectly clean branch. Treat `null` as "cannot tell yet" and re-read — never
as "fine". A false all-clear here is the same defect this check exists to catch,
pointed at yourself.

This is the pre-merge twin of the zero-deploy-run rule below: **an absence is
evidence of something, and the something is rarely "it is fine".**

**`mergeStateStatus` is a cached computation, so it is a HINT, never proof
of a conflict — and never the verdict.**
Before entering the resolution path, re-derive the answer from primary evidence
at the moment of asking — a merge trial against the actual base:

```sh
base=$(gh pr view <pr> --json baseRefName --jq .baseRefName)
head=$(gh pr view <pr> --json headRefOid --jq .headRefOid)
git fetch --quiet origin "$base" "pull/<pr>/head" || readable=no
git rev-parse --verify --quiet "origin/$base^{commit}" >/dev/null || readable=no
git rev-parse --verify --quiet "$head^{commit}" >/dev/null || readable=no
out=$(git merge-tree --write-tree "origin/$base" "$head" 2>/dev/null); code=$?
```

`git merge-tree --write-tree` performs a real three-way merge into the object
store. It touches no working tree, no index and no branch, so it is safe to run
mid-loop on a dirty checkout — which is why it, and not a scratch clone or a
throwaway `git merge`, is the trial this skill runs.

**Read the exit code together with stdout. The exit code alone cannot tell you
which of the three states you are in.** Measured on git 2.53.0: a trial naming a
ref that does not exist exits `1` — the very same code a genuine conflict
returns — printing nothing on stdout and `merge-tree: <ref> - not something we
can merge` on stderr. The discriminator is stdout: a trial that *ran* always
prints the resulting tree OID on its first line, and one that could not run
prints nothing at all.

| Outcome | State | What to do |
|---|---|---|
| `readable=no`, or `$out` empty | **NOT DETERMINED** | Neither path. Re-fetch and retry once; if it is still unreadable, report `not_determined` and let the next loop iteration ask again. |
| `code == 0` | **CLEAN** | Do **not** enter the resolution path, whatever `mergeStateStatus` says. |
| `code == 1` and `$out` non-empty | **CONFLICTED** | Enter the resolution path below. |
| anything else | **NOT DETERMINED** | As the first row. |

The third state is the one that gets collapsed, and reaching for the exit code
by itself is exactly how. An unresolvable ref, an unreachable remote, a fork
head that was never fetched, a git older th

…(truncated)
