---
name: merge-pull-requests
description: Triage, review, and merge open pull requests from multiple sources (OpenSpec, Jules, Codex, Dependabot, manual)
category: Git Workflow
tags: [pr, merge, triage, jules, codex, openspec, review, dependabot]
triggers:
  - "merge pull requests"
  - "review pull requests"
  - "triage PRs"
  - "merge PRs"
  - "check open PRs"
---

# Merge Pull Requests

Discover, triage, and merge open pull requests from multiple sources. Handles OpenSpec PRs, Jules automation PRs (Sentinel/Bolt/Palette), Codex PRs, Dependabot/Renovate PRs, and manual PRs with staleness detection and review comment analysis.

## Arguments

`$ARGUMENTS` - Optional flags: `--dry-run` (report only, no mutations)

## Script Location

Scripts live in `<agent-skills-dir>/merge-pull-requests/scripts/`. Each agent runtime substitutes `<agent-skills-dir>` with its config directory:
- **Claude**: `.claude/skills`
- **Codex**: `.codex/skills`
- **Gemini**: `.gemini/skills`

If scripts are missing, run `skills/install.sh` to sync them from the canonical `skills/` source.

## Prerequisites

- `gh` CLI authenticated (`gh auth status`)
- Repository has a remote configured
- On `main` branch with clean working directory

## Active-Agent Guard (Sync-Point Skill)

Before any other work, verify exclusive access — this skill merges into `main` and must not race other agents:

```bash
python skills/shared/active_agents.py
```

- Exit `0`: no active agents → proceed.
- Exit `1`: one or more active agents hold worktrees → **stop**, surface the list to the operator (the script's stdout already prints it), and ask whether to wait or pass `--force`. Never auto-force.

An entry is "active" when it is pinned OR its `last_heartbeat` is within 1 hour. See `skills/shared/active_agents.py` and CLAUDE.md "Sync-Point Skills" for the contract; `docs/mental-models.md` gap G10 for the rationale.

## Steps

### 1. Verify Environment

```bash
gh auth status
git status
```

**Abort conditions:**
- If `gh` is not authenticated, stop and ask the user to run `gh auth login`.
- If the working directory has uncommitted changes, **stop and warn the user**. Do not run `git checkout main` with a dirty working directory — it could silently carry or lose uncommitted work. Ask the user to commit, stash, or discard changes first.
- If not on `main`, check for uncommitted changes before switching.

**Write access check:** Before proceeding, verify the token has write access:

```bash
gh api repos/{owner}/{repo} --jq '.permissions.push'
```

If this returns `false`, warn the user that merge and close operations will fail and suggest checking token scopes or requesting write access.

### 2. Pull Latest Main

```bash
git checkout main
git pull origin main
```

### 3. Discover and Classify Open PRs

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/discover_prs.py
```

This outputs a JSON array of PRs classified by origin:
- `openspec` - Branch matches `openspec/*` or body contains `Implements OpenSpec:`
- `sentinel` - Jules Sentinel (security fixes)
- `bolt` - Jules Bolt (performance fixes)
- `palette` - Jules Palette (UX fixes)
- `jules` - Jules automation (type not determined)
- `codex` - Created by Codex
- `dependabot` - Dependabot dependency updates
- `renovate` - Renovate dependency updates
- `other` - Manual or unrecognized

Each PR also includes:
- `is_draft` - Whether the PR is a draft (cannot be merged)
- `is_stacked` - Whether the PR targets a branch other than main/master (part of a PR chain)
- `is_fork` - Whether the PR is from a forked repository
- `auto_merge_enabled` - Whether auto-merge is already configured
- `dep_ecosystem` - For Dependabot PRs, the ecosystem (e.g. `npm_and_yarn`, `pip`)

**If no open PRs are found, stop here.**

Present the PR list as a summary table:

```
| #   | Title                          | Origin     | Branch           | Age    | Flags              |
|-----|--------------------------------|------------|------------------|--------|--------------------|
| 42  | Fix XSS in login form          | sentinel   | sentinel/fix-xss | 3 days |                    |
| 40  | Bump lodash from 4.17.19       | dependabot | dependabot/npm/… | 1 day  | auto-merge         |
| 39  | Fix typo in README             | other      | fix-typo         | 2 days | fork               |
| 38  | feat: Add user export          | openspec   | openspec/add-…   | 5 days | stacked            |
| 37  | WIP: Refactor auth module      | other      | refactor-auth    | 7 days | draft              |
```

### 4. Handle Special PR Types

#### Draft PRs

Draft PRs cannot be merged. Flag them in the summary and **skip** them during the merge workflow. If the operator wants to process a draft PR, they must first mark it as ready:

```bash
gh pr ready <pr_number>
```

#### Stacked PRs

PRs that target a branch other than `main`/`master` are part of a PR chain. **Warn the operator** before taking action on stacked PRs:
- Merging or closing the base PR may break the stacked PR
- The base PR should be merged first
- Show which branch the stacked PR targets

#### Fork PRs

PRs from forked repositories have limited permissions:
- The `--delete-branch` flag is skipped automatically (no push access to the fork remote)
- The merge itself works normally
- Flag these in the summary table as `fork`

#### Auto-Merge PRs

PRs with auto-merge already enabled will merge automatically once their conditions are met (CI passes, approvals received). **Recommend skipping** these during manual triage — they don't need intervention. If the operator wants to override, they can proceed normally.

### 5. Check Staleness for Each PR

For each non-draft PR, run staleness detection:

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/check_staleness.py <pr_number> --origin <origin>
```

The script fetches the latest remote state (`git fetch origin main`) before checking. Pay special attention to Jules automation PRs (sentinel, bolt, palette) — the script uses normalized whitespace matching to check whether the code patterns being fixed still exist on main. If not, the PR is marked `obsolete`.

Staleness levels:
- **Fresh**: No overlapping changes — safe to proceed
- **Stale**: Overlapping file changes — review needed before merge
- **Obsolete**: Fix no longer needed — recommend closing

### 5b. Classify CI Failures

For PRs with failing CI, determine the failure class before choosing a fix strategy:

| Failure Class | Symptoms | Fix Strategy |
|---------------|----------|-------------|
| **Transient** | Flaky test, network timeout, OOM, runner issue | `rerun-checks` — same code should pass on retry |
| **PR-specific** | Lint/test error in files the PR modified | Fix the code, commit, push (triggers fresh CI) |
| **Stale merge** | Error in files the PR did NOT modify; same error across multiple unrelated PRs | `refresh-branch` or rebase onto main |

**How to detect stale-merge failures**: If the same CI check fails identically on 3+ unrelated PRs, and the failing files are not in those PRs' change sets, it is almost certainly a stale merge commit. `gh run rerun` will NOT fix this — it replays the workflow against the same merge commit snapshot, not a fresh one.

To refresh the merge commit without a local rebase:

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/merge_pr.py refresh-branch <pr_number>
```

This calls the GitHub Update Branch API to merge the current base into the PR branch, producing a new merge commit that triggers fresh CI automatically. For PRs that will be squash-merged, the merge commit is discarded at merge time.

The `check_staleness.py` script includes a `ci_merge_base_stale` field that compares the PR's merge base with the current base branch HEAD. When `ci_merge_base_stale` is `true` and CI is failing, prefer `refresh-branch` over `rerun-checks`.

### 6. Identify Conflicting PR Pairs

After running staleness checks for all PRs, compare their file lists to identify PR pairs that modify the same files. Warn the operator before the interactive review:

```
⚠ PRs #42 and #38 both modify src/auth.py — merging one may make the other stale.
⚠ PRs #40, #41, and #43 all touch package.json — consider merge order carefully.
```

This helps the operator decide merge order proactively rather than discovering conflicts after each merge.

### 7. Batch Close Obsolete PRs

If any PRs are classified as **obsolete**:

```bash
# Show obsolete PRs and ask for confirmation
python3 <agent-skills-dir>/merge-pull-requests/scripts/merge_pr.py batch-close <pr_numbers_comma_sep> \
  --reason "Closing as obsolete: the code patterns this PR fixes no longer exist on main. The underlying issue has been addressed by other changes."
```

Present the list of obsolete PRs and confirm with the operator before closing. Skip this step if no PRs are obsolete.

### 8. Analyze Review Comments

For remaining PRs (non-obsolete, non-draft), check for unresolved review comments:

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/analyze_comments.py <pr_number>
```

This uses the GitHub GraphQL API to get accurate thread resolution status:
- `is_resolved` - Whether the thread has been marked resolved
- `is_outdated` - Whether the comment is on outdated code
- Unresolved thread details: file path, line, reviewer, comment summary
- Review approval state per reviewer

### 9. Conditional Multi-Vendor Review

For PRs that lack detailed reviews and are large enough to warrant automated analysis, dispatch multi-vendor reviews using the review infrastructure from `parallel-implement-feature`.

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/vendor_review.py <pr_number> \
  --origin <origin> --reviews-json <comments_output_path> [--dry-run]
```

**Review is dispatched when ALL conditions are met:**
- Origin is `openspec`, `codex`, or `other` (non-trivial PRs)
- PR is not a draft
- No existing fresh approvals
- No outstanding `CHANGES_REQUESTED` reviews
- PR is non-trivial: more than 50 changed lines OR more than 3 changed files

**Review is skipped when ANY condition is met:**
- Origin is `sentinel`, `bolt`, `palette`, `jules`, `dependabot`, or `renovate` (scoped automation or dependency updates)
- PR already has 1+ approval
- PR is small (≤50 changed lines AND ≤3 files)
- `--dry-run` mode is active (reports eligibility only)

The script:
1. Computes PR size (additions, deletions, file count)
2. Checks eligibility against the rules above
3. If eligible, checks for OpenSpec planning artifacts (contracts, work-packages) in the change directory:
   - If artifacts exist: includes contract and scope information in the review prompt for richer vendor context
   - If artifacts are missing (legacy PRs, external contributions, non-OpenSpec PRs): proceeds with PR diff and metadata only — review does not fail or skip due to missing artifacts
4. Dispatches to available vendor CLIs (Codex, Gemini) in read-only mode
5. Synthesizes a consensus report from vendor findings
6. Outputs JSON with eligibility status and review findings

**Present findings to the operator** alongside the existing comment analysis in the interactive review step:

```
🔍 Vendor Review (2 vendors):
  ✓ Confirmed (2 vendors agree): 3 findings
    - [HIGH/security] Missing input validation on /api/users endpoint
    - [MEDIUM/correctness] Off-by-one error in pagination logic
    - [LOW/style] Inconsistent naming in helper functions
  ⚠ Unconfirmed (1 vendor only): 1 finding
    - [LOW/performance] Consider caching for repeated lookups
  Blocking: 1 (confirmed fix findings)
```

If vendor review produces **blocking findings** (confirmed issues with disposition=fix), recommend the operator skip or address the issues before merging.

If vendor CLIs are unavailable or all vendors fail, proceed without vendor review and note the gap.

### 9.5. Merge-Time Validation Gate for OpenSpec PRs

For OpenSpec PRs (`openspec/*` branch), check whether Docker-dependent validation has been run. Cloud-created PRs typically pass environment-safe checks (pytest, mypy, ruff, openspec validate) during implementation but lack deployment-based validation.

**Triggers when ALL conditions are met:**
- PR origin is `openspec`
- PR is not a draft
- No `validation-report.md` exists at `openspec/changes/<change-id>/`, OR the existing report is missing deploy/smoke/security/e2e phases

**Skip when ANY condition is met:**
- `validation-report.md` exists with all phases completed
- `--dry-run` mode is active
- Docker is not available (`docker info` fails)

**Action**: Delegate to `/validate-feature` with the Docker-dependent phases only:

```
/validate-feature <change-id> --phase deploy,smoke,security,e2e
```

This runs the canonical validation skill targeting only the phases that require local infrastructure. The skill handles worktree isolation, service lifecycle, report generation, and teardown. The resulting `validation-report.md` is committed to the PR branch so subsequent triage sessions skip this step.

**Present findings** in the interactive review step alongside vendor review results:

```
Merge-Time Validation (OpenSpec: <change-id>):
  ✓ Deploy: Services started (3 containers)
  ✓ Smoke: 5/5 health checks passed
  ○ Security: Skipped (Java not available)
  ○ E2E: Skipped (no tests/e2e/ directory)
  Result: PASS (2 passed, 2 skipped)
```

If any phase **fails**, flag the PR with a warning but do not hard-block — the operator decides whether to merge, fix, or skip. Critical failures (deploy crash, smoke test failures) should be highlighted prominently.

### 9.6. Check Holdout Gate (OpenSpec PRs Only)

If `openspec/changes/<change-id>/rework-report.json` exists, check whether holdout scenario failures block the merge:

```bash
REWORK_REPORT="openspec/changes/$CHANGE_ID/rework-report.json"
if [[ -f "$REWORK_REPORT" ]]; then
  HAS_BLOCKING=$(python3 -c "
import json
data = json.load(open('$REWORK_REPORT'))
print(data.get('summary', {}).get('has_blocking_holdout', False))
")
  if [[ "$HAS_BLOCKING" == "True" ]]; then
    echo "WARNING: Holdout scenario failures detected in rework report"
    echo "The rework report indicates blocking holdout failures."
    echo "Consider running /iterate-on-implementation and /validate-feature before merge."
  fi
fi
```

Holdout gate status is presented as a **warning** during the interactive review — it does not auto-block. The operator decides whether the holdout failures are acceptable for this merge or need resolution first.

### 10. Determine Merge Order

Before the interactive review, sort remaining PRs for optimal merge order:

1. **Security fixes first** (sentinel origin) — critical fixes shouldn't wait
2. **Non-overlapping PRs** (fresh staleness) — safe to merge without conflict risk
3. **Dependency updates** (dependabot/renovate) — low-risk, well-tested
4. **Stale PRs last** — require manual review of overlapping changes

Within the dependency updates group, consider grouping by ecosystem (e.g. all `npm_and_yarn` bumps together) — if one fails, it may indicate an ecosystem-wide issue.

This ordering minimizes the chance that merging one PR invalidates another.

### 11. Interactive PR Review

Process each remaining PR one at a time **in the order determined above**. Skip PRs with `auto_merge_enabled` unless the operator explicitly wants to review them. For each PR, present:
- Classification and staleness status
- Unresolved comments (if any) — distinguished from resolved threads
- **Vendor review findings** (if dispatched in Step 9) — confirmed, unconfirmed, and blocking findings
- CI and approval status (noting pending vs failed checks)
- Whether checks are still running (offer to wait)
- Whether the PR is from a fork (note: branch won't be deleted)
- Whether approval may be stale (commits pushed after last approval)
- Pending reviewers (CODEOWNERS or manually requested) — even if `reviewDecision` is APPROVED, pending reviewers may indicate a missing required review

Then offer actions:

1. **Merge** - Merge the PR (strategy selected by origin — see table below)
2. **Skip** - Move to the next PR
3. **Close** - Close the PR with a comment
4. **Address comments** - Work through unresolved review feedback
5. **Wait** - (if checks pending) Wait for CI to complete, then re-validate
6. **Re-run CI** - (if checks failed) Re-run failed workflow runs

#### Save Point Pattern

When iterating on a complex merge resolution (rebase conflicts, repeated CI fixes, multi-step "address comments" passes), **commit at every working slice** with a `wip:` prefix. Squash before final merge.

```bash
# After each working slice (tests pass, lint passes, the change makes sense in isolation):
git add -A
git commit -m "wip: <description of the slice that just started working>"

# Before opening / re-opening for review, squash the wip commits into logical units:
git rebase -i <base-branch>     # mark wip: commits as `s` (squash) into a parent
# OR if the operator prefers a single squashed commit at merge:
gh pr merge --squash             # GitHub squashes them at merge time
```

**Why this matters:** A complex merge resolution often takes 5-30 incremental fixes. Without save points, a single mistake can lose all the prior progress. With `wip:` save points you can `git reset --hard <last-wip-sha>` to return to the most recent known-good state without re-doing everything. The squash step at the end keeps the public history clean.

The `wip:` prefix is the agreed signal: any commit starting with `wip:` is **assumed to be squashed before merge** and should never appear on `main`. This skill's pre-merge gate refuses to merge a PR whose head commit message starts with `wip:` unless `--strategy squash` is in effect.

#### Change Summary template

Every PR ready-for-review MUST include the following template in its description. Reviewers depend on it; the bot scaffolds it; the skill's gate checks for it.

```
CHANGES MADE:
- <bullet list of what this PR actually does>

DIDN'T TOUCH:
- <out-of-scope items intentionally not addressed — name them so reviewers don't ask>

CONCERNS:
- <known issues, follow-ups, things reviewers should challenge>
```

**Why all three sections matter:**
- `CHANGES MADE` forces the author to enumerate the actual changes (not just link the title) — exposes scope creep early.
- `DIDN'T TOUCH` pre-empts the most common reviewer round-trip ("why didn't you also fix X?"). Naming the boundary up front saves a review cycle.
- `CONCERNS` is the author's invitation to be challenged. A PR with `CONCERNS: none` after a non-trivial change is a **red flag**: real changes always have at least one open question.

If a PR is missing this section, this skill flags it as `description-incomplete` during the interactive review and offers to scaffold the template into the body before merging.

#### Merge Strategy Selection

The merge strategy is selected based on PR origin to balance history preservation with cleanliness:

| Origin | Default Strategy | Rationale |
|--------|-----------------|-----------|
| `openspec`, `codex` | **rebase** | Agent PRs with structured commits — preserve granular history for `git blame`/`bisect` |
| `sentinel`, `bolt`, `palette` | squash | Jules automation — typically single-purpose fixes |
| `dependabot`, `renovate` | squash | Dependency bumps — one logical change |
| `other` | squash | Manual PRs — unknown commit quality, safe default |

The operator can override any default by passing `--strategy <squash|merge|rebase>`.

#### Merge a PR

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/merge_pr.py merge <pr_number> --origin <origin>
```

Pass `--origin` using the `origin` field from `discover_prs.py` output so the script selects the appropriate strategy automatically. To override:

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/merge_pr.py merge <pr_number> --origin <origin> --strategy squash
```

The script validates CI status (distinguishing failed from pending), draft status, merge conflicts, and mergeability before merging. It handles:
- **Fork PRs**: Automatically skips `--delete-branch`
- **Merge queue repos**: If direct merge fails because a merge queue is required, automatically retries with `--merge-queue`
- **Branch deletion failure**: Detects when merge succeeded but branch deletion failed, reports as warning
- **Merge conflicts**: Surfaces `CONFLICTING` status with specific guidance to rebase or merge the base branch
- **Stale approvals**: Warns if commits were pushed after the last approval
- **Pending reviewers**: Shows which reviewers (including CODEOWNERS teams) haven't reviewed yet
- **Conditional approval gate**: Probes the base branch's protection rules and only requires approval when GitHub itself would (i.e., `required_approving_review_count >= 1`). Solo repos and unprotected branches merge without an approval check, the same way `gh pr merge` would allow. Pass `--force-approval` to force-bypass even when protection requires approval (admin overrides, or when probing protection failed and the gate fell back to strict mode).

**After every merge, update local state:**
```bash
git pull origin main
```

This ensures subsequent staleness checks and merges operate on the current main.

For **OpenSpec PRs**: After a successful merge, record the PR number, head branch, and change-id for the final post-merge cleanup approval step. Do not run `/cleanup-feature` immediately inside the per-PR merge loop.

Example record shape:

```json
{
  "pr_number": 42,
  "origin": "openspec",
  "change_id": "add-user-export",
  "branch": "openspec/add-user-export",
  "success": true,
  "status": "merged"
}
```

Keep these records scoped to PRs merged during this invocation only. They are the input to Step 11.5.

#### Re-run Failed CI Checks

> **Important**: `rerun-checks` replays the workflow against the **same merge commit**. It does NOT pick up changes to the base branch. Use this only for **transient** failures (flaky tests, timeouts, OOM). For failures caused by stale base-branch code, use `refresh-branch` or rebase instead. See Step 5b for the diagnostic flowchart.

```bash
# Transient failure — replay same merge commit:
python3 <agent-skills-dir>/merge-pull-requests/scripts/merge_pr.py rerun-checks <pr_number>

# Stale merge commit — merge current base into PR branch for fresh CI:
python3 <agent-skills-dir>/merge-pull-requests/scripts/merge_pr.py refresh-branch <pr_number>
```

`rerun-checks` finds failed workflow runs on the PR's branch and re-runs only the failed jobs. `refresh-branch` calls the GitHub Update Branch API to merge the base branch into the PR branch, producing a new merge commit. Both trigger CI; after either, offer to **Wait** for the checks to complete.

#### Re-check Staleness After Merge

After merging a PR, the staleness assessment for remaining PRs may be outdated. **Re-run staleness detection** for the next PR before presenting it:

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/check_staleness.py <next_pr_number> --origin <origin>
```

If a previously fresh PR is now stale (due to overlapping with the just-merged PR), update the assessment before offering actions.

#### Close a PR

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/merge_pr.py close <pr_number> --reason "<explanation>"
```

#### Address Comments

For PRs with unresolved comments:
1. Present each unresolved thread (skip resolved/outdated ones)
2. Check out the PR branch: `git checkout <branch>`
3. Make the requested changes
4. Commit and push
5. Return to main: `git checkout main`
6. Return to the PR review workflow

### 11.5. Post-Merge OpenSpec Cleanup Approval

After the PR review loop completes, prepare a single post-merge cleanup prompt for any **local OpenSpec** PRs merged during this invocation.

**Do not run this step in `--dry-run` mode.** In dry-run mode, report which cleanup commands would be offered for approval.

Use the merged PR records collected during Step 11:

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/post_merge_cleanup.py \
  --merged-json <merged_prs_this_pass.json>
```

The helper is non-mutating. It filters to merged OpenSpec PRs whose `openspec/changes/<change-id>/` directory exists locally, checks local worktree registry and branch remnants, and renders an approval prompt like:

```text
Merged local OpenSpec PRs eligible for post-merge cleanup:

| PR | Change ID | Branch | Local remnants | Command |
|----|-----------|--------|----------------|---------|
| #42 | add-user-export | openspec/add-user-export | 2 worktree registry entries, 3 local branches | `/cleanup-feature add-user-export --post-merge --pr 42` |

Ask the operator: Proceed with post-merge cleanup for these changes?
Only run the listed cleanup commands after explicit approval.
```

If the operator approves, run the listed cleanup commands sequentially:

```bash
/cleanup-feature <change-id> --post-merge --pr <pr_number>
```

The `--post-merge` cleanup mode must:
- Confirm the PR is already merged.
- Skip the PR merge and pre-merge validation stages.
- Archive the OpenSpec change, sync specs, validate, commit, and push.
- Remove local worktrees and local branches for that change.
- Treat dirty worktrees or branch deletion failures as operator-attention items, not silent force deletions.

If the operator declines, do not clean up local remnants. Record the declined cleanup commands in the summary and merge log.

If a post-merge cleanup command fails, stop the cleanup pass, preserve the error output in the summary, and do not proceed to the next cleanup command until the operator decides how to continue.

### 12. Summary

After processing all PRs, present a summary:

```
## PR Triage Summary
- Merged: #42, #38
- Queued (merge queue): #45
- Closed (obsolete): #35, #33
- Skipped: #40
- Skipped (draft): #37
- Skipped (auto-merge): #41
- CI re-run: #39
- Comments addressed: #38
- Post-merge OpenSpec cleanup: #38 add-user-export (approved, completed)
- Post-merge OpenSpec cleanup declined: #44 improve-validation-flow
- Merge-time validation: #38 (deploy: pass, smoke: pass, security: skip, e2e: skip)
```

### 13. Append Merge Log

Write a merge-log entry to `docs/merge-logs/YYYY-MM-DD.md` capturing the triage decisions, vendor review findings, and user steering from this session.

**Create directory if needed:**

```bash
mkdir -p docs/merge-logs
touch docs/merge-logs/.gitkeep
```

**Merge-log entry template:**

```markdown
---

## Session: <HH:MM> (<agent-type>)

### PRs Processed

| PR | Origin | Action | Rationale |
|----|--------|--------|-----------|
| #<number> | <origin> | <merged/closed/skipped> | <brief rationale> |

### Vendor Review Findings
- <PR #N>: <N> confirmed findings (<disposition>), <N> unconfirmed (<disposition>)

### User Decisions
- <User steering decisions captured during the session>

### Post-Merge Cleanup
- <OpenSpec cleanup approvals/declines/failures and commands run>

### Observations
- <Cross-PR patterns, recurring issues, notable observations>
```

**Focus on**: Cross-PR reasoning (why PRs were processed in this order, how they relate), user steering decisions, vendor review outcomes, and observations about patterns.

**Sanitize-then-verify:**

```bash
python3 "<skill-base-dir>/../session-log/scripts/sanitize_session_log.py" \
  "docs/merge-logs/<date>.md" \
  "docs/merge-logs/<date>.md"
```

Read the sanitized output and verify: (1) all sections present, (2) no incorrect `[REDACTED:*]` markers, (3) markdown intact. If over-redacted, rewrite without secrets, re-sanitize (one attempt max). If sanitization exits non-zero, skip merge log and proceed.

**Commit and push:**

```bash
git add docs/merge-logs/
git commit -m "chore: merge-log <YYYY-MM-DD>"
git push
```

## Dry-Run Mode

When invoked with `--dry-run`, the skill runs all discovery and analysis steps but performs no mutations (no merges, no closes, no comments). Pass `--dry-run` to each script:

```bash
python3 <agent-skills-dir>/merge-pull-requests/scripts/discover_prs.py --dry-run
python3 <agent-skills-dir>/merge-pull-requests/scripts/check_staleness.py <pr> --origin <type> --dry-run
python3 <agent-skills-dir>/merge-pull-requests/scripts/analyze_comments.py <pr> --dry-run
python3 <agent-skills-dir>/merge-pull-requests/scripts/vendor_review.py <pr> --origin <type> --dry-run
python3 <agent-skills-dir>/merge-pull-requests/scripts/post_merge_cleanup.py --merged-json <merged_prs_this_pass.json>
```

Output a full report:

```
## Dry-Run Report
| #   | Title              | Origin     | Staleness | Unresolved | CI      | Vendor Review      | Flags              |
|-----|--------------------|------------|-----------|------------|---------|--------------------|--------------------
| 42  | Fix XSS in login   | sentinel   | obsolete  | 0          | pass    | skip (origin)      |                    |
| 41  | Bump axios         | dependabot | fresh     | 0          | pass    | skip (origin)      | auto-merge         |
| 40  | Bump lodash        | dependabot | fresh     | 0          | pass    | skip (origin)      |                    |
| 39  | Fix typo           | other      | fresh     | 0          | fail    | skip (small)       | fork               |
| 38  | feat: Add export   | openspec   | fresh     | 2          | pass    | 3 findings (1 fix) |                    |
| 37  | WIP: Refactor auth | other      | —         | 1          | pending | skip (draft)       | draft              |
| 35  | Fix slow query     | bolt       | stale     | 0          | pass    | skip (origin)      | stacked            |
```

## Output

- PRs merged, closed, or skipped with reasons
- PRs added to merge queue (for repos that use it)
- Obsolete PRs batch-closed with explanatory comments
- OpenSpec change-ids offered for post-merge `/cleanup-feature --post-merge` approval
- Post-merge cleanup approvals, declines, completions, and failures
- Draft PRs flagged (not processed)
- Fork PRs handled (no branch deletion)
- Auto-merge PRs noted (recommended to skip)
- Stacked PRs warned about dependency chain
- Conflicting PR pairs warned about before merge
- **Vendor review findings** for eligible PRs (confirmed, unconfirmed, blocking)
- Failed CI re-runs triggered
- Summary of all actions taken

## Error Handling

- **gh not installed**: Scripts detect this and exit with a clear error message
- **gh not authenticated**: Stop and ask user to run `gh auth login`
- **No write access**: Detected early via `permissions.push` check — warn before attempting mutations
- **Dirty working directory**: Abort before `git checkout main` to prevent losing uncommitted work
- **Merge conflicts**: Surface `CONFLICTING` status with guidance to rebase or merge base branch
- **CI checks pending**: Distinguish from failed — offer to wait
- **CI checks failed**: Show failing checks, offer to re-run failed workflow runs
- **Merge queue required**: Automatically retry with `--merge-queue` when direct merge is rejected
- **Branch deletion failure**: Detect and report as warning (merge still succeeded)
- **Post-merge cleanup declined**: Leave local OpenSpec worktrees/branches intact and record the declined cleanup command in the merge log
- **Post-merge cleanup failure**: Stop the cleanup pass and surface the failure; do not continue deleting local remnants for later changes without operator direction
- **Fork PRs**: Automatically skip `--delete-branch` (no push access to fork remote)
- **Stale approvals**: Warn when commits were pushed after the last review approval
- **Pending reviewers (CODEOWNERS)**: Surface pending reviewer requests even when `reviewDecision` shows APPROVED
- **Subprocess timeout**: All `gh`/`git` calls have timeouts (30-60s) to prevent hangs
- **API rate limits**: Scripts use `gh` CLI which handles token refresh; if rate-limited, wait and retry
- **Stacked PRs**: Warn about dependency chain before allowing close/merge

## Common Rationalizations

| Rationalization | Why it's wrong |
|---|---|
| "I'll skip the Change Summary — the PR title is self-explanatory" | Titles compress; summaries enumerate. Reviewers waste time inferring scope from diffs when `DIDN'T TOUCH` would have answered the question in one line. |
| "`wip:` save points clutter history — I'll just be careful and not break anything" | Careful is a plan that fails the first time. Save points are insurance; squashing at merge erases the clutter. The cost is zero, the upside is recovering hours of lost progress. |
| "All my CONCERNS are minor — I'll write `CONCERNS: none`" | Non-trivial changes always have at least one open question. `none` signals either the author hasn't thought hard enough, or is hiding doubts to get the PR through. |
| "Auto-merge is on; I don't need to triage" | Auto-merge merges when checks pass — it doesn't catch obsolete fixes, conflicting PR pairs, or stale base-branch failures. Triage is the layer above auto-merge, not redundant with it. |
| "CI is flaky, just rerun" | `rerun-checks` replays the SAME merge commit. If the failure is a stale-base issue, rerun is theatre — `refresh-branch` is the real fix. See Step 5b. |

## Red Flags

- A merged PR's description has no `CHANGES MADE / DIDN'T TOUCH / CONCERNS` block.
- A PR's head commit starts with `wip:` and is being merged with `--rebase` (the wip commit will land on main).
- The same CI check fails identically across 3+ unrelated PRs but the operator keeps clicking "Re-run failed jobs" instead of refreshing branches.
- An obsolete-classified PR was merged anyway because "the diff looked harmless".
- The merge log for the day is empty even though PRs were merged (decision history lost).
- A stacked PR's base PR was closed without warning the operator about the chain.

## Verification

1. The merge log entry for today (`docs/merge-logs/<YYYY-MM-DD>.md`) lists every PR processed with its origin, action, and rationale — not just the merged ones.
2. For every merged PR with a non-trivial diff (>50 lines or >3 files), the PR description contained the Change Summary template before merge (check via `gh pr view <pr> --json body`).
3. No commit on `main` has a message starting with `wip:` after this skill ran (`git log main --since=<start-time> --pretty=%s | grep -i ^wip:` returns empty).
4. Stale-base CI failures were resolved with `refresh-branch`, not repeated `rerun-checks` (the merge log records which strategy was used and why).
5. Obsolete PRs were closed with the explanatory comment (not silently), and the close reason is recorded in the merge log.
6. For every local OpenSpec PR merged during the pass, the summary and merge log record whether post-merge cleanup was approved, declined, skipped, or failed.
