plugins/dev/skills/merge-pr/SKILL.md
Safely merge PR with verification and Linear integration. **ALWAYS use when** the user says 'merge the PR', 'merge this', 'ship it', or wants to merge an approved pull request. Runs tests, checks CI, verifies approvals, squash merges, cleans up branches, and moves Linear ticket to Done.
npx skillsauth add coalesce-labs/catalyst merge-prInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Safely merges a PR after comprehensive verification, with Linear integration and automated cleanup.
# Project setup + orch-monitor daemon liveness (REQUIRED — Phase 6 consumes webhook events)
if [[ -f "${CLAUDE_PLUGIN_ROOT}/scripts/check-project-setup.sh" ]]; then
"${CLAUDE_PLUGIN_ROOT}/scripts/check-project-setup.sh" || exit 1
fi
Read and follow the safety rules in
"${CLAUDE_PLUGIN_ROOT}/references/merge-blocker-diagnosis.md" — specifically the "Safety Rules"
section. Summary: NEVER use --admin, --force, or any flag that bypasses branch protection.
Always resolve blockers legitimately or escalate with specifics.
Read team configuration from .catalyst/config.json:
CONFIG_FILE=".catalyst/config.json"
[[ ! -f "$CONFIG_FILE" ]] && CONFIG_FILE=".claude/config.json"
TEAM_KEY=$(jq -r '.catalyst.linear.teamKey // "PROJ"' "$CONFIG_FILE")
TEST_CMD=$(jq -r '.catalyst.pr.testCommand // "make test"' "$CONFIG_FILE")
If argument provided:
/merge_pr 123If no argument:
# Try current branch
gh pr view --json number,url,title,state,mergeable 2>/dev/null
If no PR on current branch:
gh pr list --limit 10 --json number,title,headRefName,state
Ask: "Which PR would you like to merge? (enter number)"
gh pr view $pr_number --json \
number,url,title,state,mergeable,mergeStateStatus,\
baseRefName,headRefName,reviewDecision
Extract:
state=$(gh pr view $pr_number --json state -q .state)
mergeable=$(gh pr view $pr_number --json mergeable -q .mergeable)
If PR not OPEN:
❌ PR #$pr_number is $state
Only open PRs can be merged.
If not mergeable (CONFLICTING):
❌ PR has merge conflicts
Resolve conflicts first:
gh pr checkout $pr_number
git fetch origin $base_branch
git merge origin/$base_branch
# ... resolve conflicts ...
git push
Exit with error.
# Checkout PR branch
gh pr checkout $pr_number
# Fetch latest base
base_branch=$(gh pr view $pr_number --json baseRefName -q .baseRefName)
git fetch origin $base_branch
# Check if behind
if git log HEAD..origin/$base_branch --oneline | grep -q .; then
echo "Branch is behind $base_branch"
fi
If behind:
# Auto-rebase
git rebase origin/$base_branch
# Check for conflicts
if [ $? -ne 0 ]; then
echo "❌ Rebase conflicts"
git rebase --abort
exit 1
fi
# Push rebased branch
git push --force-with-lease
If conflicts during rebase:
❌ Rebase conflicts detected
Conflicting files:
$(git diff --name-only --diff-filter=U)
Resolve manually:
1. Fix conflicts in listed files
2. git add <resolved-files>
3. git rebase --continue
4. git push --force-with-lease
5. Run /catalyst-dev:merge-pr again
Exit with error.
Read test command from config:
test_cmd=$(jq -r '.catalyst.pr.testCommand // "make test"' .catalyst/config.json)
Execute tests:
echo "Running tests: $test_cmd"
if ! $test_cmd; then
echo "❌ Tests failed"
exit 1
fi
If tests fail:
❌ Local tests failed
Fix failing tests before merge:
$test_cmd
Or skip tests (not recommended):
/catalyst-dev:merge-pr $pr_number --skip-tests
Exit with error (unless --skip-tests flag provided).
Read and follow the full workflow in
"${CLAUDE_PLUGIN_ROOT}/references/merge-blocker-diagnosis.md". The wake-up
mechanism here is the canonical "Reactive PR lifecycle" pattern from
monitor-events (Pattern 3, CTL-228) — a single wait-for that fires on
any of: PR merged, PR closed, CI failure, review changes-requested, or push
to the base branch. Each wake-up is paired with an authoritative gh api
REST re-check; the event tells the agent what changed, gh api tells it
the current truth.
Why a multi-event filter and not just github.pr.merged: most of the time
between PR-create and PR-merge is spent on CI, review, and base-branch
churn — not waiting on a clean merge to land. Subscribing only to the merge
event means the agent learns nothing from a check failure or a
changes-requested review until the 600-second timeout fires, at which point
it falls back to REST polling via gh api. The disjunctive filter restores
event-driven dispatch for those cases.
# Two-phase compliant cadence loop — see [[wait-for-github]]. The 600s timeout
# serves as a fallback cadence; the authoritative REST check runs on every wake-up.
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
BASE_BRANCH=$(gh api "repos/${REPO}/pulls/${pr_number}" --jq '.base.ref')
ITER=0
MAX_ITER=20
while [ $ITER -lt $MAX_ITER ]; do
ITER=$((ITER + 1))
# Reactive multi-event subscription. wait-for is a no-op on event arrival;
# on timeout we fall through to the authoritative re-check below. The
# 600-second timeout is the fallback cadence when no events arrive (e.g.
# daemon down).
EVENT_JSON=$(catalyst-events wait-for \
--filter '
(.attributes."event.name" == "github.pr.merged" and .attributes."vcs.pr.number" == '"$pr_number"') or
(.attributes."event.name" == "github.pr.closed" and .attributes."vcs.pr.number" == '"$pr_number"') or
(.attributes."event.name" == "github.check_suite.completed"
and (.body.payload.prNumbers // [] | index('"$pr_number"') != null)
and (.attributes."cicd.pipeline.run.conclusion" == "failure" or .attributes."cicd.pipeline.run.conclusion" == "timed_out")) or
(.attributes."event.name" == "github.pr_review.submitted"
and .attributes."vcs.pr.number" == '"$pr_number"'
and (.body.payload.state == "changes_requested"
or (.body.payload.state == "commented" and (.body.payload.author.type // "") == "Bot"))) or
(.attributes."event.name" == "github.push" and .attributes."vcs.ref.name" == "refs/heads/'"$BASE_BRANCH"'")
' \
--timeout 600 || true)
# MANDATORY authoritative REST re-check on every wake-up.
STATE=$(gh api "repos/${REPO}/pulls/${pr_number}" \
--jq 'if .merged then "MERGED" elif .state == "closed" then "CLOSED" else "OPEN" end' \
2>/dev/null || echo "OPEN")
if [ "$STATE" = "MERGED" ]; then break; fi
if [ "$STATE" = "CLOSED" ]; then
echo "❌ PR #$pr_number was closed without merging"
exit 1
fi
EVENT=$(echo "$EVENT_JSON" | jq -r '.attributes."event.name" // ""')
case "$EVENT" in
github.check_suite.completed)
# CI failed — diagnose via merge-blocker-diagnosis.md, push fix.
;;
github.pr_review.submitted)
# Bot reviewers (Codex, claude-code-review) are addressable inline;
# humans require operator action. body.payload.author.type is "Bot" or "User".
# Codex submits inline-thread reviews as state="commented", not
# "changes_requested" — handle both via /catalyst-dev:review-comments,
# which addresses the code AND resolves threads via the GraphQL
# resolveReviewThread mutation.
AUTHOR_TYPE=$(echo "$EVENT_JSON" | jq -r '.body.payload.author.type // "User"')
if [ "$AUTHOR_TYPE" = "Bot" ]; then
/catalyst-dev:review-comments "$pr_number"
fi
;;
github.push)
gh pr update-branch "$pr_number" || true
;;
"")
# Timeout — gh api check above confirmed we're not merged.
# Diagnose blockers per the reference doc.
;;
esac
done
Why every wake-up runs gh api: if the orch-monitor daemon is down,
no GitHub webhook events flow into the log and wait-for blocks until
timeout (600 s). The gh api REST call after timeout is the safety net that keeps
merge confirmation correct even when the event stream has dropped. Same rule
applies on real event arrivals — events are wake-up triggers, never the
source of truth. Use gh api (REST), never gh pr view --json (GraphQL).
The reference doc contains:
CLEAN, BEHIND, DIRTY, BLOCKED, DRAFT, UNSTABLE, HAS_HOOKS,
UNKNOWN)BLOCKED into specific causes| Blocker | Auto-resolution |
|---------|----------------|
| branch-behind (BEHIND) | gh api -X PUT /repos/{owner}/{repo}/pulls/{n}/update-branch (most repos disable GitHub's auto-update via allow_update_branch: false, so manual update is required), then continue polling |
| conflicts (DIRTY) | Attempt gh pr checkout && git rebase origin/<base>; if unresolvable, exit non-success with specific files |
| draft | gh pr ready |
| ci-failing (UNSTABLE / failing checks) | Analyze failure logs, fix code, push, continue polling |
| unresolved-threads | Run /review-comments (see review-thread-resolution.md); push fixes; continue polling for new comments |
| changes-requested | Check if addressed; suggest re-request review |
| review-required | Cannot fix — exit non-success and report how many approvals needed and who to request |
| hooks-pending (HAS_HOOKS) | Wait one cadence cycle and re-query |
| unknown-blocker (UNKNOWN) | Query branch protection rules, report every requirement with status |
The outer loop has a MAX_ITER=20 cap to prevent runaway behaviour on a
stuck failure mode. Apply per-failure-type fix budgets inside each handler
(e.g., max 3 distinct fix attempts for the same CI failure mode). Exit
non-success only when a genuine human-gated blocker is identified
(unresolvable conflict, required reviewer, branch protection requirement that
cannot be satisfied).
When the loop confirms state == "MERGED", capture mergedAt and proceed to step 7.
Signal file write (when invoked from a worker context): if $SIGNAL_FILE is set (oneshot or
orchestrator worker), write pr.mergedAt, pr.ciStatus = "merged", and status = "done" to
the signal file as soon as state == MERGED is observed:
if [ -n "$SIGNAL_FILE" ] && [ -f "$SIGNAL_FILE" ]; then
PR_MERGED_AT=$(gh pr view "$PR_NUMBER" --json mergedAt --jq '.mergedAt')
jq --arg ts "$PR_MERGED_AT" \
'.pr.ciStatus = "merged" | .pr.mergedAt = $ts | .status = "done" | .updatedAt = $ts | .completedAt = $ts' \
"$SIGNAL_FILE" > "$SIGNAL_FILE.tmp" && mv "$SIGNAL_FILE.tmp" "$SIGNAL_FILE"
fi
branch=$(gh pr view $pr_number --json headRefName -q .headRefName)
title=$(gh pr view $pr_number --json title -q .title)
# From branch using configured team key
if [[ "$branch" =~ ($TEAM_KEY-[0-9]+) ]]; then
ticket="${BASH_REMATCH[1]}"
fi
# From title if not in branch
if [[ -z "$ticket" ]] && [[ "$title" =~ ($TEAM_KEY-[0-9]+) ]]; then
ticket="${BASH_REMATCH[1]}"
fi
About to merge:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PR: #$pr_number - $title
From: $head_branch
To: $base_branch
Commits: $commit_count
Files: $file_count changed
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Merge: $mergeStateStatus (CLEAN)
Reviews: $review_status
CI: $ci_status
Tests: ✅ Passed locally
Ticket: $ticket (will move to Done)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Merge strategy: Squash and merge
Proceed? [Y/n]:
gh pr merge $pr_number --squash --delete-branch
Always:
Capture merge commit SHA:
merge_sha=$(git rev-parse HEAD)
If ticket found and not using --no-update:
# Use the shared transition helper (CTL-69). It reads stateMap from
# `.catalyst/config.json`, is idempotent, and silently skips when the
# linearis CLI is not installed.
"${CLAUDE_PLUGIN_ROOT}/scripts/linear-transition.sh" \
--ticket "$ticket_id" --transition done --config .catalyst/config.json
# Then add a comment with PR number, merge commit, and base branch.
# Use `linearis comments usage` for exact syntax. Skip silently if CLI missing.
# Switch to base branch
git checkout $base_branch
# Pull latest (includes merge commit)
git pull origin $base_branch
# Delete local feature branch
git branch -d $head_branch
# Confirm deletion
echo "✅ Deleted local branch: $head_branch"
Always delete local branch - no prompt (remote already deleted).
If running in a git worktree, the primary checkout of main may be stale. Update it:
"${CLAUDE_PLUGIN_ROOT}/scripts/pull-primary-worktree.sh" --branch "$base_branch"
Read PR description:
desc_file="thoughts/shared/prs/${pr_number}_description.md"
if [ -f "$desc_file" ]; then
# Extract "Post-Merge Tasks" section
tasks=$(sed -n '/## Post-Merge Tasks/,/^##/p' "$desc_file" | grep -E '^\- \[')
fi
If tasks exist:
📋 Post-merge tasks from PR description:
- [ ] Update documentation
- [ ] Monitor error rates in production
- [ ] Notify stakeholders
Save these tasks? [Y/n]:
If yes:
# Save to thoughts
cat > "thoughts/shared/post_merge_tasks/${ticket}_tasks.md" <<EOF
# Post-Merge Tasks: $ticket
Merged: $(date)
PR: #$pr_number
$tasks
EOF
humanlayer thoughts sync
Two learning steps run for every merged ticket, in order:
/catalyst-dev:compound-estimate $ticket_id. It prompts for
the post-merge re-score (CTL-746 scale: XS=1 S=3 M=5 L=8 XL=13) plus two short reflections,
appends the weekly compound-log entry (thoughts/shared/retros/estimate/), and offers a
corpus refresh when the reference-class corpus is stale. That entry is what
refresh-corpus.sh feeds back into reference-class-corpus.json as human ground truth —
skipping it is how the loop falls open again./catalyst-dev:ticket-retro (no arguments). It regenerates
thoughts/shared/retros/ticket/<today>.md over the since-last-retro window (same-day re-runs
are cumulative) and refreshes the watch-items the morning briefing surfaces.Off the critical path: if the user declines, the ticket was never estimated, or either skill errors, log one line and continue — never block the merge ritual on them.
After branch cleanup, check if the merge triggered any deployment workflows:
# Check for workflow runs triggered by the merge commit on the base branch
DEPLOY_RUNS=$(gh run list --branch "$base_branch" --limit 5 --json name,status,workflowName,url \
--jq '.[] | select(.status == "in_progress" or .status == "queued")' 2>/dev/null)
if [[ -n "$DEPLOY_RUNS" ]]; then
echo ""
echo "Active workflow runs detected after merge:"
gh run list --branch "$base_branch" --limit 5 --json workflowName,status,url \
--jq '.[] | select(.status == "in_progress" or .status == "queued") | " - \(.workflowName): \(.status) (\(.url))"'
echo ""
echo "Tip: Monitor deployment with:"
echo " /loop 3m gh run list --branch $base_branch --limit 3 --json workflowName,status,conclusion --jq '.[]'"
echo ""
else
echo ""
echo "No active deployment workflows detected."
fi
Display the standard success summary after this check:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ PR #$pr_number merged successfully!
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Merge details:
Strategy: Squash and merge
Commit: $merge_sha
Base branch: $base_branch (updated)
Merged by: @$user
Cleanup:
Remote branch: $head_branch (deleted)
Local branch: $head_branch (deleted)
Linear:
Ticket: $ticket → Done ✅
Comment: Added with merge details
Post-merge tasks: $task_count saved to thoughts/
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
--skip-tests - Skip local test execution
/catalyst-dev:merge-pr 123 --skip-tests
--no-update - Don't update Linear ticket
/catalyst-dev:merge-pr 123 --no-update
--keep-branch - Don't delete local branch
/catalyst-dev:merge-pr 123 --keep-branch
Combined:
/catalyst-dev:merge-pr 123 --skip-tests --no-update
For all errors, provide clear messages with the specific error, what went wrong, and how to fix it. Never give up with a generic message — always diagnose the specific cause and provide actionable next steps.
Fail fast (stop execution):
/catalyst-dev:merge-pr--skip-testsDiagnose and attempt to fix (step 6 blocker loop):
/review-comments, resolve threadsEscalate with specifics (never generic):
Never suggest:
Warn but continue (graceful degradation):
Uses .catalyst/config.json:
{
"catalyst": {
"project": {
"ticketPrefix": "PROJ"
},
"linear": {
"teamKey": "PROJ",
"stateMap": {
"done": "Done"
}
},
"pr": {
"defaultMergeStrategy": "squash",
"deleteRemoteBranch": true,
"deleteLocalBranch": true,
"updateLinearOnMerge": true,
"requireApproval": false,
"requireCI": false,
"testCommand": "make test"
}
}
}
State names are read from stateMap with sensible defaults. See .catalyst/config.json for all
keys.
/catalyst-dev:merge-pr 123 # Merge PR for current branch
/catalyst-dev:merge-pr 123 --skip-tests # Skip local test execution
/catalyst-dev:merge-pr 123 --no-update # Don't update Linear ticket
/catalyst-dev:merge-pr 123 --keep-branch # Don't delete local branch
Never bypass branch protection:
--admin, --force, or any flag that circumvents protection rulesFail fast on:
Diagnose and fix automatically:
/review-comments, resolve via GraphQLgh pr readyEscalate with actionable specifics:
Always automated:
Graceful degradation:
linearis skill referencetesting
Phase-agent that fixes a failing verify verdict so the pipeline self-heals instead of stalling to needs-human (CTL-653). Reads `${ORCH_DIR}/workers/<ticket>/verify.json`, fixes the `findings[]` (every severity:"high" plus the regression_risk drivers) directly via Edit/Write, commits the remediation, and emits `phase.remediate.complete.<ticket>`. The scheduler's router then re-dispatches `verify` to re-check (the verify⇄remediate cycle, cap 3). Dispatched as a `claude --bg` job by `phase-agent-dispatch`, which invokes it via slash command — hence `user-invocable: true`.
tools
--- name: phase-triage description: Phase agent that triages a Linear ticket — expands acronyms, classifies (feature/bug/docs/refactor/chore), identifies genuine blockers (a semantic second-pass over the backlog — NOT a prose scrape; CTL-838), estimates scope, writes triage.json, and posts a triage analysis comment to Linear. Triage completion is signaled by that comment plus the local triage.json — there is no `triaged` label. Emits phase.triage.complete.<TICKET> on success and phase.triage.fai
tools
Phase agent for the research step of the 9-phase orchestrator pipeline (CTL-450). Wraps /catalyst-dev:research-codebase and produces thoughts/shared/research/<date>-<ticket>.md, then emits phase.research.complete.<ticket>. Reads triage.json from the worker dir as its prior-phase artifact. Spawned via plugins/dev/scripts/phase-agent-dispatch, which invokes it via slash command — hence `user-invocable: true`.
development
Phase-agent wrapper that opens the pull request after implementation completes (CTL-449 Initiative 1 Phase 3). Delegates to `/catalyst-dev:create-pr` (which already auto-runs `describe-pr` and transitions Linear to `inReview`), then writes the PR number + URL into the phase signal file so the downstream `phase-monitor-merge` agent can read it without re-querying GitHub. Dispatched as a `claude --bg` job by `phase-agent-dispatch`, which invokes it via slash command — hence `user-invocable: true`.