Shepherd to Merge
Guide a pull request through code review, feedback resolution, CI validation, and auto-merge. This skill orchestrates the full lifecycle from review to merge without circumventing any branch protection rules or security constraints.
Important: Never use admin privileges to bypass branch protections, force-merge, or dismiss reviews. The goal is to satisfy all merge requirements legitimately.
Review threads: Every actionable line-level review comment (humans, Copilot, other bots) must receive a public inline reply on GitHub explaining the outcome (what changed, with enough context for reviewers to find it—e.g. commit subject or SHA—or why you did not change code) before you resolve the thread. Silent resolves are treated as a process failure even when the diff is correct.
Multi-agent safety: Multiple agents may be shepherding different PRs in parallel. Expect the base branch to move frequently as other agents merge. The rebase retry loop in step 10 handles this. Each agent works on its own PR branch and does not modify branches belonging to other sessions.
Input
$ARGUMENTS supports both single-PR and sequential batch modes.
Single-PR inputs:
- A full PR URL:
https://github.com/owner/repo/pull/123 - A repo-qualified reference:
owner/repo#123 - A bare PR number:
123— only works when your current directory is inside a git repo with a GitHub remote.
Sequential batch inputs:
owner/repo 123 124 125(explicit queue order)owner/repo --mine(discover current user's open PRs and process oldest-updated first)
If $ARGUMENTS is empty, ask the user which PR to shepherd — request the full URL or
owner/repo#number.
Steps
1. Identify repository and PR queue
Parse $ARGUMENTS and normalize it into:
owner/repopr_queue(ordered list of one or more PR numbers)mode(singleorsequential)
For single-PR input forms:
- Full URL (
https://github.com/owner/repo/pull/123): extract owner, repo, and number from the URL path and setpr_queue=[number]. - Qualified reference (
owner/repo#123): split on/and#and setpr_queue=[number]. - Bare number (
123): detect owner/repo fromgit remote get-url originand setpr_queue=[number]. If repo detection fails (not in a git repo, noorigin, or non-GitHub remote), stop and ask forowner/repo#numberor a full PR URL.
For sequential inputs:
- Explicit numbers (
owner/repo 123 124 125): setpr_queueto numbers in provided order. --mine: discover open PRs authored by current user:
This yields a deterministic queue (non-draft PR numbers, oldest-updated first).me=$(gh api user --jq .login) gh pr list -R <owner>/<repo> --state open --author "$me" --json number,updatedAt,isDraft \ --jq 'map(select(.isDraft | not)) | sort_by(.updatedAt) | map(.number) | unique | .[]'
If pr_queue is empty after filtering, stop with a concise summary.
2. Validate each queued PR and report plan
For each PR in pr_queue, fetch details and confirm it is open:
gh pr view <number> -R <owner>/<repo> --json number,title,state,isDraft,headRefName,baseRefName,url,reviews,statusCheckRollup
Skip PRs that are already merged, closed, or draft; report why each was skipped.
Build a processing_queue that excludes skipped PRs and continue with processing_queue only.
Print the final processing_queue before continuing. In sequential mode, this is required progress
context for the operator.
3. Process queue one PR at a time
For each PR in processing_queue, run steps 4-16 completely before moving to the next PR. Never shepherd
multiple PRs concurrently from this skill.
After each PR reaches MERGED, print a one-line progress update:
Completed <index>/<total>: #<number> <title> (<url>)
4. Check out the PR branch
Ensure you're in the repo and check out the PR branch:
gh pr checkout <number>
5. Spawn a review subagent
Use the Task tool to launch a subagent that performs a thorough code review of the PR. The subagent should:
- Read and understand every changed file in the PR diff (
gh pr diff <number>) - Review for correctness, security issues, performance concerns, and style consistency
- Return a structured summary: a list of issues found (with file, line, and description) and an overall assessment (approve, request changes, or comment)
Wait for the subagent to return its findings before proceeding.
6. Address review feedback
After the subagent returns, check for any existing review comments on the PR (including GitHub Copilot comments — Copilot provides a single automated review when the PR is created and does not re-review on subsequent pushes):
gh api repos/<owner>/<repo>/pulls/<number>/comments --jq '.[] | {id, node_id, path, line, body, user: .user.login}'
gh api repos/<owner>/<repo>/pulls/<number>/reviews --jq '.[] | {id, node_id, state, body, user: .user.login}'
Public reply before resolve (merge shepherding policy): Do not treat a review thread as “done”
without a public inline reply on GitHub on that comment or thread. Resolving threads with no
reply looks like ignored feedback even when the code was fixed; many repos (including companion
AGENTS.md guidance) expect a short explanation on the thread when it is resolved. This applies to
every actionable PR review comment, including Copilot and other bots: after you fix code or
after you decide not to change behavior, post the reply first, then resolve (on repos that use
resolved threads as a merge gate). Declining a suggestion is fine when explained — the reply is
still required.
For each piece of actionable feedback (from the subagent review, human reviewers, or Copilot):
- Fix the issue in the local checkout — edit the file, make the correction (or decide not to change behavior and prepare the explanation for item 4 below).
- Commit the fix with a clear message referencing the feedback (skip a code commit only when the outcome is explanation-only, e.g. out of scope or incorrect suggestion).
- Push the branch so the updated diff (if any) is visible on GitHub before you reply.
- Post an inline reply on the review comment or thread before resolving it. The reply must
state clearly what you did (e.g. behavior changed, commit subject or SHA so reviewers need
not diff-hunt) or why you did not implement the suggestion (out of scope, wrong
suggestion, intentional tradeoff) with enough detail for a reviewer to understand without
hunting the diff. Use the REST API on the review comment id from the comments listing, for
example:
Use the numericgh api -X POST "repos/<owner>/<repo>/pulls/<number>/comments/<comment-id>/replies" -f body='...'idfrom the PR review-comments listing (GET .../pulls/<number>/comments). Note: a comment’surlfield uses.../pulls/comments/{id}for GET, but creating a reply requires the pull number in the path (.../pulls/<number>/comments/<comment-id>/replies); omitting<number>returns 404. Or use equivalent GraphQL if the repo’s automation prefers it. Top-level PR comments (not tied to a line) may usegh pr commentor issue comment APIs; the requirement is the same — a public explanation tied to the feedback, not a silent resolve. - Resolve the review thread only after that reply exists (when the repo uses resolved threads
as a merge gate).
PullRequestReviewCommentno longer exposespullRequestReviewThreadon thenode(id: …)query — listreviewThreadson the pull request and pick the thread whosecommentsinclude that comment’s GraphQLnode_idfrom the REST listing:# thread id (PRRT_...) for a review comment's node_id (PRRC_... from REST) gh api graphql -f query='query { repository(owner: "<owner>", name: "<repo>") { pullRequest(number: <number>) { reviewThreads(first: 100) { nodes { id comments(first: 50) { nodes { id } } } } } } } }' \ --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select([.comments.nodes[].id] | index("<comment-node-id>")) | .id][0]' # Resolve the thread gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<thread-id>"}) { thread { isResolved } } }'
If any feedback requires clarification or is outside the scope of the PR, use item 4 to explain why it was not implemented — then resolve the thread if appropriate; do not resolve without that public reply.
7. Resolve all review threads
Before proceeding, ensure every review thread on the PR is resolved. Unresolved threads block auto-merge on repos with branch protection. Do not resolve any thread until step 6’s public inline reply exists on that thread (same policy as step 6: silent resolves look like ignored feedback).
List all review threads and check for unresolved ones:
gh api graphql -f query='query { repository(owner: "<owner>", name: "<repo>") { pullRequest(number: <number>) { reviewThreads(first: 100) { nodes { id isResolved comments(first: 1) { nodes { body path } } } } } } }' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)'For each unresolved thread: if there is not already a public reply from you on that thread documenting the outcome (what you fixed with enough context for reviewers to verify, or an explained decline), post one first — e.g.
gh api -X POST "repos/<owner>/<repo>/pulls/<number>/comments/<comment-id>/replies"with-f body='...'— then resolve:gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<thread-id>"}) { thread { isResolved } } }'Threads where the feedback was not implemented still require that explanatory reply before resolve (out of scope, wrong suggestion, tradeoff); declining with explanation satisfies the policy — resolving without any reply does not.
8. Re-review after fixes
If changes were made in step 6, do a quick self-review of the new commits to make sure the fixes are correct and don't introduce new issues. If new problems are found, go back to step 6.
9. Verify CI checks
Wait for all CI status checks to complete:
gh pr checks <number> -R <owner>/<repo> --watch
If any checks fail:
- Inspect the failure logs:
gh run view <run-id> --log-failed - Fix the issue locally, commit, and push
- Wait for checks to re-run and pass
- Repeat until all checks are green
Do not skip or override failing checks. If a check is flaky and the failure is unrelated to the PR, note it in a comment but do not bypass it.
If CI workflows fail to trigger at all (no runs appear after pushing):
- Rebase onto the latest base branch — stale workflow YAML is the most common cause.
- If still no trigger, push an empty commit:
git commit --allow-empty -m "chore: trigger CI" - If still no trigger, close and reopen the PR:
gh pr close <number> -R <owner>/<repo> && gh pr reopen <number> -R <owner>/<repo> - As a last resort, create a new PR from the same branch.
10. Rebase on base branch (with retry loop)
Concurrent merges from other agents move the base branch forward frequently. This step may need to run multiple times — up to 5 attempts. With many parallel agents, the base branch can move several times while CI is running.
For each attempt (max 5):
Check merge state:
gh pr view <number> -R <owner>/<repo> --json mergeStateStatus --jq .mergeStateStatusIf the status is
CLEAN, exit the loop — proceed to step 11.Otherwise (
BEHIND,DIRTY, or any other non-clean state): a. Fetch the latest base branch:git fetch origin <base-branch>b. Rebase onto it:
git rebase origin/<base-branch>c. If there are merge conflicts, resolve them carefully — read both sides before choosing. d. Force-push with lease (safe — only overwrites your own branch):
git push --force-with-leasee. Wait for CI to re-run and pass before the next iteration. f. After CI passes, re-check
mergeStateStatus. If still notCLEAN, loop again.If all 5 attempts fail to reach
CLEAN, inform the user — multiple agents are likely merging concurrently and manual coordination may be needed.
11. Enable auto-merge
Once all feedback is addressed and CI is green (or running), enable auto-merge so GitHub merges the PR automatically when all branch protection requirements are met:
gh pr merge <number> -R <owner>/<repo> --auto --squash
Use --squash by default. If the repo convention prefers merge commits or rebases, match the
convention instead.
Do not use --admin or any flag that bypasses branch protections. The PR must satisfy all
status checks and have all review threads resolved before merging. No human review approval is
required — the only merge gates are CI and resolved threads. If mergeStateStatus is BLOCKED
after CI passes, check for unresolved review threads (step 7) rather than assuming human approval
is needed.
Copilot review note: GitHub Copilot provides a single automated review when the PR is first
created. It does not re-review after subsequent pushes. Do not request new Copilot reviews or
wait for Copilot to review new commits — it won't happen. Copilot review is not a merge gate.
If Copilot left review comments, treat them like any other review thread: address or respond with
a public inline reply, then resolve the thread — never resolve Copilot (or any) threads without
that reply. If mergeStateStatus remains BLOCKED after all threads
are resolved and CI is green, the issue is likely unrelated to Copilot (check for ruleset
requirements, workflow file scope restrictions, or other branch protection rules).
12. Confirm and summarize
Print a summary:
- PR: title and URL
- Review: findings from the subagent review
- Review threads: confirm each thread got an inline reply (implementation or explained decline) before resolve
- Fixes applied: list of commits pushed to address feedback
- CI status: all checks passing
- Merge: auto-merge enabled, will merge when all requirements are met
If auto-merge could not be enabled (e.g., the repo doesn't support it), inform the user and suggest they merge manually once requirements are satisfied.
13. Post-merge session memory rescue
After the PR merges (or when auto-merge is enabled), check whether any session memories are stranded on the current branch but missing from the merged PR. Session memories committed to worktree branches often get lost when the PR squash-merges a different commit chain.
state=$(gh pr view <number> -R <owner>/<repo> --json state --jq .state)
If the state is MERGED:
Check for session memory files added on the local branch but absent from main:
repo_root=$(git rev-parse --show-toplevel) default=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||') [ -z "$default" ] && default="main" git fetch origin "$default" # IMPORTANT: Use --diff-filter=A to only find files ADDED on this branch # (absent from main). Without this filter, files that exist on both branches # but differ (e.g. MEMORY.md) would be included, causing the rescue to # overwrite main's newer version with the worktree's stale copy. orphaned=$(git diff --name-only --diff-filter=A "origin/$default" HEAD -- docs/agent-sessions/ 2>/dev/null)If orphaned session memories are found, rescue them by creating a cleanup PR:
if [ -n "$orphaned" ]; then # Save the current branch ref before switching source_ref=$(git rev-parse HEAD) USERNAME=$(gh api user --jq .login 2>/dev/null || echo "agent") rescue_branch="${USERNAME}/rescue-session-memory-$(date +%Y%m%d-%H%M%S)" git switch -c "$rescue_branch" "origin/$default" # Checkout ONLY the added files (not the entire directory), to avoid # overwriting main's version of shared files like MEMORY.md echo "$orphaned" | while read -r f; do git checkout "$source_ref" -- "$f" done git add docs/agent-sessions/ # Validate: the staged diff should contain only additions, never deletions if git diff --cached --numstat | awk '{if ($2 > 0) exit 1}'; then git commit -m "docs: rescue stranded session memory from merged PR #<number> Why: Session memory was committed to a worktree branch that diverged from the PR's commit chain. The PR merged via squash, leaving the memory stranded." git push -u origin HEAD gh pr create --title "docs: rescue session memory from PR #<number>" \ --body "Rescues session memory files that were stranded on a worktree branch after PR #<number> merged via squash." else echo "Warning: rescue diff contains deletions — aborting to avoid overwriting main." git checkout -- . git switch - git branch -D "$rescue_branch" fi fiIf the rescue PR creation fails, warn the user that session memories need manual rescue.
14. Archive transcript
After a successful merge, automatically archive the current session transcript so future agents can search and recall this session's context.
Prerequisites: A repo-specific transcript archive command must exist (commonly
scripts/transcript-archive). If archive tooling or credentials are missing, warn and skip this
step rather than failing.
Before running archive commands, check repo guidance (AGENTS.md/CLAUDE.md) for required
environment prefixes (for example profile selection or endpoint overrides) and apply them.
Detect session ID and repo root:
repo_root=$(git rev-parse --show-toplevel)Detect the current session ID from runtime-specific sources:
- Prefer
CODEX_THREAD_IDin Codex by matching*${CODEX_THREAD_ID}*.jsonlunder~/.codex/sessions/and~/.codex/archived_sessions/. - Prefer
CLAUDE_SESSION_IDin Claude Code by matching*${CLAUDE_SESSION_ID}*.jsonlunder~/.claude/projects/. - If those env vars are unavailable, detect runtime from path (
.codex/worktreesor.claude/worktrees) and fall back to the most recent.jsonlfor that runtime. - If runtime cannot be inferred from env or path, scan both roots and use the most recent JSONL.
- Use the matched filename without
.jsonlassession_id. - If no JSONL is found, warn and skip archival (best-effort behavior).
- Prefer
Detect context for archive metadata:
current_branch=$(git branch --show-current) worktree_name=$(echo "$PWD" | sed -n 's|.*/\.\(claude\|codex\)/worktrees/\([^/]*\)\(/.*\)\{0,1\}$|\2|p') agent_name="${worktree_name:-shepherd}"Run the archive command:
runtime="" session_id="" if [ -n "${CODEX_THREAD_ID:-}" ]; then runtime="codex" elif [ -n "${CLAUDE_SESSION_ID:-}" ]; then runtime="claude" else runtime=$(echo "$PWD" | sed -n 's|.*/\.\(claude\|codex\)/worktrees/.*|\1|p') fi if [ -n "${CODEX_THREAD_ID:-}" ]; then codex_match=$(find "$HOME/.codex/sessions" "$HOME/.codex/archived_sessions" -type f \ -name "*${CODEX_THREAD_ID}*.jsonl" 2>/dev/null | head -1) [ -n "$codex_match" ] && session_id=$(basename "$codex_match" .jsonl) fi if [ -z "$session_id" ] && [ -n "${CLAUDE_SESSION_ID:-}" ]; then claude_match=$(find "$HOME/.claude/projects" -type f -name "*${CLAUDE_SESSION_ID}*.jsonl" 2>/dev/null | head -1) [ -n "$claude_match" ] && session_id=$(basename "$claude_match" .jsonl) fi if [ -z "$session_id" ]; then if [ "$runtime" = "codex" ]; then latest=$(find "$HOME/.codex/sessions" "$HOME/.codex/archived_sessions" -type f -name "*.jsonl" 2>/dev/null | sort | tail -1) elif [ "$runtime" = "claude" ]; then latest=$(find "$HOME/.claude/projects" -type f -name "*.jsonl" 2>/dev/null | sort | tail -1) else latest=$( find "$HOME/.codex/sessions" "$HOME/.codex/archived_sessions" "$HOME/.claude/projects" \ -type f -name "*.jsonl" 2>/dev/null | sort | tail -1 ) fi [ -n "$latest" ] && session_id=$(basename "$latest" .jsonl) fi if [ -z "$session_id" ]; then echo "Warning: no session JSONL found for current runtime — skipping transcript archival." else ${TRANSCRIPT_ARCHIVE_PREFIX:+${TRANSCRIPT_ARCHIVE_PREFIX} }go run "${repo_root}/scripts/transcript-archive" archive \ --session "$session_id" \ --agent "$agent_name" \ --repo <owner>/<repo> \ --branch "$current_branch" \ --pr <number> \ --summary "Shepherded PR #<number>: <pr-title>" fiTRANSCRIPT_ARCHIVE_PREFIXis optional and lets repos inject required env vars (for example, an AWS profile) without hardcoding private account details in this skill. Do not include a trailing space in the value; the command above adds spacing automatically when it is set.If the
scripts/transcript-archivedirectory does not exist, skip with a warning:Warning: transcript-archive CLI not found — skipping transcript archival.If the archive command fails (e.g., AWS credentials expired), warn the user but do not treat it as a blocking error. The PR is already merged; transcript archiving is best-effort.
15. Post-merge cleanup (standalone only)
When /shepherd-to-merge is invoked standalone (not as part of /pick-up-issue), clean up the local
branch after the PR merges. If the PR is still pending auto-merge, skip this step.
If the state is MERGED:
- Switch away from the PR branch. In a worktree, use
claude/<worktree-name>orcodex/<worktree-name>:runtime=$(echo "$PWD" | sed -n 's|.*/\.\(claude\|codex\)/worktrees/.*|\1|p') worktree_name=$(echo "$PWD" | sed -n 's|.*/\.\(claude\|codex\)/worktrees/\([^/]*\)\(/.*\)\{0,1\}$|\2|p') if [ -n "$worktree_name" ]; then git switch "${runtime}/${worktree_name}" 2>/dev/null || true else default=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||') [ -z "$default" ] && default="main" git switch "$default" fi - Delete the local PR branch:
git branch -D <pr-branch>
16. Rebase remaining queue entries (sequential mode only)
When mode=sequential and at least one PR remains in processing_queue, update each remaining PR
branch onto its own latest base branch before processing the next PR.
- Detect current GitHub user once (for push safety):
current_user=$(gh api user --jq .login) - For each remaining PR in queue order:
pr_number=<remaining-number> pr_meta=$(gh pr view "$pr_number" -R <owner>/<repo> \ --json state,isDraft,baseRefName,headRefName,author,headRepositoryOwner,viewerCanPush,url) pr_state=$(echo "$pr_meta" | jq -r '.state') is_draft=$(echo "$pr_meta" | jq -r '.isDraft') base_ref=$(echo "$pr_meta" | jq -r '.baseRefName') head_ref=$(echo "$pr_meta" | jq -r '.headRefName') pr_author=$(echo "$pr_meta" | jq -r '.author.login') head_owner=$(echo "$pr_meta" | jq -r '.headRepositoryOwner.login') viewer_can_push=$(echo "$pr_meta" | jq -r '.viewerCanPush') if [ "$pr_state" != "OPEN" ] || [ "$is_draft" = "true" ]; then echo "Skipping PR #$pr_number: state=$pr_state draft=$is_draft" continue fi if [ "$viewer_can_push" != "true" ]; then echo "Skipping PR #$pr_number: viewer cannot push to head branch." continue fi if [ "$pr_author" != "$current_user" ] || [ "$head_owner" != "$current_user" ]; then echo "Skipping PR #$pr_number: PR/head branch not owned by current user." echo "Manual fallback:" echo " gh pr checkout $pr_number -R <owner>/<repo>" echo " git fetch origin \"$base_ref\"" echo " git rebase \"origin/$base_ref\"" echo " git push --force-with-lease" continue fi gh pr checkout "$pr_number" -R <owner>/<repo> git fetch origin "$base_ref" git rebase "origin/$base_ref" git push --force-with-lease - If a rebase conflict occurs, stop batch processing and report:
- PR number and branch
- conflicted files
- exact resume command (
/shepherd-to-merge <owner>/<repo> <remaining-prs...>)
Do not auto-resolve ambiguous conflicts.
17. Final queue report (sequential mode only)
After the queue is exhausted (or processing stops early), print:
- PRs merged (number + URL)
- PRs skipped (with reason)
- first blocking PR, if any
- whether the full queue completed
Converted and distributed by TomeVault — claim your Tome and manage your conversions.