pr-dev
The final delivery lifecycle driver. One objective's PR, from open → review addressed → CI green → merged, under the repository's actual merge policy.
Why this skill exists
After a PR opens, the real work is staying on top of CI failures, reviewer threads, and merge conflicts — often across many minutes. A one-shot "check and report" leaves the PR stalled whenever a simple retry or two-line fix would have unblocked it, and it misses threads that arrive after the initial check. This skill runs a persistent drive loop — re-fetch, act, push, loop — so nobody babysits GitHub tabs.
Two things changed from the earlier version, and they matter:
- It is not called once per worker. A worker task ends at commit + handoff.
pr-devruns once per objective, on the PR thatdeliver-devopened from the integration branch. Driving five PRs for one objective was the defect, not the workflow. - Auto-merge is a resolved policy, not an invariant. The old rule ("always enable auto-merge") is right for most repositories and wrong for any repository or user that says otherwise. Policy is now resolved once, from a documented precedence, and reported.
Validation tier
Tier 3 — delivery/CI. CI is the independent authority here. This skill does not re-run the full local suite on every loop; it runs the repo's pre-PR gates before each push it makes (Tier 2) and lets CI be Tier 3.
Inputs
Resolve once at startup; reuse throughout:
| Input | Resolution |
|---|---|
| No argument | gh pr view --json number -q .number from current branch |
PR number 42 |
Use directly |
| PR URL | Extract number from URL |
If no PR resolves, ask and stop.
Skill directory (all platforms)
Resolve once at startup as SKILL_DIR — every scripts/, templates/, and references/ path below uses it. Never hardcode an absolute path.
Try in order:
$CLAUDE_SKILL_DIRwhen set (Claude Code sets this automatically)$CLAUDE_PLUGIN_ROOT/skills/dev-workflow/pr-devwhen the plugin root is known<git-root>/skills/dev-workflow/pr-devwhen developing this repo from a checkoutfind ~/.claude/plugins -path "*/dev-workflow/pr-dev/scripts/fetch-pr-state.sh" -print -quit | xargs dirnameas a last resort
If none resolve to a directory containing scripts/fetch-pr-state.sh, stop and say so.
| Platform | Typical resolution |
|---|---|
| Claude Code / Desktop | $CLAUDE_SKILL_DIR or $CLAUDE_PLUGIN_ROOT/skills/dev-workflow/pr-dev |
| Cursor / Codex / Gemini CLI / OpenCode | Git-root path when working from a checkout; otherwise the installed skills root + dev-workflow/pr-dev |
Startup — resolve context and policy (before the first loop pass)
1. Objective context
A PR that came from an objective carries metadata worth knowing: which tasks it contains, which branch is the integration branch, and what delivery strategy was chosen.
PR_JSON="$(gh pr view "$PR" --json number,headRefName,baseRefName,body)"
HEAD="$(jq -r .headRefName <<<"$PR_JSON")"
BASE="$(jq -r .baseRefName <<<"$PR_JSON")"
# objective/<slug> as the head branch, or an "Objective: <id>" line in the body
OBJECTIVE_ID="$(sed -n 's|^objective/||p' <<<"$HEAD")"
[ -n "$OBJECTIVE_ID" ] || OBJECTIVE_ID="$(jq -r .body <<<"$PR_JSON" | sed -n 's/^[Oo]bjective:[[:space:]]*`\{0,1\}\([a-z0-9-]*\).*/\1/p' | head -1)"
When an objective resolves, read .dev-files/objectives/$OBJECTIVE_ID/objective.json and note:
integration_branch— this is the PR head. Never push to aworker/*branch from here; worker branches are already merged into it.delivery.strategy—single-prmeans this PR is the whole objective. If you find yourself about to open a second PR for the same objective, stop: that is the defect this design removes.delivery.auto_merge— feeds the policy resolution below.tasks— useful context when writing review replies ("that path belongs to task-003").
No objective is a perfectly normal case (a one-off PR). Continue without one.
2. Merge policy
POLICY="$(bash "$SKILL_DIR/scripts/resolve-merge-policy.sh" ${REPO:+--repo "$REPO"} "$PR" "${OBJECTIVE_ID:-}")"
echo "$POLICY" | jq .
AUTO_MERGE="$(jq -r .auto_merge <<<"$POLICY")" # enable | skip
MERGE_METHOD="$(jq -r .merge_method <<<"$POLICY")" # squash | merge | rebase
MERGE_QUEUE="$(jq -r .merge_queue <<<"$POLICY")" # true | false | null
STRICT="$(jq -r .strict_branch_update <<<"$POLICY")" # true | false | null
PROT_SRC="$(jq -r .protection_source <<<"$POLICY")" # classic | rulesets | classic+rulesets | none
Precedence, highest first (documented in the script's header):
TAMIRS_AUTO_MERGE=always|never— explicit user/session override- the objective's
delivery.auto_merge .dev-files/policy.json→delivery.auto_merge- repository capability — a repo with auto-merge disabled overrules any request to enable it
- documented default: enable auto-merge when the repository allows it
State the resolution in one line before looping:
Merge policy: auto-merge ENABLED (default: repository allows auto-merge), squash + delete branch.
Base master: strict branch updates required, 9 required checks, review required. Merge queue: none.
or
Merge policy: auto-merge SKIPPED (objective auth-system delivery.auto_merge=false).
I will drive to green and ask you before merging.
Then enable it, if and only if policy says so:
if [ "$AUTO_MERGE" = "enable" ]; then
gh pr merge "$PR" --auto "--$MERGE_METHOD" --delete-branch
fi
Core drive loop
startup:
resolve PR, HEAD, BASE, OBJECTIVE_ID
resolve merge policy; report it; enable auto-merge only if policy says enable
loop:
state = bash $SKILL_DIR/scripts/fetch-pr-state.sh $PR
if PR merged → cleanup-after-merge.sh; report; STOP
if PR closed (not merged) → report; STOP
# Review threads
address every unresolved thread (see below)
if any thread needed a push →
re-apply merge policy (idempotent); restart loop immediately (no sleep)
# CI
classify every non-green check (see the table below)
if branch-related fix pushed →
re-apply merge policy; restart loop immediately
# Branch freshness — only when policy says strict
if STRICT == true and PR is BEHIND → update the branch once (see below)
if STRICT != true → leave a behind-but-mergeable branch alone
# Readiness
if all checks green AND unresolved == 0:
if AUTO_MERGE == enable → ensure auto-merge/queue entry; poll until merged
else → print the readiness summary and ASK before merging
sleep(cadence); restart loop
Fetch fresh state
bash "$SKILL_DIR/scripts/fetch-pr-state.sh" ${REPO:+--repo "$REPO"} "$PR"
Pass --repo <owner>/<name> whenever the working directory is not the PR's
repository. Every script here resolves the repo from the cwd by default, so
the same PR number in a sibling checkout is a real, plausible-looking,
completely unrelated pull request — a wrong answer, not an error, which is
why it goes unnoticed. All four scripts accept --repo (and --repo=<slug>);
it pins GH_REPO for every gh call they make. This matters most for an agent
whose shell resets to a different directory between commands.
Quick snapshot inside the loop:
gh pr view "$PR" --json number,title,state,mergeable,mergeStateStatus,\
reviewDecision,statusCheckRollup,headRefOid,headRefName,autoMergeRequest
Address a review thread
Unchanged, and still the highest-value part of the loop:
- Read the full thread body — understand what the reviewer is asking.
- Assess and state in conversation:
agree,partially agree, ordisagree. - State the reply before posting it — the user can redirect tone or content. Use the shapes in
$SKILL_DIR/templates/review-reply.md.tmpl. - Post:
gh api "repos/$REPO/pulls/$PR/comments" \ -X POST -f body="$REPLY" -f in_reply_to="$COMMENT_ID" - Fix code if you agreed or partially agreed.
- Resolve:
bash "$SKILL_DIR/scripts/resolve-thread.sh" ${REPO:+--repo "$REPO"} "$THREAD_ID" - Commit, push, restart the loop.
When the PR spans an objective, say which task a comment lands in — it makes the reply concrete and tells the reviewer the change was intentional, not incidental.
Diagnose a CI failure
RUN_ID=$(gh run list --repo "$REPO" \
--commit "$(gh pr view $PR --repo $REPO --json headRefOid -q .headRefOid)" \
--json databaseId,conclusion \
--jq '.[] | select(.conclusion=="failure") | .databaseId' | head -1)
gh run view "$RUN_ID" --repo "$REPO" --log-failed
Classify CI results
| Signal | Classification | Action |
|---|---|---|
| Test/lint/compile error in a PR-touched file | Branch-related | Patch, commit, push; restart loop |
Conclusion cancelled on a superseded commit (a newer push exists) |
Supersession | Not a failure. Ignore it; grade only the checks on the current headRefOid. Never retry it and never count it against the flake budget. |
Conclusion cancelled on the current head with no newer push |
Real cancellation | Re-run once; if it cancels again, surface it — something is cancelling your runs |
runner provisioning failed, network timeout, registry error |
Flaky/infra | gh run rerun $RUN_ID --repo $REPO --failed (max 3× per PR) |
Failure in a check that is not in required_checks |
Non-blocking | Report it; it does not block merge; do not burn the flake budget on it |
| Dependency outage, GH Actions infra error | Unrelated | Surface to user; STOP |
| Ambiguous | Ambiguous | One manual diagnosis, then decide |
Always grade the current head. Checks from an older commit are history, not status. This is the single most common way a drive loop concludes "failing" about a run that no longer exists.
Branch freshness — loose vs strict
strict_branch_update comes from branch protection's "Require branches to be up to date before merging":
strict_branch_update |
Behaviour |
|---|---|
true (strict) |
A BEHIND PR cannot merge. Update it once: gh pr update-branch "$PR" (or --rebase where the repo prefers a linear history), then let CI re-run. |
false (loose) |
A behind-but-mergeable PR merges fine. Do not merge the base in — every needless update restarts CI and cancels in-flight runs for nothing. |
null (unknown) |
Treat as loose. Only update when GitHub actually reports the merge as blocked by staleness. |
Protection lives in two independent systems. GitHub has classic branch protection
(/branches/{b}/protection) and rulesets (effective result:
/rules/branches/{b}). A repository may use either, both, or neither, and they do not
shadow each other — the effective protection is the union. resolve-merge-policy.sh
reads both and reports which answered in protection_source.
This matters because the classic endpoint returns 404 "Branch not protected" on a
ruleset-governed branch. Reading only it reports required_checks: [] and
requires_review: false for a branch that in fact requires nine checks and a pull
request — and the readiness gate then has nothing to wait for. If you ever see
protection_source: "none" on a repository you believe is governed, that is the finding,
not a green light.
Never update the branch in a loop. If a single update does not clear BEHIND, something else is wrong — diagnose instead of repeating.
Merge queue
When merge_queue is true on the base branch:
gh pr merge "$PR" --auto "--$MERGE_METHOD"enqueues the PR rather than merging it directly. That is the correct call; do not try to bypass the queue.- Once queued, the queue owns branch freshness and the merge order. Stop updating the branch and stop re-running checks — you are fighting the queue.
- A PR ejected from the queue (queue CI failed) comes back as a normal failing PR: diagnose, fix, push, re-enqueue.
- Report queue position when GitHub exposes it, and keep polling until merged.
Merging
if [ "$AUTO_MERGE" = "enable" ]; then
gh pr merge "$PR" --auto "--$MERGE_METHOD" --delete-branch
fi
Run this at startup, after every push, and at the readiness gate — it is idempotent.
When autoMergeRequest is set, GitHub merges as soon as required checks and branch protection (including any required review) are satisfied. REVIEW_REQUIRED alone is not blocked — report "auto-merge enabled; waiting for review" and keep polling.
When policy is skip, do not merge silently. Print the readiness summary and ask:
PR #N is green and has no unresolved threads, but merge policy here is "ask first"
(<source of the policy>). Merge now with squash + delete branch? (yes / no)
--admin — gh pr merge "$PR" "--$MERGE_METHOD" --delete-branch --admin — is for two cases and no others:
- required checks cannot pass for reasons outside the code (e.g. Actions billing), or
- the repository is a solo-maintainer repo with branch protection whose required review can never be satisfied — the maintainer authors every PR, so there is nobody who can approve.
Resolve that second case; never assume it. It holds only where the repository is solo and the
caller holds a ruleset bypass actor — the same derivation scripts/github-policy.sh uses. Naming a
specific repository here would make every repository that installs this plugin inherit the
instruction and bypass its own branch protection:
# solo? and does the caller actually hold a bypass?
collaborators=$(gh api "repos/$REPO/collaborators" --jq 'length' 2>/dev/null || echo 1)
bypass=$(gh api "repos/$REPO/rulesets" --jq '[.[].bypass_actors // []] | flatten | length' 2>/dev/null || echo 0)
# --admin is the normal path only when collaborators == 1 AND bypass > 0
--admin still requires the user's merge intent. It bypasses protection; it does not bypass policy.
Readiness gate
Confirm ALL before merging or waiting on auto-merge:
- Pre-PR gates green for anything you pushed (
bash skills/dev-workflow/_shared/scripts/run-pre-pr-gates.shwhen the Makefile defines agent targets) - Every required check green on the current head (no pending, no failing)
- 0 unresolved review threads (re-run
fetch-pr-state.sh— do not trust cached state) - Branch freshness satisfied per the loose/strict rule
- Merge policy resolved and stated
Print:
PR #N ready — objective auth-system (5 tasks, one PR).
✓ 9/9 required checks green on abc1234
✓ 0 unresolved review threads
✓ Branch up to date (strict base)
→ auto-merge enabled (squash + delete branch); waiting for GitHub
Push a fix
REPO_ROOT="$(git rev-parse --show-toplevel)"
SHARED_DIR="$REPO_ROOT/skills/dev-workflow/_shared/scripts"
if [ -f "$SHARED_DIR/run-pre-pr-gates.sh" ]; then
bash "$SHARED_DIR/run-pre-pr-gates.sh" "$REPO_ROOT"
fi
git add -p # stage only the relevant change
git commit -m "fix: <what and why>
Co-Authored-By: Claude <noreply@anthropic.com>"
git push origin HEAD
Push only to the PR's head branch. When that head is an objective integration branch, the fix belongs there — not on a worker branch that is already merged into it.
Then restart the loop immediately — a push is never a terminal outcome.
Wait for CI
Short cycles: gh pr checks "$PR" --watch.
Long cycles (5+ min) or when other work can proceed: the Monitor until-loop — see $SKILL_DIR/references/ci-monitor-loop.md. On a platform without background tasks, poll on the cadence below instead.
Polling cadence
| State | Interval |
|---|---|
| CI pending/queued/running | 60 s |
| Fix just pushed | Immediate restart — no sleep |
| CI green, threads still open | 45 s |
| All green, auto-merge enabled | Poll until merged |
| In a merge queue | 60 s; report position changes only |
After merge
bash "$SKILL_DIR/scripts/cleanup-after-merge.sh" ${REPO:+--repo "$REPO"} "$PR"
Then:
- Close issues linked in the PR body if still open:
gh issue close <N> --comment "Shipped in PR #$PR." - When an objective drove this PR, mark it done: set
objective.jsonstatustocompletedand recorddelivery.pr_url(viaobjective-state.sh setwhen available). - Confirm
origin/<head>is gone (cleanup-after-merge.shdeletes it when a UI merge left it behind).
If the PR was already merged when the loop starts, skip everything and run only the cleanup.
Blocked state
Blocked on PR #N — need your input:
Issue: <specific description of what can't be resolved>
Options:
A) <option>
B) <option>
Which do you prefer?
Never silently stop, guess, or take a destructive action without confirmation. When the block will last a while, suggest /switch-dev handoff so the context survives the wait.
Hard rules
- Re-fetch before every decision — cached state causes wrong merge-readiness calls after fast CI flips.
- Pass
--repowhen the cwd is not the PR's repository. Repo resolution is cwd-based by default, so a wrong directory yields a plausible answer about a different repository rather than an error. - Grade only the current head commit. Superseded runs are history.
- Never stop on one idle poll — CI is often queued seconds after a push.
- Never push to any branch other than the PR head.
- Never prefix commits with
[skip ci]— required checks must run. - State every review reply in conversation before posting it.
- Never change tests or CI config to silence a flaky failure unless the logs prove it is branch-related.
- Never retry a flaky run more than 3× per PR — beyond that it is an infra problem needing human judgment.
- Always run pre-PR gates before every push when the repo defines them.
- Always re-apply the merge policy after a push, and never force auto-merge against the resolved policy.
- Never open a second PR for the same objective. One objective = one PR unless
delivery.strategynames an exception fromcore/policies/delivery.md. - Never update the branch on a loose base, and never update it in a loop.
- Never fight a merge queue — enqueue and wait.
- Always delete the remote branch after merge.
Anti-patterns
| Wrong | Right |
|---|---|
| Drive one PR per worker task | One PR per objective; workers end at commit + handoff |
gh pr merge --auto unconditionally |
Resolve policy first; enable only when it says enable |
Treat a cancelled superseded run as a failure |
Grade the current head only |
| Merge base into the branch every loop | Update once, and only when the base is strict |
| Bypass the merge queue with a direct merge | Enqueue and wait |
| Retry a flaky runner 10× | Max 3×; then surface |
| Stop on one idle poll | Keep looping |
| Stop after pushing a fix | Restart the loop immediately |
| Post a reply without stating it first | State it, then post |
| Patch CI config to silence a flaky test | Retry; escalate if it persists |
--admin to skip a review you just did not want |
--admin only for billing/solo-maintainer protection cases, with user intent |
Supporting files
| File | Purpose |
|---|---|
scripts/resolve-merge-policy.sh |
Resolves auto-merge, merge method, queue, branch-protection and strictness into one JSON object |
scripts/fetch-pr-state.sh |
Fresh PR state + checks + review threads |
scripts/resolve-thread.sh |
Resolve one review thread |
scripts/cleanup-after-merge.sh |
Post-merge branch cleanup verification |
references/ci-monitor-loop.md |
Long-cycle CI waiting with Monitor |
templates/review-reply.md.tmpl |
Review reply shapes |
Output format
- Progress updates: brief, state-change only (not every poll tick)
- At startup: objective context (when any) + the resolved merge policy, in two lines
- On fix push:
Pushed fix: <what> (SHA abc1234). Resuming. - On flake retry:
Retried flaky run (attempt N/3). Watching. - On thread resolved:
Thread "<snippet>": agreed/partial/disagree — replied and resolved. - At readiness gate: the full readiness summary above
- After merge: one-line summary — SHA, checks, fixes pushed, retries, issues closed, objective marked completed, remote branch deleted