Babysitting open PRs
SkillMonitoring & opsHow to sit on a set of already-open PRs and drive them to landed — building the watch set, reading GitHub's mergeability and queue state correctly, re-arming auto-merge, dispatching fixes for reds, and deciding which wake-ups deserve a reply. Activate when asked to "babysit", "watch", "monitor", "sit on", "keep an eye on", or "drive to green" one or more open PRs, when asked to re-enable auto-merge on PRs that lost it, when a PR sits in a state you cannot explain (`unknown` mergeability, auto-merge silently off, a required check that never reports), or when a wake-up arrives from a PR you are already watching. The load-bearing rule is that babysitting lands PRs through the auto-merge path — a direct merge is reserved for a narrow, reproduced fix for a red on `main`.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Babysitting open PRs skill
What this skill tells your AI
The instructions your AI receives, as published by alexandermattturner/agent-glovebox in .claude/skills/babysit-prs/SKILL.md and read by ahel’s review.
The job
Take a named set of open PRs and drive each to landed — green checks, auto-merge armed, conflicts resolved by whoever owns them — while touching nothing you were not asked to touch.
Land through the auto-merge path, almost always. Arm auto-merge and let the required checks and the queue decide; a direct merge_pull_request / gh pr merge skips the queue's own batch build, so a PR that passed its own checks can still break main in a batch nobody built. Green, approved, or small is not a reason to take the direct route.
The one exception is a NARROW fix for a red on main — and there the direct route is mandatory, not optional. A red main blocks every watch-set PR at once, and the queue cannot land the repair while the base it builds against is broken, so a session holding a qualifying fix pushes it straight to main in the same turn, without asking. That push is the whole of the mandate: MERGING a pull request to deliver the same repair stays a hard halt under root CLAUDE.md § Autonomy until the user instructs it, so a repair that exists only as a PR waits for that instruction while you push the equivalent diff. The five conditions Reds § sets still gate the push — a reproduced cause, only the files that cause names, targeted tests passing locally, about 20 lines, and an empty merge queue. Confidence means you reproduced the red and reproduced its repair; a fix you are inferring takes the ordinary route. For an external-outage red (a download host refusing connections) the repair starts FIRST: the qualifying fix is the minimal change that drops the dependency on the dead host — a mirrored source the existing digest pin still verifies, a cached artifact — and a one-time re-run rides alongside it, of the RUN and never its failed jobs, per the ci-triage skill. A prevention PR that cannot cure the live red is never the thing to force-merge. Say in chat which SHA you landed and what proved it.
Arming auto-merge is scoped: re-arm it freely on a PR in the watch set — one the user named, or one that carried auto-merge and lost it. Arming a PR that never had it is a new landing decision; ask first — unless the user's live request directly instructs the merge, in which case arm it immediately: that first arming converts the chat instruction into the durable auto_merge_enabled timeline event that every later session (and the rearm sweep) reads as standing consent, and skipping it strands the instruction in a session that will end. The sweep's consent read counts any auto_merge_enabled event, with no actor check — unlike added_to_merge_queue, which arms only on a User actor — so the event you write is indistinguishable from the user's own click. Keep it falsifiable: when you arm under this carve-out, quote the instruction you acted on in the PR description, so a later session or a human can audit whose consent the event records.
A PR the user auto-merged keeps that authorization when the queue evicts it. Eviction — a CI_FAILURE removal, a dequeue for a conflict, a base that moved — clears auto_merge as a side effect of ejecting the PR, never as a withdrawal. Once the eviction's cause is fixed, re-arm without asking; an evicted PR the machine then labelled requeue-held needs a new head first, and Reds § Re-queue a held PR owns that path. Three limits: the authorization attaches to that PR and no neighbour, so establish it really was armed from evidence (an auto_merge_enabled event in GET /repos/{o}/{r}/issues/{n}/timeline, or the user naming it); a removed approved label is the withdrawal — ask before re-arming a PR whose label a human took off; and never re-arm while the cause is still live, which only re-queues the PR to be evicted again at a full CI fan-out per cycle.
Converting a PR to draft clears auto_merge the same way, so marking one ready again is half a job until you re-arm it. Drafting is how a session parks work when the check fan-out saturates, so a session that marks PRs ready because capacity came back must re-arm every one that was armed before. Read the label, not the flag: GitHub writes an auto_merge_disabled with the human actor at the same second as the convert_to_draft (observed on PR #3407), and it writes the same event for three other side effects of its own — a close, auto-merge firing, and a merge to the base that turns the PR conflicting. No actor and no timestamp separates those from a person's click, so _pr_sweep.consent_of reads none of them: a disable withdraws nothing, and the withdrawal is the approved label coming off. .github/scripts/landing_sweep.py reads it the same way and re-arms within 10 minutes on its own, so your arming only makes it immediate.
A human pulling the PR out of the queue is a withdrawal too, and it is the one that looks exactly like an eviction. A manual dequeue writes a removed_from_merge_queue with a human actor and reason manual, writes no auto_merge_disabled, and clears auto_merge to null byte-for-byte the way an eviction does. The actor is the whole signal: github-merge-queue[bot] removed it ⇒ eviction, re-arm once the cause is fixed; anyone else removed it ⇒ the PR is parked until a human arms it again — never enable auto-merge on it or enqueue it unsupervised, and keep it on the checklist as parked (manual dequeue) rather than asking about it or dropping the row. On a clean PR "arming" is a direct enqueuePullRequest (auto-merge is refused there), so this bans that call too. The scheduled sweep in .github/scripts/landing_sweep.py reads consent the same way (removed_from_merge_queue with .actor.type != "Bot" disarms) and .github/scripts/landing_requeue.py denies reason manual.
Build the watch set explicitly
Write the list down before acting, because "the ones with auto-merge on" is a moving target — the queue clears the flag as it takes a PR. The set is usually:
- PRs whose
auto_mergeis non-null right now, plus - PRs that had it on recently but read
auto_merge: nulltoday — often already queued, not un-armed (seereading-state.md), plus - PRs the user named.
Everything else is out of scope. Do not widen the set because a neighbouring PR is also red. A PR another session is actively driving is out of scope too — it fails membership at set-build time, not later at dispatch. fix-agents.md says how to read that from the claude-owned label's timestamps, and carries the one exception: a PR the USER names or hands you stays in the set whatever its label says.
Apply the claude-owned label to each PR as you adopt it, in the turn you write the set down — not after its first fix lands. The liveness check under Brief each fix agent § is what a parallel session reads before it dispatches, and it reads that label; a PR you drive without one reads as unowned, so another session sends its own agent onto your branch and both fixes collide on the same lines. Add it with the atomic REST route, gh api -X POST repos/<owner>/<repo>/issues/<pr#>/labels -f 'labels[]=claude-owned' — it adds the one label without touching any other, so a label another job wrote between your read and this write survives. Reserve mcp__github__issue_write method update for the documented REST-refusal fallback (pr-creation/pr-templates.md § labels): its labels field REPLACES the whole set, so it can silently drop a consent label (approved, force-queue, only-queue) if one arrived since your last read, disarming auto-merge. Re-apply it whenever the stale sweep reaps it while the PR is still in your set: the sweep reaps on two stale timestamps, never on a release you signalled. Take it off only when you abandon the PR.
A dependency-bot PR stops maintaining itself the moment anyone else pushes to it. Renovate refuses to rebase a PR whose newest commit author it does not recognize, and its PRs also tend to read auto_merge_disabled minutes after opening (#4287 lost it 3 minutes in and landed 32 hours later by hand). Once the resolver or a session has pushed to one, its base refreshes are yours (update-branch), and read its auto-merge state instead of assuming the bot keeps it armed.
DISPATCH THE WHOLE SET IN ONE BLOCK OF Agent CALLS. Never work one PR, then the next. After the single status read (reading-state.md), put every branch's fix agent in ONE assistant turn — ten Agent calls side by side, not ten turns. A CI round here is tens of minutes, so serial costs one round per PR and a 10-PR set outlives the session. The tell you are already failing: an Agent call exists for one PR while another PR in the set is still unread. Hold each diagnosis until the set-wide read is done, then send them together. What a correct dispatch looks like: read status over all 10, then one turn holding Agent(#4923, #4931, #4967), Agent(#4972, #4988, #4991) … each model: "sonnet", isolation: "worktree", owning every red on each of its branches — the fleet is capped by load, so ten PRs are four calls when slots are scarce, never ten turns. Fill every free slot with ONE branch first. Then ROUND-ROBIN the rest, one more branch per agent per pass, and give no agent more than three. Ten PRs against four slots is then 3+3+2+2; eight PRs is 2+2+2+2, never 3+3+1+1; five PRs is 2+1+1+1, not 3+2. Only after that block do you watch. Drafts are dropped from the set before you count to 10 — a draft cannot arm auto-merge, so driving one to green lands nothing.
Build it in ONE call, already in the order you must work it. mcp__github__list_pull_requests with state: open, sort: created, direction: asc, and fields: ["number","title","state","draft","created_at","head","labels"]. fields drops body, which is the largest part of each result, and forty PR bodies is the difference between a list you can read and a wall you cannot. uv run .github/scripts/pr-status.py --all-open --ready-only is the same set with its check state, in one command.
Rebuilding means re-running that call and re-applying the membership criteria above; it never adopts every open PR by itself. The call is the filter's INPUT, not the set. auto_merge is not among its fields, so confirm that criterion per candidate from GET /repos/{o}/{r}/pulls/{n}, or let pr-status.py do it. A user who names the whole open set has widened the criteria themselves.
Rebuild the set at four points:
- the start of a session — a set never carries into one that did not build it;
- after a compaction;
- when a member merges or closes;
- when every remaining member waits on something you cannot speed up.
The last two carry the rule. A set drains one PR at a time. One long-blocked member keeps it non-empty for the rest of the session, so "rebuild when it drains" never fires. Members leave by merging. Members ENTER with no webhook to you, because .github/scripts/draft-ready-prs-over-cap.py returns a draft to ready each time a ready PR lands. A set that shrank to one or two PRs is a stale list, not a quiet repository.
A rebuild that returns only the members you already have is not the end of the loop — REFILL, one PR per departure. The criteria can yield nothing new on a merge for a timing reason: cap-ready-prs.yaml promotes the replacement draft AFTER the close, so the candidate did not exist when you re-applied them. Read uv run .github/scripts/pr-status.py --all-open --ready-only and take the oldest ready open PR the rebuild did not return. A PR reached this way carries none of the consent its neighbours do, so it enters PARKED: arming it is a new landing decision under "Arming auto-merge is scoped" above — ask first — and a PR whose timeline holds a human removed_from_merge_queue is never armed or enqueued at all. Drive its reds and its review; leave the landing to the user. Write the number down with the merge or close that made room for it, or the set is one nobody can audit.
Read the candidate head's own commit date before you adopt it, with the probe fix-agents.md owns — take that command rather than writing a shorter one. A head committed within the last half hour means a session is driving that branch right now, whatever its labels say. The claude-owned sweep drops the label after about an hour with no head push, so a session that spends that hour on a diagnosis wears none while it works. A bare git log -1 origin/<branch> answers wrongly twice — a stale local ref, and a bot merge sitting over the session commit — and fix-agents.md owns the fetch and the walk that answer both. The PR object's head.repo.pushed_at is the whole REPOSITORY's last push and never answers this.
EVERY member's round being in flight is a WIDENING trigger, never a reason to end the turn. That state reads like nothing left to do, and it is the shape a whole hour disappears into: each PR was pushed, each round takes tens of minutes, and the session goes quiet while the ready backlog grows. It is not a wait you may hold — a wait blocks a turn only when it blocks work you cannot do elsewhere, and here the work is a PR you have not adopted yet. Refill by the rule above, then start the new member's reds in the same turn. Only an empty --ready-only list ends it.
Never mark a draft ready to widen the set. The cap drafted it to protect check capacity, and the drafts return on their own as ready PRs land. docs/merge-queue-runbook.md §5 owns the cap. Two drafts are still not DROPPED, though neither can be driven: one the user named, and one already in the set when the cap converted it. Each stays on the checklist as parked (draft), which keeps the re-arm-after-ready rule above reachable.
mergeable_state is an allowed fields value that the call does not return — GitHub's list endpoint never serves it and the tool does not backfill it, so it comes back absent rather than as an error. Reading its absence as "this PR has no mergeable state" is the failure. Get it per PR from GET /repos/{o}/{r}/pulls/{n}, or let pr-status.py do it for the whole set. The merge-conflict label is never that substitute, in EITHER direction. pr-meta-privileged.yaml's labeler writes it from a scan: a per-PR sync on that PR's own opened, synchronize, reopened and ready_for_review, and a full scan on a push to main and on an hourly cron delivering about ten fires a day. The label reports the last scan of that PR, and a per-PR sync often ends without an answer. GitHub can still call mergeability UNKNOWN after the sync's six passes, and a newer event on the same PR cancels the run outright. Measured on 2026-09-04: four PRs still wore it after the resolver had already merged main into each, and #5473 wore none while reading dirty. Counting the label gives a conflicted backlog that is too big and a ready set that is too clean, both at once.
Work the set oldest-first, by created_at — and inside a PR, the blocking set first. While a required check is red on a PR, the only legitimate work on it is work that plausibly changes that check's outcome, or an explicit reviewer demand; review nits, description polish, and advisory reds wait until the blocking set is empty, or until you state in the PR why this session cannot address the red. Then: answer its review, re-arm auto-merge if it lost it — and hand its conflict to the auto-resolve workflow rather than resolving it here (see reds.md § Conflicts), so a dirty oldest PR never blocks the rest of the queue behind it.
Oldest-first orders the DISPATCH, never the completion, and the dispatch fires again on every arrival — a reviewer finding, a red webhook, your own diagnosis — not once at the start. Read every PR's state, cluster the reds, and launch every cluster's fix agent in ONE batch before you settle into watching anything. A CI round here is tens of minutes, so a session that drives PR 1 to green before opening PR 2's log pays one round per PR in sequence — a 10-PR set then outlives the session, which is how a babysitting session ends with zero landed. The correct shape: one status read over the whole set, one batch of agents, each holding one branch, or up to three when the set outnumbers the free slots — partitioned by BRANCH, so a branch whose reds sit in two clusters gets ONE agent owning both, per the no-two-fix-agents rule under Reds — then you watch. Serial is right only inside one branch (its fix agent owns every red on it) and for the dependent links of a stack.
Every wake re-reads the WATCH SET before it acts on the PR that woke you — pr-status.py <n> <n> … over the numbers you wrote down, never --all-open, which reports PRs nobody asked this session to touch and whose reds are then tempting to fix. That bans widening the set at a wake, never the rebuild above: a set this session did not build gets rebuilt first, and the numbers it yields are then what every later wake re-reads. A wake arrives per PR, so a session that answers each one where it lands follows a single PR's event stream for hours while the rest of the set ages, and the PR with the loudest webhook is never the oldest. One set-wide read per wake costs one command. Where gh reaches GitHub, pr-status.py is the ONLY way you read a PR's reds — never a check-run call or a jq beside it, and never one per PR when it takes the whole set in one command. The ci-triage skill § Reading GitHub state owns what a hand-read gets wrong. Where pr-status.py cannot reach GitHub, read mergeable_state FIRST — it is the one field that is the verdict. blocked and unstable each mean something is failing. Take it per PR from mcp__github__pull_request_read method get. Neither of the other two reads is a verdict, and each is silent about the other's half. Review findings resolved is a commit status, not a check run, so get_check_runs never lists it, and a PR blocked only by it reads green on every check-run page you turn. get_status is the mirror image: it serves the commit statuses alone, so a head whose CI check run FAILED still answers "state":"success" there. A check-run page is never the whole set. The tool serves at most 100 per page, and a head here carries hundreds — #5767's carried 394 on 2026-09-04. One page is then a quarter of them, and the red is as likely to sit on page 3. Page to total_count, or state no count and no colour. Page for the NAMES only on a PR you are about to fix. A PR whose reds you have already pushed a fix for is WAITING, and waiting is not work — move to the next unfixed PR IN THE SET in the same turn rather than watching a round you cannot speed up.
Two reasons the order is this way round, and neither is about fairness. An old PR is old because something about it keeps failing to resolve, and its branch drifts further from main every day it stays open, so its conflicts and its stale-branch reds get more expensive rather than less — the work is cheapest the sooner it is done. And newest-first starves it structurally, not just in practice: while PRs keep being opened, the newest is never the oldest, so the oldest is never reached. The pull is toward the newest — it is the one just pushed, whose CI is already being watched — which is exactly why the order has to be written down.
An only-queue hold: spend the wait on the reds nobody is driving
A PR carrying only-queue holds main, so no other PR in your set can land until that one does. Do not idle that time. _pr_sweep.hold_holder names the holder, and reconcile-pr-landing.py disarms and dequeues every other open PR for it, so a green member of your set just waits. That is the widening trigger the section above describes, not a reason to end the turn.
Widen ONCE per hold, in the turn you notice it:
- List every open PR with
uv run .github/scripts/pr-status.py --all-open, WITHOUT--ready-only. A draft is in scope here, and only here.cap-ready-prs.yamldrafted it, so its reds report nowhere until a slot frees and it is promoted. Finding them during the hold is what stops a promoted PR arriving red. - Drop every PR carrying
claude-owned, and every PR already in your set. The label means another session is driving it. - Keep the PRs with at least one red check. A check that is red on
maintoo is not that PR's, so theci-triageskill still decides what to fix. - Adopt at most three. Apply
claude-ownedby the atomic REST route above, then drive each red the way you drive a member's. A larger number turns one task session into a repository-wide CI babysitter, which rootCLAUDE.md§ Autonomy refuses.
Never mark an adopted draft ready, and never arm an adopted PR. You took on its RED, not its landing: the cap owns its draft state and the user owns its consent. Take claude-owned off each one when the hold lifts, or leave it for the hourly sweep to reap.
Where the rest of the mechanics live
Three files sit beside this one. Read each when its situation arrives, not up front — this skill loads whole on every invocation, and the three together are more than twice its size.
| File | Read it when |
|---|---|
reading-state.md | before ANY claim about a watched PR — green, red, blocked, ready, done |
reds.md | a watched PR reports a red check or a merge conflict |
fix-agents.md | you are about to dispatch a sub-agent to fix one |
A fix you push into CI machinery — a workflow, a landing or queue script, the scheduler — answers the TLA+ spec question in that PR's body; .claude/rules/tla-plus.md owns it.
Wake-ups
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 63
- Forks
- 11
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
babysit-prs- Source
- github.com/alexandermattturner/agent-glovebox