plugins/dev/skills/create-pr/SKILL.md
Create pull request with automatic Linear integration. **ALWAYS use when** the user says 'create a PR', 'open a pull request', 'ship this', 'ready for review', or wants to push changes and create a GitHub PR. Handles commit, rebase, push, PR creation, description generation, and Linear ticket update.
npx skillsauth add coalesce-labs/catalyst create-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.
Orchestrates the complete PR creation flow: commit → rebase → push → create → describe → link Linear ticket.
# Check project setup (thoughts, CLAUDE.md snippet, config)
if [[ -f "${CLAUDE_PLUGIN_ROOT}/scripts/check-project-setup.sh" ]]; then
"${CLAUDE_PLUGIN_ROOT}/scripts/check-project-setup.sh" || exit 1
fi
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")
git status --porcelain
If there are uncommitted changes:
/commit workflowbranch=$(git branch --show-current)
If on main or master:
# Check which exists
if git show-ref --verify --quiet refs/heads/main; then
base="main"
elif git show-ref --verify --quiet refs/heads/master; then
base="master"
else
base=$(git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@')
fi
# Fetch latest
git fetch origin $base
# Check if behind
if git log HEAD..origin/$base --oneline | grep -q .; then
echo "Branch is behind $base"
fi
If behind:
git rebase origin/$basegh pr view --json number,url,title,state 2>/dev/null
If PR exists:
/describe-pr and exitbranch=$(git branch --show-current)
# Extract pattern: PREFIX-NUMBER using configured team key
if [[ "$branch" =~ ($TEAM_KEY-[0-9]+) ]]; then
ticket="${BASH_REMATCH[1]}" # e.g., ENG-123
fi
PR titles follow <type>(<scope>): <ticket> ... so active work is identifiable from GitHub
alone. Prefer the first commit subject (it carries type/scope per commit conventions); inject
the ticket via draft_pr_title. Branch-derived title remains the no-commit fallback.
# CTL-783: PR titles follow `<type>(<scope>): <ticket> ...` convention.
# Prefer first commit subject; branch-derived title is the no-commit fallback.
source "${CLAUDE_PLUGIN_ROOT}/scripts/lib/draft-pr.sh"
commit_subj=$(git log --no-merges --format='%s' "origin/${base}..HEAD" 2>/dev/null | tail -1)
if [[ -n "$commit_subj" ]]; then
title="$(draft_pr_title "$ticket" "$commit_subj")"
else
# Branch-derived fallback (no commits or base unreachable)
if [[ "$ticket" ]]; then
desc=$(echo "$branch" | sed "s/^$ticket-//")
desc=$(echo "$desc" | tr '-' ' ')
title="$ticket: $desc"
else
desc=$(echo "$branch" | tr '-' ' ')
title="$desc"
fi
fi
# Push current HEAD and verify origin == HEAD. On a non-fast-forward (branch
# rebased/amended after a prior push), retry with --force-with-lease so the PR
# never points at a stale commit (CTL-1051).
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if ! git push -u origin HEAD; then
echo "create-pr: fast-forward push failed; retrying with --force-with-lease" >&2
git push --force-with-lease -u origin HEAD
fi
git fetch --quiet origin "$BRANCH" || true
if [[ "$(git rev-parse "origin/${BRANCH}")" != "$(git rev-parse HEAD)" ]]; then
echo "create-pr: post-push verify failed — origin/${BRANCH} != HEAD" >&2
exit 1
fi
CRITICAL: NO CLAUDE ATTRIBUTION
DO NOT add any of the following to the PR:
The PR should be authored solely by the user (git author). Keep the description clean and professional.
# Generate a meaningful initial body from commit messages (NO CLAUDE ATTRIBUTION)
commits=$(git log origin/$base..HEAD --oneline --no-merges)
body="## Changes
$commits"
# If ticket exists, add reference
if [[ "$ticket" ]]; then
body="$body
Refs: $ticket"
fi
# CTL-623: prevent Linear from auto-linking sibling tickets embedded in the
# branch name (multi-ticket orchestrator runs build branches like
# `o-adv-1155-1156-1157-ADV-1155`) and dragging their workflow status when this
# PR opens. The skip/ignore negative magic word fully unlinks siblings even when
# the branch still carries their IDs (https://linear.app/docs/github). No-op for
# single-ticket branches. Linking can fire on PR-open, BEFORE /describe-pr runs,
# so the guard block must be present in this transient initial body too.
# CTL-633: create-pr scans the BRANCH only — the transient body is assembled
# from commit messages, not user prose, so no body-mode scan is needed (and
# adding one risks fabricating from commit subjects). Call _from_branch
# explicitly to opt into the new mode-aware API.
# shellcheck source=/dev/null
source "${CLAUDE_PLUGIN_ROOT}/scripts/lib/linear-pr-skip.sh"
skip_block="$(linear_sibling_skip_block_from_branch "$ticket" "$branch")"
[[ -n "$skip_block" ]] && body="$body
$skip_block"
# Create PR (author will be the git user)
gh pr create --title "$title" --body "$body" --base "$base"
The initial body uses commit messages so the PR is immediately readable even before /describe-pr
generates the full description.
Capture PR number and URL from output.
After creating the PR, track it — substitute the actual PR URL and ticket:
"${CLAUDE_PLUGIN_ROOT}/scripts/workflow-context.sh" add prs "https://github.com/org/repo/pull/NUMBER" "TICKET-ID"
Immediately call /describe-pr with the PR number to:
If ticket was extracted from branch:
# If Linearis CLI is available:
# 1. Update ticket status to stateMap.inReview from config
# 2. Add a comment with the PR link
# Use `linearis issues usage` and `linearis comments usage` for exact syntax.
# Skip silently if CLI not available.
Skip the status transition (step 1) when CATALYST_PHASE is set — under a
phase agent the deterministic coordinator (CTL-558) owns the Linear status
write-back (the execution-core scheduler / orchestrate-phase-advance writes
the inReview-equivalent state on the pr phase). This status transition is
only for interactive /catalyst-dev:create-pr use; the PR-link comment (step 2)
is still posted in both modes.
CRITICAL: Creating the PR is NOT the end of this skill. You MUST monitor CI checks, wait for automated reviewer comments, address them, and only report success when the PR is in a clean, mergeable state — or genuinely blocked on a human gate (like approval from a specific person).
Do NOT just say "PR created" or "PR created with auto-merge" and stop. That leaves the user to do all the follow-up work manually.
Step 12a: Wait for CI checks and automated reviewers (event-driven)
Automated review agents (Codex, security scanners, linters) typically post
comments within 3–5 minutes of PR creation. CI checks also need time to run.
Use the canonical "Reactive PR lifecycle" pattern from [[monitor-events]] §
Pattern 3 (CTL-228) — a single multi-event subscription that wakes on PR
merged, PR closed, CI completed, review submitted, or push to the base
branch — instead of sleep 30 polling.
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
BASE_BRANCH=$(gh api "repos/${REPO}/pulls/${pr_number}" --jq '.base.ref' 2>/dev/null || echo "main")
if command -v catalyst-events >/dev/null 2>&1; then
# Reactive event-driven path. Wakes on the first actionable signal
# (CI complete, comment, review, merge, base advance, or 5-min timeout).
# Two-phase compliant cadence loop — see [[wait-for-github]].
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)) or
(.attributes."event.name" == "github.pr_review.submitted"
and .attributes."vcs.pr.number" == '"$pr_number"') or
(.attributes."event.name" == "github.issue_comment.created"
and .attributes."vcs.pr.number" == '"$pr_number"') or
(.attributes."event.name" == "github.pr_review_comment.created"
and .attributes."vcs.pr.number" == '"$pr_number"') or
(.attributes."event.name" == "github.push" and .attributes."vcs.ref.name" == "refs/heads/'"$BASE_BRANCH"'")
' \
--timeout 300 || true)
# MANDATORY authoritative REST re-check on every wake-up.
PR_DATA=$(gh api "repos/${REPO}/pulls/${pr_number}" \
--jq '{merged: .merged, state: .state, head_sha: .head.sha}' 2>/dev/null || echo '{}')
PR_STATE=$(echo "$PR_DATA" | jq -r 'if .merged then "MERGED" elif .state == "closed" then "CLOSED" else "OPEN" end')
HEAD_SHA=$(echo "$PR_DATA" | jq -r '.head_sha // ""')
CI_STATUS="unknown"
if [ -n "$HEAD_SHA" ]; then
CI_STATUS=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs" \
--jq '[.check_runs[] | .conclusion // .status] | unique | join(",")' 2>/dev/null || echo "pending")
fi
echo "wake: state=${PR_STATE} CI=${CI_STATUS} event=$(echo "$EVENT_JSON" | jq -r '.attributes."event.name" // "(timeout)"')"
else
# Fallback when catalyst-events CLI is not installed — REST-only poll.
# See [[wait-for-github]] for the full two-phase pattern.
COUNT=0
MAX=24 # 2-hour limit at 5-min intervals
MERGED_FLAG="false"
while [ "$MERGED_FLAG" != "true" ] && [ $COUNT -lt $MAX ]; do
sleep 300
COUNT=$((COUNT + 1))
PR_DATA=$(gh api "repos/${REPO}/pulls/${pr_number}" 2>/dev/null || echo '{"merged":false}')
MERGED_FLAG=$(echo "$PR_DATA" | jq -r '.merged')
COMMENT_COUNT=$(gh api "repos/${REPO}/pulls/${pr_number}/comments" --jq 'length' 2>/dev/null || echo "0")
REVIEW_COUNT=$(gh api "repos/${REPO}/pulls/${pr_number}/reviews" \
--jq '[.[] | select(.state != "APPROVED" and .state != "DISMISSED")] | length' 2>/dev/null || echo "0")
echo "REST poll @$((COUNT * 5))min: merged=${MERGED_FLAG} comments=${COMMENT_COUNT} reviews=${REVIEW_COUNT}"
[ "$MERGED_FLAG" = "true" ] && break
{ [ "$COMMENT_COUNT" -gt 0 ] || [ "$REVIEW_COUNT" -gt 0 ]; } && break
done
fi
The reactive path replaces the sleep 180 + sleep 30 poll cadence with
event-driven wake-ups. The --timeout 300 floor prevents indefinite blocks
when the orch-monitor daemon is down. The fallback path uses REST-only polling
(gh api at 5-min intervals) — no gh pr checks --json or gh pr view --json
in any loop. See [[wait-for-github]] for the full two-phase diagnostic pattern.
The fallback path is preserved verbatim for installs without the catalyst-events CLI.
Step 12b: Address all review comments
If any comments or reviews exist, run /review-comments $pr_number to:
Step 12c: Diagnose and resolve merge blockers
Read and follow "${CLAUDE_PLUGIN_ROOT}/references/merge-blocker-diagnosis.md". Run the full
blocker diagnosis and resolution loop (max 3 rounds):
ci-failing → analyze failure logs, fix code, push, re-pollunresolved-threads → run /review-comments (addresses + resolves threads)branch-behind → rebase and pushdraft → gh pr readychanges-requested → check if addressed; attempt to fixCRITICAL MISDIAGNOSIS WARNING: Do NOT confuse "unresolved review threads" with "needs approving
reviewer." Code comments from automated reviewers (Codex, security scanners) create threads that
YOU can resolve by addressing the feedback and resolving the thread via GraphQL. These are NOT a
human approval gate. Only review-required (no approving reviews at all) is a genuine human gate.
Read the merge-blocker-diagnosis reference carefully.
Step 12d: Re-poll until clean or genuinely human-blocked
After each fix cycle, re-query the merge state. Continue looping until:
mergeStateStatus is CLEAN → PR is ready to merge, report successreview-required (needs human approval) → report what's neededReport based on the actual merge state after monitoring — not just "PR created."
If CLEAN (ready to merge):
✅ PR #{number} ready to merge
PR: #{number} - {title}
URL: {url}
Base: {base_branch}
Ticket: {ticket} (moved to "In Review")
Status:
✅ CI checks passed
✅ Review comments addressed ({N} resolved)
✅ No merge blockers
Merge with: /catalyst-dev:merge-pr
If blockers remain (report exactly what's needed):
PR #{number} created — {N} blocker(s) remain
PR: #{number} - {title}
URL: {url}
Resolved:
✅ {what was fixed}
Still blocking:
❌ {specific blocker and exactly what's needed to resolve it}
On main/master branch:
❌ Cannot create PR from main branch.
Create a feature branch first:
git checkout -b TICKET-123-feature-name
Rebase conflicts:
❌ Rebase conflicts detected
Conflicting files:
- src/file1.ts
- src/file2.ts
Resolve conflicts and run:
git add <resolved-files>
git rebase --continue
/catalyst-dev:create-pr
GitHub CLI not configured:
❌ GitHub CLI not configured
Run: gh auth login
Then: gh repo set-default
Linearis CLI not found:
⚠️ Linearis CLI not found
PR created successfully, but Linear ticket not updated.
Install Linearis:
npm install -g linearis
Configure:
export LINEAR_API_TOKEN=your_token
Linear ticket not found:
⚠️ Could not find Linear ticket for {ticket}
PR created successfully, but ticket not updated.
Update manually or check ticket ID.
Uses .catalyst/config.json:
{
"catalyst": {
"project": {
"ticketPrefix": "PROJ"
},
"linear": {
"teamKey": "PROJ",
"stateMap": {
"inReview": "In Review"
}
}
}
}
State names are read from stateMap with sensible defaults. See .catalyst/config.json for all
keys.
Branch: ENG-123-implement-pr-lifecycle
Extracting ticket: ENG-123
Generated title: "ENG-123: Implement pr lifecycle"
Creating PR...
✅ PR #2 created
Calling /catalyst-dev:describe-pr to generate description...
Updating Linear ticket ENG-123 → In Review
✅ Complete!
Branch: feature-add-validation (no ticket)
No ticket found in branch name
Generated title: "Feature add validation"
Creating PR...
✅ PR #3 created
Calling /describe-pr...
⚠️ No Linear ticket to update
✅ Complete!
/commit - if uncommitted changes (optional)/describe-pr - always, to generate comprehensive description/merge-pr - PR is now ready for review and eventual mergelinearis 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`.