Skill v1.0.1
currentAutomated scan100/100+2 new
version: "1.0.1" name: merge-pr description: | Merge an open PR for the current branch. Refuses to merge when the worktree has uncommitted changes, consolidates the branch's plan file (renaming the latest Handoff Context section to Final Progress and moving the file to done/ in state-machine mode), merges via GitHub CLI, and reports status. Also cleans up stale merged-but-not-removed worktrees from prior sessions on every Claude Code run. Codex V1 uses a merge-only profile that performs no pre-merge plan or worktree mutation. Use when the user says "merge", "merge PR", "merge this", or wants to land the current branch.
Merge PR
Host profiles
Before any other action, check whether CODEX_THREAD_ID is set.
- Unset — Claude Code: run Steps 0–6 unchanged.
- Set — Codex V1: use the merge-only profile. Skip Steps 0 and 3 entirely;
run Steps 1, 2, 4, 5, and 6. Do not clean another worktree, consolidate or move a plan, create a commit, or push before the CI gate. This deliberately omits Claude's plan-state housekeeping so an Autopilot call after Greenlight cannot mutate the reviewed head before merge.
The Codex profile keeps the current worktree and uses GitHub's atomic --match-head-commit precondition. After GitHub reports the PR merged, it may delete the remote feature branch; failure to delete is reported as cleanup debt, never misreported as a failed merge.
Core Design: Why the Current Session's Own Worktree Cannot Be Deleted
When a Claude Code session starts, it locks a primary working directory. After each Bash tool call completes, the harness resets CWD back to that path. If the current session's worktree is deleted mid-session (e.g. via git worktree remove), the next Bash call fails its cd before any command runs — the entire session becomes non-functional.
Solution: leave your own worktree for the next session to clean up. Every /merge-pr run scans all worktrees and removes stale merged ones, except the current session's own worktree. This caps the long-term residue at 1 leftover worktree, which gets cleaned on the next /merge-pr run from any other session.
Flow
Step 0: Scan and clean up stale worktrees from prior sessions (Claude only)
On Codex, skip this step. On Claude Code, regardless of whether there is a new PR to merge, start by cleaning up any worktrees left over from previous sessions whose branches have already been merged. Skip only the current session's own worktree.
CURRENT_WORKTREE=$(git rev-parse --show-toplevel)MAIN_REPO=$(dirname "$(git rev-parse --git-common-dir)")# List all worktrees with their branch names, excluding current and main repo.# Parsed in bash rather than awk: a skill body is not a safe place for `$N` of# any kind (the harnesses disagree about substituting it — see# ../../shared/config.sh). Prefix-stripping also keeps paths containing spaces# intact, and a detached-HEAD worktree emits no `branch` line so it is skipped.git worktree list --porcelain | while IFS= read -r line; docase "$line" in"worktree "*) wt_path="${line#worktree }" ;;"branch refs/heads/"*) printf '%s\t%s\n' "$wt_path" "${line#branch refs/heads/}" ;;esacdone | while IFS=$'\t' read -r wt br; do[ "$wt" = "$CURRENT_WORKTREE" ] && continue # Never delete own worktree (would break session CWD)[ "$wt" = "$MAIN_REPO" ] && continue # Never delete main repo[[ "$br" = "main" || "$br" = "master" ]] && continue# Only remove if the branch has a merged PR and no currently open PR# (open PR check guards against branch-name reuse: an older merged PR# with the same name must not cause deletion of an active worktree)OPEN_PR=$(gh pr list --state open --head "$br" --json number --jq '.[0].number' 2>/dev/null)[ -n "$OPEN_PR" ] && continuePR_NUM=$(gh pr list --state merged --head "$br" --json number --jq '.[0].number' 2>/dev/null)if [ -n "$PR_NUM" ]; thenecho "Cleaning up stale worktree: $wt (branch: $br, PR #$PR_NUM merged)"git worktree remove --force "$wt" 2>/dev/null || truegit branch -D "$br" 2>/dev/null || truegit push origin --delete "$br" 2>/dev/null || truefidone# Prune stale entries in the worktree registry (directories deleted manually)git worktree prune -vecho "=== Step 0 complete ==="git worktree list
Step 1: Confirm the current PR to merge
Determine the branch name from the current working context. If there is no open PR for this branch (e.g. the user only wanted stale-worktree cleanup), stop after Step 0.
BRANCH=$(git branch --show-current)PR_NUMBER=$(gh pr view --json number --jq '.number' 2>/dev/null)if [ -z "$PR_NUMBER" ]; thenif [ -n "${CODEX_THREAD_ID:-}" ]; thenecho "No open PR for the current branch — stopping without changes."elseecho "No open PR for the current branch — cleanup only, not merging."fiexit 0fiecho "Ready to merge: branch=$BRANCH, PR=#$PR_NUMBER"
Step 2: Pre-merge uncommitted-changes check (only runs in a worktree)
Refuse to proceed if the worktree has any uncommitted changes. The new flow commits everything intentionally, so anything uncommitted here is a mistake that must be surfaced before merging.
IS_WORKTREE=$([ "$(git rev-parse --git-common-dir)" != "$(git rev-parse --git-dir)" ] && echo yes || echo no)if [ "$IS_WORKTREE" = "yes" ]; then# Refusal: the new flow commits everything intentionally, so anything# uncommitted at this point is unintentional and must be surfaced.if ! git diff --quiet HEAD; thenecho "Worktree has uncommitted changes — refusing to merge."echo "Commit or stash first, then re-run /merge-pr."exit 1fifi
Step 3: Consolidate the plan file for this branch (Claude only)
On Codex, skip this step. On Claude Code, resolve the plan file associated with the current branch, rename the latest Handoff Context section to Final Progress, delete older same-branch Handoff Context sections, update the status line, optionally move the file to done/, and commit each change.
Step 3a: Resolve plan file path
# --- solopreneur config helpers (sourced from shared/config.sh) ---# One real shell file, so no harness rewrites the helpers on the way to the# shell. Claude Code replaces the ${CLAUDE_SKILL_DIR} token below when it loads# this body; Codex does not. It is SINGLE-quoted on purpose — it is a load-time# token, not an environment variable, and letting the shell expand the name# would source whatever an inherited value happened to point at. Unreplaced, it# is not a directory, so substitute the absolute path of the directory holding# THIS SKILL.md — every harness states that path to the model.SOLO_SKILL_DIR='${CLAUDE_SKILL_DIR}'[ -d "$SOLO_SKILL_DIR" ] || SOLO_SKILL_DIR="<absolute path of the directory holding this SKILL.md>"SOLO_CONFIG_SH="$SOLO_SKILL_DIR/../../shared/config.sh"# Three candidates, one contract. Inside the plugin the helpers sit at ../../shared/;# authoring against this repo reaches them under src/solopreneur/shared/; and a# skill republished on its own — any flattened skills directory — carries them# at scripts/config.sh instead, because shared/ is a sibling of skills/ and does# not travel with a per-skill copy. Try each in order, then STOP. Sourcing a# file that is not there does not halt the shell: every helper stays undefined,# every config read returns empty, and the 2026-08-11 A2 run showed where that# leads — the model "rescued" it with a repo-relative path, which resolves only# when the repo under review happens to be this plugin's own source repo.# Canonical authoring keeps non-skill source under src/.[ -f "$SOLO_CONFIG_SH" ] || SOLO_CONFIG_SH="$SOLO_SKILL_DIR/../../../src/solopreneur/shared/config.sh"[ -f "$SOLO_CONFIG_SH" ] || SOLO_CONFIG_SH="$SOLO_SKILL_DIR/scripts/config.sh"[ -f "$SOLO_CONFIG_SH" ] || { echo "HALT: solopreneur config helpers not found under $SOLO_SKILL_DIR — stop here, do not improvise a path"; exit 1; }source "$SOLO_CONFIG_SH"# --- end solopreneur config helpers ---TODOS_CONFIG=$(read_solopreneur_config todos)PLANS_CONFIG=$(read_solopreneur_config plans)BACKLOG=$(echo "${TODOS_CONFIG:-{}}" | jq -r '.backlog // empty')DOING=$(echo "${TODOS_CONFIG:-{}}" | jq -r '.doing // empty')DONE_DIR=$(echo "${TODOS_CONFIG:-{}}" | jq -r '.done // empty')PLANS_DIR=$(echo "${PLANS_CONFIG:-{}}" | jq -r '.dir // empty')PLANS_DIR="${PLANS_DIR:-docs/solopreneur/plans}"BRANCH=$(git branch --show-current)
Build the list of plan roots — union of $BACKLOG, $DOING, $DONE_DIR, and $PLANS_DIR (whichever are set and exist on disk).
Resolution order (first match wins):
- Plan-Branch marker (primary): search for
*.mdfiles containing
Plan-Branch: <exact branch> in any plan root.
``bash PLAN_FILE="" for root in "$BACKLOG" "$DOING" "$DONE_DIR" "$PLANS_DIR"; do [ -z "$root" ] && continue [ -d "$root" ] || continue match=$(grep -lF "Plan-Branch: ${BRANCH}" "$root"/*.md 2>/dev/null | head -1) if [ -n "$match" ]; then PLAN_FILE="$match" break fi done ``
- Commit-history grep (fallback): only if
PLAN_FILEis still empty.
Find the most recent commit matching the handoff pattern and extract its .md file path.
```bash if [ -z "$PLAN_FILE" ]; then HANDOFF_SHA=$(git log --pretty=format:'%H %s' main..HEAD \
if [ -n "$HANDOFF_SHA" ]; then PLAN_FILE=$(git show --name-only --format='' "$HANDOFF_SHA" \
| grep -E '\.md$' \ |
|---|
| head -1) |
fi fi ```
- No match → skip consolidation. Not every branch has a plan file.
Step 3 still succeeds; consolidation is simply not performed.
Step 3b: Rule-based consolidation
Only runs if PLAN_FILE is set and the file exists on disk.
Changes to make in `PLAN_FILE`:
- Find all
## Handoff Context (<date>, branch: <BRANCH>)sections
(matched by branch name in the heading):
``bash ESC_BRANCH=$(printf '%s' "$BRANCH" | sed 's/[]\.[*^$(){}?+|]/\\&/g') grep -n "^## Handoff Context (.*branch: ${ESC_BRANCH})" "$PLAN_FILE" ``
- Rename the latest matching section to
## Final Progress (merged <MERGE_DATE>, branch: <BRANCH>) where <MERGE_DATE> = $(date +%Y-%m-%d). Do NOT modify any [ ] or [x] checkboxes inside the section.
- Delete older same-branch Handoff Context sections (all but the latest).
"Older" means earlier in the file. Sections from other branches are left untouched.
- Update the top-of-file status line if present: find a line matching
Status: <something> in the first 20 lines and replace it with Status: merged <MERGE_DATE>.
Implementation — use Python to process the file (sed is too fragile for multi-line section deletion). The key invariant: never touch [ ] or [x] checkboxes.
Write the following script to a temp file and run it:
import re, sys, osfrom datetime import datedef consolidate(path, branch):merge_date = date.today().isoformat()with open(path) as f:content = f.read()lines = content.split('\n')# 1. Update status line in first 20 linesfor i in range(min(20, len(lines))):if re.match(r'^Status:', lines[i]):lines[i] = f'Status: merged {merge_date}'breakcontent = '\n'.join(lines)# 2. Find all Handoff Context sections for this branchpattern = rf'^## Handoff Context \([^)]*branch: {re.escape(branch)}\)'matches = [(m.start(), m.group()) for m in re.finditer(pattern, content, re.MULTILINE)]if not matches:print(f'No Handoff Context sections found for branch {branch}')return content# 3. Rename the latest (last in file) to Final Progresslatest_start, latest_heading = matches[-1]new_heading = f'## Final Progress (merged {merge_date}, branch: {branch})'content = content[:latest_start] + new_heading + content[latest_start + len(latest_heading):]# 4. Delete older same-branch Handoff Context sections (all except latest)# After rename, re-find remaining old headings (indices may have shifted)for old_start, old_heading in reversed(matches[:-1]):# Find the section extent: from heading to just before the next ## headingsection_start = old_startafter = content[section_start + len(old_heading):]next_h2 = re.search(r'\n## ', after)if next_h2:section_end = section_start + len(old_heading) + next_h2.start()else:section_end = len(content)# Delete the old section (trim leading newlines from what follows)rest = content[section_end:].lstrip('\n')content = content[:section_start] + restreturn contentif __name__ == '__main__':path = sys.argv[1]branch = sys.argv[2]result = consolidate(path, branch)with open(path, 'w') as f:f.write(result)print('Consolidation complete')
Run as:
TMPSCRIPT=$(mktemp /tmp/consolidate_XXXXXX.py)cat > "$TMPSCRIPT" << 'PYEOF'import re, sys, osfrom datetime import datedef consolidate(path, branch):merge_date = date.today().isoformat()with open(path) as f:content = f.read()lines = content.split('\n')# 1. Update status line in first 20 linesfor i in range(min(20, len(lines))):if re.match(r'^Status:', lines[i]):lines[i] = f'Status: merged {merge_date}'breakcontent = '\n'.join(lines)# 2. Find all Handoff Context sections for this branchpattern = rf'^## Handoff Context \([^)]*branch: {re.escape(branch)}\)'matches = [(m.start(), m.group()) for m in re.finditer(pattern, content, re.MULTILINE)]if not matches:print(f'No Handoff Context sections found for branch {branch}')return content# 3. Rename the latest (last in file) to Final Progress# Safe: matches are sorted ascending by position; latest_start is always the highest# offset, so the rename does not affect byte offsets of earlier (old) headings.latest_start, latest_heading = matches[-1]new_heading = f'## Final Progress (merged {merge_date}, branch: {branch})'content = content[:latest_start] + new_heading + content[latest_start + len(latest_heading):]# 4. Delete older same-branch Handoff Context sections (all except latest)# Iterate in reverse so higher-offset deletions don't shift lower-offset positions.for old_start, old_heading in reversed(matches[:-1]):section_start = old_startafter = content[section_start + len(old_heading):]next_h2 = re.search(r'\n## ', after)if next_h2:section_end = section_start + len(old_heading) + next_h2.start()else:section_end = len(content)rest = content[section_end:].lstrip('\n')content = content[:section_start] + restreturn contentif __name__ == '__main__':path = sys.argv[1]branch = sys.argv[2]result = consolidate(path, branch)with open(path, 'w') as f:f.write(result)print('Consolidation complete')PYEOFpython3 "$TMPSCRIPT" "$PLAN_FILE" "$BRANCH"rm -f "$TMPSCRIPT"
Step 3c: Commit consolidation
Only run if consolidation actually changed the file.
git add "$PLAN_FILE"git diff --cached --quiet || {git commit -m "docs: consolidate plan progress before merging PR #${PR_NUMBER}"git push}
Step 3d: Move file (state-machine mode only)
State-machine mode = both $DOING and $DONE_DIR are set. Only move the file if it currently lives in $DOING. Filename is preserved — same date prefix, no re-dating.
if [ -n "$DOING" ] && [ -n "$DONE_DIR" ]; thenif [[ "$PLAN_FILE" == "$DOING"/* ]]; thenFILENAME=$(basename "$PLAN_FILE")mkdir -p "$DONE_DIR"git mv "$PLAN_FILE" "$DONE_DIR/$FILENAME"fifi
Step 3e: Commit the move (state-machine only)
if [ -n "$DOING" ] && [ -n "$DONE_DIR" ] && [[ "$PLAN_FILE" == "$DOING"/* ]]; thenFILENAME=$(basename "$PLAN_FILE")git commit -m "chore: move $FILENAME to done/"git pushfi
Step 4: CI gate — merge only when checks for the head SHA are green
Before merging, confirm CI has passed for the exact commit that will be merged. Every read is pinned to the PR's current head SHA so a just-pushed commit whose CI has not registered yet can never inherit an earlier commit's green — a gate that passes on a stale green is worse than no gate, because everything downstream then trusts a false signal.
Four outcomes (all handled by the loop below):
- All checks for the head SHA green → merge.
- Any check pending, or no checks reported yet → not green. Poll every
60s, up to 10 attempts (mirrors autopilot Step 6). Still not green → abort with CI still pending — not merging. Absence of checks is never success.
- Any check failed → abort, listing the failing check names.
- Repo has zero CI checks configured at all → merge, but print a
prominent merged with no CI signal flag line. This is concluded only after the full poll budget elapses with no check ever reported — a check that shows up pending keeps us in outcome 2, never here.
# Pin every read to the exact commit we are about to merge. Passing the SHA# in the API path (not `gh pr checks`, which reads the PR's current head and# can lag behind a just-pushed commit) is what makes the stale-SHA race# impossible: checks for an older commit can never be mistaken for this one's.HEAD_SHA=$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')if [ -z "$HEAD_SHA" ]; thenecho "Could not resolve head SHA for PR $PR_NUMBER — refusing to merge blind."exit 1fiecho "Gating merge on CI for head SHA: $HEAD_SHA"# Verdict for a SHA: green | pending | failed | none. Reads BOTH the Checks# API (GitHub Actions) and legacy commit statuses (external CI) so a# status-only repo is neither mistaken for "no CI" nor merged over a red# external check. `--paginate --slurp` pulls EVERY page of check-runs: a commit# with >100 checks (large monorepos do) would otherwise hide failures on later# pages and let the gate pass a red commit as green.ci_verdict_for_sha() {local sha="$1" cr st# A failed API call must NEVER be mistaken for "zero checks". A rate-limit /# network / auth blip that returned no data would read as `none` and, after# the poll budget, merge a repo that actually HAS CI. Treat any fetch failure# as `pending` so the loop retries and ultimately aborts, never merges. (No# `2>/dev/null`: let gh's error surface for the operator.)cr=$(gh api --paginate --slurp "repos/{owner}/{repo}/commits/$sha/check-runs?per_page=100") || { echo pending; return; }st=$(gh api "repos/{owner}/{repo}/commits/$sha/status") || { echo pending; return; }local cr_total cr_bad cr_incomplete st_total st_state# `--slurp` wraps the pages in an array: total_count is identical on every# page (read [0]); check-runs are flattened across all pages (.[].check_runs[]).cr_total=$(jq -r '.[0].total_count // 0' <<<"$cr")# "bad" = a completed run whose conclusion is not a pass (success/neutral/# skipped). Matches GitHub branch-protection semantics — cancelled,# timed_out, action_required and failure all count as not-passing.cr_bad=$(jq '[.[].check_runs[]? | select(.status=="completed") | select(.conclusion!="success" and .conclusion!="neutral" and .conclusion!="skipped")] | length' <<<"$cr")cr_incomplete=$(jq '[.[].check_runs[]? | select(.status!="completed")] | length' <<<"$cr")st_total=$(jq -r '.total_count // 0' <<<"$st")st_state=$(jq -r '.state // "pending"' <<<"$st")# Neither system reports anything for this SHA.if [ "$cr_total" -eq 0 ] && [ "$st_total" -eq 0 ]; then echo none; return; fi# Any hard failure wins. Legacy commit-status rolls up to success/pending/# failure/error — `error` (external CI infra failure) is a hard fail too, not# a pass, so catch it here or it falls through to green.if [ "$cr_bad" -gt 0 ] || [ "$st_state" = failure ] || [ "$st_state" = error ]; then echo failed; return; fi# Anything still running, or statuses rolled up pending — but only when# statuses actually exist: an empty status set reports state=pending, which# must NOT be read as an outstanding check (verified against the API).if [ "$cr_incomplete" -gt 0 ] || { [ "$st_total" -gt 0 ] && [ "$st_state" = pending ]; }; then echo pending; return; fiecho green}MAX_ATTEMPTS=10ATTEMPT=0EVER_SAW_CHECK=0NO_CI_SIGNAL=0while :; doVERDICT=$(ci_verdict_for_sha "$HEAD_SHA")if [ "$VERDICT" = green ]; thenecho "CI green for $HEAD_SHA — proceeding to merge."breakfiif [ "$VERDICT" = failed ]; thenecho "CI FAILED for $HEAD_SHA — refusing to merge. Failing checks:"gh api --paginate "repos/{owner}/{repo}/commits/$HEAD_SHA/check-runs?per_page=100" \--jq '.check_runs[] | select(.status=="completed") | select(.conclusion!="success" and .conclusion!="neutral" and .conclusion!="skipped") | " - \(.name): \(.conclusion)"'gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/status" \--jq '.statuses[]? | select(.state=="failure" or .state=="error") | " - \(.context): \(.state)"'exit 1fi# VERDICT is "pending" or "none" → not green yet.[ "$VERDICT" = pending ] && EVER_SAW_CHECK=1ATTEMPT=$((ATTEMPT + 1))if [ "$ATTEMPT" -ge "$MAX_ATTEMPTS" ]; thenif [ "$EVER_SAW_CHECK" -eq 1 ]; thenecho "CI still pending — not merging (waited ${MAX_ATTEMPTS}×60s for $HEAD_SHA)."exit 1fi# No check EVER reported across the whole window → treat as a repo with# zero CI configured. Proceed, but flag loudly — never silent.echo "FLAG: merged with no CI signal — no checks ever reported for $HEAD_SHA."NO_CI_SIGNAL=1breakfiif [ "$EVER_SAW_CHECK" -eq 1 ]; thenecho "CI pending for $HEAD_SHA (attempt $ATTEMPT/$MAX_ATTEMPTS) — waiting 60s..."elseecho "No checks reported yet for $HEAD_SHA (attempt $ATTEMPT/$MAX_ATTEMPTS) — treating as pending, waiting 60s..."fisleep 60done
Step 5: Merge the PR
|| true is intentionally absent: a merge rejected by branch protection (or any other gh error) must surface as an explicit failure carrying gh's own error output, not be swallowed as success.
# Re-confirm the head has not advanced since the Step 4 gate. A commit pushed# during the poll window would otherwise be merged while only the OLD SHA's CI# was verified — the exact stale-green that head-SHA pinning exists to prevent.LATEST_HEAD=$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')if [ "$LATEST_HEAD" != "$HEAD_SHA" ]; thenecho "PR head moved ($HEAD_SHA → $LATEST_HEAD) since the CI gate — refusing to merge a commit CI hasn't cleared. Re-run /merge-pr to re-gate the new head."exit 1fiif [ -n "${CODEX_THREAD_ID:-}" ]; thenMERGE_ARGS=(--squash --match-head-commit "$HEAD_SHA")elseMERGE_ARGS=(--squash --delete-branch)fiif ! gh pr merge "$PR_NUMBER" "${MERGE_ARGS[@]}"; thenecho "Merge command failed (see gh error above) — stopping."exit 1fi# Verify the merge succeededSTATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state')echo "PR state: $STATE"[ "$STATE" = "MERGED" ] || { echo "Merge failed — stopping."; exit 1; }CODEX_REMOTE_BRANCH_STATUS=""if [ -n "${CODEX_THREAD_ID:-}" ]; thenif git push origin --delete "$BRANCH"; thenCODEX_REMOTE_BRANCH_STATUS="deleted"elseCODEX_REMOTE_BRANCH_STATUS="retained (manual cleanup required)"fifi
On Claude Code, --delete-branch deletes the remote branch. The local branch deletion will fail because the worktree is still checked out — this is expected. Step 0 of the next Claude /merge-pr run cleans it up. On Codex, the remote deletion happens only after the merged state is verified, and the local worktree and branch are always retained.
Step 6: Report status
PR #<N> merged to main (commit <sha>)Current worktree retained (will be cleaned automatically when /merge-pr runsfrom another session):worktree: <path>branch: <branch>To clean up immediately, run /merge-pr from the main repo or another worktree session.
The automatic-cleanup wording above is Claude-only. The Codex report says the current worktree is intentionally retained and must be cleaned after its session ends. It also includes remote branch: <CODEX_REMOTE_BRANCH_STATUS> and plan consolidation: skipped (Codex merge-only profile).
If the CI gate (Step 4) merged with the no-CI-signal flag set (NO_CI_SIGNAL=1), prepend this prominent line to the report so it cannot be missed. Omit it entirely on the normal CI-green path:
[FLAG] merged with no CI signal — repo has no CI checks configured
Notes
Worktree behaviour
- Never delete the current session's own worktree — it would make the
session CWD unreachable and disable all further Bash calls.
- Other sessions' worktrees are safe to remove (Step 0 does this).
git worktree remove --forcewith--forceavoids stalling if the
directory has already been deleted manually.
|| trueprovides error tolerance for non-fatal cleanup operations.
General
- Only process the PR for the current conversation's branch; never merge
unrelated PRs.
- If the branch has only a local checkout with no remote PR, ask the user
how to proceed.
- Stop and report on merge failure; do not continue.