<< All versions

Skill v1.0.1

Automated scan100/100
sd0xdev/sd0x-dev-flow/create-pr

+5 new

──Details
PublishedAugust 23, 2026 at 03:34 AM
Content Hashsha256:57c5c2187428662a...
Git SHA5ebaa8e15b16
Bump Typepatch
Compare with v1.0.0
──Files
Files (1 file, 42.6 KB)
SKILL.md42.6 KBactive
SKILL.md · 511 lines · 42.6 KB

version: "1.0.1" name: create-pr description: "Create or update GitHub PR with gh CLI. Auto-extracts ticket ID from branch name, generates title/summary from commits. Auto-detects existing PR and switches to update mode. Supports --stack for stacked PR chains (per-layer PRs with chained bases; never executes push/rebase). Default: --dry-run (show command, don't execute). Use when: user asks to open/create/update a PR, says /create-pr, wants a stacked PR chain, wants to refresh PR description after new commits, or says 'update pr', 'update PR title', 'refresh PR body'." allowed-tools: Bash(git:), Bash(gh:), Bash(mktemp:), Bash(rm:), Bash(bash:*), Read, Write, Grep, Glob, AskUserQuestion


Create PR

Input

/create-pr [--head <branch>] [--base <branch>] [--title <title>] [--stack <branch...>] [--update] [--execute] [--dry-run]

  • --head: Source branch (default: current branch)
  • --base: Target branch (default: {TARGET_BRANCH} or main)
  • --title: Override auto-generated title (rejected with --stack)
  • --stack: Stacked PR chain mode, bottom layer first (mutually exclusive with --head; the bottom layer's base follows the same --base / {TARGET_BRANCH} / main resolution as normal mode) — see Stacked PR Mode
  • --update: Force update mode (re-generate title/body for existing PR)
  • --dry-run: Show command without executing (default). Scopes to mutating gh calls — the Step 1 git fetch --prune origin still runs, so the preview reflects the server's real refs
  • --execute: Actually create/update the PR (requires user confirmation)
  • No args: use current branch → default target, dry-run mode. Auto-detects existing PR → update mode

Workflow

0. Mode Dispatch (first, before anything else)

When `--stack` is present: read references/stack-mode.md and run Phases A–D from there. Skip generic Steps 1, 5, 6 and 7 entirely — Phase A must run git fetch --prune origin and classify sync state before any PR planning, so the generic ls-remote / local base..head path must not run first. What is reused, per layer, exactly as Phase C directs:

ReusedSkipped
Steps 2–4 content generation (ticket ID, title, body)Step 1 gather — Phase A replaces it
Step 4b sanitization, Step 7b post-creation verifyStep 5 pre-flight + mode detection — Phase A/B replace it
Step 5a's smart-diff update logic — the routing decision is Phase B's, the diff-and-update mechanics are Step 5a'sSteps 6–7 single-PR output/execute — Phase C emits per-layer commands instead

Otherwise continue with Step 1 below.

1. Gather Info

The fetch runs first and alone — the ref-range reads below it (git log and git diff over refs/remotes/origin/*) depend on the remote-tracking refs it refreshes, so it cannot join the parallel batch. It runs even in --dry-run (the dry-run promise at the top scopes to mutating gh calls; refreshing and pruning local remote-tracking refs is how the preview describes the commits that would actually ship). ls-remote in the next fence only LISTS the server's refs — it never updates refs/remotes/* — so without this fetch they can be missing or stale (another clone pushed, or this one never fetched) and the PR body would describe old commits. Same exact form and same discipline as stack mode's Phase A: the explicit exit keeps a failed fetch from being followed by reads of stale refs.

Emit this line in the turn that runs the fetch, before the fence — rules/git-workflow.md lists status | diff | log | branch | rev-parse as allowed and git fetch is in neither that list nor the forbidden one, which makes it a Default-tier deviation, and a deviation is declared per run, not once during development (same shape as stack-mode.md § Phase A; the stated reason differs because the fetch serves PR-body range generation here, sync classification there):

[DEVIATION] rule=rules/git-workflow.md § allowed ops default=fetch is not in the allowed list chosen=git fetch --prune origin
reason=the PR body is generated from origin/<base>..origin/<head>; without the fetch every range is computed from stale remote-tracking refs signal=fetch is absent from the forbidden closed set (add|commit|push|stash|reset --hard|rebase) and writes only remote-tracking refs — no working tree, no history
bash
git fetch --prune origin || exit "$?"

The rest are independent — run them in parallel:

bash
# Current branch
git rev-parse --abbrev-ref HEAD
# Remote repo (owner/repo)
gh repo view --json nameWithOwner --jq '.nameWithOwner'
# Check if head branch is pushed
git ls-remote --heads origin -- 'feat/PROJ-42-add-widget'
# Check existing PR
gh pr list --head 'feat/PROJ-42-add-widget' --base 'main' --json number,title,state
# Commits between base..head
git log --oneline 'refs/remotes/origin/main..refs/remotes/origin/feat/PROJ-42-add-widget'
# Full diff for summary
git diff 'refs/remotes/origin/main...refs/remotes/origin/feat/PROJ-42-add-widget' --stat

2. Extract Ticket ID

From branch name, extract ticket ID using {TICKET_PATTERN} (default: [A-Z]+-\d+):

Branch PatternTicket ID
fix/PROJ-520PROJ-520
fix/PROJ-520-2PROJ-520
feat/PROJ-123-some-descPROJ-123
refactor/PROJ-999PROJ-999

Regex: first match of {TICKET_PATTERN} — take first match. Strip trailing -N suffixes.

3. Generate Title

Format: <type>: [<TICKET>] <concise summary>

  • <type>: from branch prefix (fix/ → fix, feat/ → feat, docs/ → docs, refactor/ → refactor)
  • <TICKET>: extracted ticket ID (omit if none found)
  • <concise summary>: summarize commits in <60 chars, focus on main changes

4. Generate Body

markdown
## Summary
<3-5 bullet points summarizing changes from commits>
## Ticket
[<TICKET>]({ISSUE_TRACKER_URL}<TICKET>)
## Test plan
-[ ] <test items based on what changed>

Rules:

  • No AI-generated tags — enforced by Step 4b sanitization (see below)
  • Keep summary factual, based on actual commits
  • Use imperative mood in bullet points
  • Omit Ticket section if no ticket ID or {ISSUE_TRACKER_URL} not configured

Forbidden patterns (case-insensitive ERE with \b word boundaries — canonical source: scripts/commit-msg-guard.sh):

Pattern CategoryRegex
Co-Authored-By AI`Co-Authored-By:.*(Claude\Anthropic\\bAI\b\GPT\OpenAI\Copilot\Codex\Gemini\noreply@anthropic)`
Generated-by tag`Generated[ -](by\with).*(Claude\Anthropic\\bAI\b\GPT\OpenAI\Copilot\Codex\Gemini)`
Emoji robot tag`🤖.*(Claude\Anthropic\\bAI\b\GPT\OpenAI\Copilot\Codex\Gemini)`
Note: \| in the table above is Markdown table escaping. Actual ERE uses unescaped |. Only AI is \b-bounded — it prevents bare AI from matching inside ordinary words ("maintainer", "domain") under -i. GPT and OpenAI are intentionally left unbounded so they still match inside ChatGPT / GPT-4 (no English word contains "gpt"). Generated[ -] covers the hyphenated Generated-by: form, which the earlier space-only version accepted.

4b. AI Content Sanitization

After generating title and body (Step 3-4), scan for forbidden patterns and sanitize before any output or execution. Applies to all modes: dry-run/execute, create/update, --title override.

Sanitization is executed, not paraphrased — skills/create-pr/scripts/sanitize-pr-content.sh is the implementation, and it reads the three forbidden patterns out of scripts/commit-msg-guard.sh at runtime so the two can never drift. The path is resolved from the script's own location, after following any symlink chain, and from nothing else — `PLUGIN_ROOT` is deliberately not consulted, even though run-skill.sh exports it to exactly that value, because an environment variable that selects the policy source lets a caller swap in a guard declaring three never-matching patterns and get exit 0 on a real trailer. What self-location cannot cover is an invocation whose path is not the file's real location — a copy or a hardlink into a planted tree; the script says so in its own comment rather than implying otherwise, and run-skill.sh building an absolute target from its own location — after physically resolving its own symlink chain, without which a symlink to the wrapper planted in an attacker's tree selected that tree's copy of the policy script — is what closes that for the documented entrypoint. The documented invocation spells the interpreter absolutely (/bin/bash -p) for the same reason one layer earlier: a bare bash is resolved in the caller's shell, so an exported bash function answered the whole command with exit 0 and neither script ever started. A pattern set that reads as empty, as fewer entries than the guard declares, or as a line this parser cannot fully read aborts with exit 2 rather than reporting content clean.

The run directory is allocated before this step, in every mode — including dry-run. Sanitization operates on files, so a mode that writes no file cannot sanitize; a dry-run that skipped it would render an unsanitized body into its own report, which is precisely the text a user copies into gh. What dry-run must not do is leave the files behind or run a mutating gh call, and § Command Rendering's teardown fence is what guarantees the first. "No gh at all" would be the wrong contract and the wrong claim: stack mode's Phase B reads gh pr list and Phase D reads gh extension list — both read-only — in order to decide what to print. The line is gh pr create / gh pr edit: those are never run in dry-run. The lifecycle below is one sequence with one owner, and every exit from it — including a sanitizer failure — passes through the same teardown:

#StepOn failure
1Allocate the run directory (mktemp -d, § Command Rendering step 1)Nothing was created; stop
2Write pr-title.txt and pr-body-N.md out of band (Write tool) — stack mode writes one of each per layer (pr-title-N.txt), see references/stack-mode.mdRun the teardown fence, then stop
3title mode on the title fileExit 3 → regenerate once; still 3 → teardown, then hard fail
4body-inplace mode on each body fileExit 2 → teardown, then hard fail
5Operate (gh) — execute mode only; dry-run renders the report instead. The --title value is read back from the file step 3 scanned, not from the generator's copyThe guarded block already cleans and re-raises
6Teardown (§ Command Rendering) — in dry-run this is a step of its own, run after the report is rendered—

Why step 5 reads the title back. --body-file names the very artifact body-inplace rewrote, so nothing in this workflow re-renders the body between the verdict and the send. gh has no --title-file, so the title is the one field this workflow could make diverge by itself: scan pr-title.txt, then render --title from a string the generator still holds, and the verdict belongs to bytes that were never published. Regenerating the title after the scan (step 3's "regenerate once" path) is exactly when that happens. The file is the artifact of record: re-scan it after any regeneration, and render the flag from what it holds.

What this does *not* establish — stated because the earlier wording overclaimed it. "Same pathname" is not "same bytes". Scan and publish are separate processes reading a mutable path, so between them any process running as the same user — a second agent, a stray editor, anything sharing the account — can replace either file, and the 0700 run directory does not help because it is that same user's own directory. The sanitizer has a narrower instance of the same gap internally: it scans the path, then reopens it to emit. Closing this properly means one hardened operation owning both the verdict and the send (sanitize and pipe those exact bytes into gh --body-file -), which gh's per-flag interface and this skill's agent-driven step sequence do not currently allow. So the honest contract is: this workflow never itself publishes unscanned bytes; it does not defend against a concurrent same-user writer. Step 7b's post-publication scan is what covers that residual, and it is detection — it runs after gh has already sent the content, so it bounds exposure rather than preventing it.

Steps 3 and 4, with <PR_BODY_DIR> replaced by the literal path from step 1:

bash
/bin/bash -p scripts/run-skill.sh create-pr sanitize-pr-content.sh title '<PR_BODY_DIR>/pr-title.txt'
bash
/bin/bash -p scripts/run-skill.sh create-pr sanitize-pr-content.sh body-inplace '<PR_BODY_DIR>/pr-body-1.md'

Title sanitization (regenerate/fail) — title mode exits 3 on a match, never rewrites, and reports [AI_DETECTED] line <n> matched pattern <k>:

  1. Scan title for forbidden patterns
  2. Exit 3 → regenerate title from commits (1 attempt, without AI attribution) and re-run
  3. If the regenerated title still exits 3 → HARD FAIL: tear down, abort with an error message. No gh command runs
  4. --title override: same scan-and-fail logic (no regeneration — user-provided text fails immediately if matched)

Body sanitization (line-strip + log) — body-inplace replaces the file with its sanitized content and logs each removal to stderr:

  1. Scan body line-by-line for forbidden patterns
  2. Remove matching lines
  3. Log each removal as [AI_STRIPPED] line <n> matched pattern <k> — the matching line itself is never echoed. A PR body is attacker-influenced text and a matched line can carry a token (Generated by GPT-4; token=…); @rules/security.md forbids putting one into a log. The line number locates it in the file
  4. If all content lines removed → preserve template structure (Summary / Test plan headers only)

Use body-inplace, not body with a redirect: … body <file> > <file> truncates the file before the sanitizer reads it, so gh would receive an empty body. body mode (stdout) exists for previewing and for tests; body-inplace is what the workflow runs, and it replaces the file through a sibling temp file and an atomic rename. In --stack mode this happens per layer, on that layer's own pr-body-N.md, immediately before that layer's block.

5. Pre-flight Checks + Mode Detection

CheckAction if fails
Head branch not pushedWarn: "branch not pushed to remote, push first" and STOP
PR already exists→ Enter Update Mode (see section below)
--update flag + no existing PRWarn: "no PR found for this branch" and STOP
No commits between base..head (create mode)Warn: "no diff between branches" and STOP
No commits between base..head (update mode)Continue — PR may need title/body refresh from --title override

Mode detection logic:

ConditionMode
--update flag passedForce update mode (error if no PR exists)
Existing PR detected (auto)Update mode (auto-switch)
No existing PR, no --updateCreate mode (original workflow)

5a. Update Mode

When an existing PR is detected (or --update is passed):

Step 1: Fetch current PR state (use PR number from pre-flight gh pr list result):

bash
gh pr view <PR-number> --json number,title,body,url,baseRefName

Step 2: Re-generate title and body from latest commits (same logic as Steps 2-4 above, using full commit range base..head). Run Step 4b AI Content Sanitization on the re-generated content before proceeding.

Step 3: Smart diff — compare current vs newly generated:

FieldCurrentNewAction
TitlesamesameSkip (no change needed)
TitlediffersdiffersShow before/after
BodysamesameSkip
BodydiffersdiffersShow before/after

Step 4: Decision — if both title and body are unchanged → report "PR is already up to date" and STOP. Step 4b has already allocated the run directory and written the title and body files by this point, so STOP means running the teardown fence first (§ Command Rendering, <PRIOR_STATUS> = 0). An early exit is still an exit from the lifecycle, and it is the one most easily mistaken for "nothing happened".

If changes detected, show the diff and decide what to update:

  • Title changed significantly: update title automatically. Criteria: type prefix changed (fix: → feat:) or ticket ID changed.
  • Title changed trivially: AskUserQuestion — "Title changed slightly. Update?" (show before/after). Criteria: only the summary text after <type>: [<TICKET>] differs.
  • Body changed: always update (body reflects commit history, should stay current)
  • When --title is passed: override title regardless of diff

Step 5: Output (respects --dry-run / --execute):

Dry-run (default) — show the gh pr edit command with only changed fields included:

Every update is the canonical block below, instantiated — never a bare gh pr edit. A bare one is unguarded, so a caller's errexit exits at the failing gh before any cleanup runs, and Step 4b has already allocated the run directory and written the title and body files into it by this point. Only the two parameters the Cleanup row names vary, and no copy of the shape is kept here — a second copy is a second source, and this section is where it would drift from:

UpdateFlags on the block's gh pr editCleanup operand
Title only--title with the rendered title, no --body-file<PR_BODY_DIR> — nothing was written for this operation, but the Step 4b directory is still this path's to remove
Body--body-file naming the file in the run directory<PR_BODY_DIR>
Body + titleboth<PR_BODY_DIR>

Command Rendering (mandatory) ⚠️

Branch names, titles and bodies are all attacker-influenceable — a branch name is accepted by git check-ref-format --branch with ;, &, quotes and $( ) in it, and the body is generated from commit messages. Two rules, and both are load-bearing:

1. Single-quote rendering, never double quotes. Every dynamic value interpolated into a rendered command is wrapped in single quotes with embedded quotes escaped:

render(v) = ' + v.replace(every ' with '\'') + '

Double quotes are not a substitute: "$(id)" still runs id, so git rev-parse "refs/heads/$BRANCH" executes a command substitution embedded in a branch name. Templates in this skill and in references/stack-mode.md show values already rendered. Add -- before positional arguments wherever the CLI accepts it, so a value cannot be parsed as a flag.

2. Body text never appears inside shell syntax — no heredoc, ever. This is a prohibition, not a preference, and it has no "unless" clause. A heredoc terminates at the first line equal to its delimiter, so a body containing that line closes the heredoc early and every following line is parsed as shell input — arbitrary command execution, not a formatting bug. Quoting the delimiter (<<'X') only disables expansion inside the body; it does not prevent the collision. Nor does a random-looking fixed delimiter: fixed is fixed, and a body can contain it. --body-file /dev/stdin does not help either — termination happens in the shell before gh runs.

PathRule
RequiredWrite the body to a file out of band — the Write tool when the skill runs (it is in allowed-tools for exactly this), the user's editor when they copy-paste — then pass --body-file '<path>'. The command names the file and never contains the body
WhereA directory allocated by `mktemp -d`, one per run: mktemp -d is atomic, returns a unique name, and creates it 0700. Never invent the name — an invented path is not created, so Write fails on the missing parent, and on a shared /tmp a predictable name can be pre-created or symlinked by another user. Never under `.git/` either: pre-edit-guard.sh rejects every .git/ path, and in a linked worktree .git is a file, not a directory
How the path is carriedRun mktemp -d once, read the path it prints, and substitute that literal absolute path into every later command. Each Bash invocation is a fresh shell, so DIR=$(mktemp -d) followed by $DIR in a later step silently resolves to nothing — and on macOS TMPDIR is an ambient variable pointing at the shared temp root, so a stray rm -rf "$TMPDIR" would target that root. Same rule and the same reasoning as skills/necessity-audit/SKILL.md § Phase 0. <PR_BODY_DIR> below marks where the returned literal goes
CleanupThe shell shape is defined once, by the canonical block below, and every body-file command in this skill — create, update, stacked, Step 7b — uses that shape unchanged. What varies between them is the operation and the cleanup operand, and only those: a single-PR command cleans the whole run directory, while a stacked layer cleans its own pr-body-N.md and leaves the directory for the layers above it (references/stack-mode.md § Phase C). The structure around them — subshell, seeded status, guarded operation, guarded cleanup, arithmetic re-raise — is copied verbatim. Do not restate it in prose; a restatement is a second source that can drift. What the shape buys, so it is not "simplified" away: the operation is guarded so a caller's errexit cannot exit at the failing gh before cleanup runs (an unguarded command followed by a capture line is skipped outright); the status is carried in a subshell's positional parameters rather than a named variable, because a named one belongs to the caller — readonly STATUS=9 would make the seeding assignment itself fail after allocation, leaking the directory, and an ordinary caller would silently lose its own value; the expansion is quoted, because a bare one is field-split with the caller's IFS and an IFS containing the status digit drops the status (bash and sh report a different code, zsh under SH_WORD_SPLIT reports success); and the subshell keeps the re-raise from closing an interactive shell. Cleanup runs on success and on failure — a PR body can carry private repository context even without credentials — but it must never mask the failure: an unconditional trailing rm succeeds, so the block would report 0 after a failed gh. Capture, clean, re-raise — and the cleanup is guarded in turn, because a failing rm would otherwise replace the operation's status with its own. -- guards the operand, and the operand is the exact literal mktemp -d printed: an empty one deletes nothing, while a wrong non-empty literal would delete the wrong directory — which is why the exact-output provenance rule above is load-bearing. Bash(mktemp:*) and Bash(rm:*) are in allowed-tools for exactly these two steps, and Bash(bash:*) for the Step 4b/7b sanitizer invocation
NeverAny << heredoc, echo/printf of body text, or body interpolated into a command string. With no delimiter in play, no body line can collide with one
Commands this skill executesPass arguments as an array — never interpolate body or title into a shell string

This applies to every mode: create, update, stacked, and the Step 7b remediation below.

Canonical cleanup block. Every body-file command below is this block with its own gh invocation substituted — the one authority for the shape. It is two fences, and the split is load-bearing: a single fence containing both the allocator and the placeholder has no correct way to be run. Execute it whole and mktemp -d's output is discarded while gh is handed the un-substituted literal <PR_BODY_DIR>/…, which does not exist; run the allocator separately and then execute the same fence "verbatim" and it allocates a second directory that nothing ever removes. A shell comment cannot pause execution while a body is written out of band — only a fence boundary can.

bash
# Step 1 — allocate the run directory. Run this fence alone and read the literal
# path it prints. Nothing else belongs in it.
mktemp -d

Step 2 is out of band and is not shell: write each body file into that directory with the Write tool (or your editor). If that write fails or is interrupted, the directory is already allocated and may already hold a private body, so the teardown fence is the immediate next action rather than step 3.

The same fence is the single teardown for every exit that does not reach step 3 — a failed or interrupted write, a Step 4b sanitizer failure (exit 2 or a title that fails twice), a user declining the confirmation, and the end of a dry-run preview. Step 3's own guarded block covers the paths that do reach it. Between them, no route out of the lifecycle leaves the directory behind, which is what makes "the body never outlives the run" a property rather than an intention:

bash
# Any exit before step 3 — remove the allocated directory before anything else.
(
set -- '<PRIOR_STATUS>'
case "$1" in ''|*[!0-9]*) set -- 2 ;; esac
rm -rf -- '<PR_BODY_DIR>' || set -- "$1" "$?"
exit "$(( $1 ? $1 : ${2:-0} ))"
)

<PRIOR_STATUS> is the status the run already carries, and it is single-quote rendered like every other dynamic value — there is no bare-placeholder exemption in this skill. It once had one, on the reasoning that an arithmetic operand must not be quoted; that was wrong in both directions. $(( … )) converts a quoted digit string without complaint, and an unquoted set -- <PRIOR_STATUS> executes a hostile value at substitution time, before any arithmetic runs — the exemption created the hole it was reasoned into. The case line is the second layer: $(( $1 ? … )) re-evaluates $1's contents as an expression, so a value like a[$(…)] would still run a command substitution there; anything that is not a run of digits becomes 2 (environment failure) before the arithmetic sees it. Cleanup runs either way.

The value itself: for an out-of-band write failure and for the end of a dry-run it renders as 0 — neither left a shell status behind, so the teardown's own status is the one worth reporting. After a Step 4b failure it renders as the sanitizer's exit (2 for an environment failure, 3 for a title that failed twice), so the reason the run stopped survives the cleanup instead of being replaced by it. In --execute mode it renders as the failing layer's status instead, and cleanup can then only add a failure, never replace one — see references/stack-mode.md § Phase C.

bash
# Step 3 — the guarded operation. Run it only once every <PR_BODY_DIR> is the
# literal path from step 1 and every named body file exists. No allocator here.
(
set -- 0
gh pr edit 42 --body-file '<PR_BODY_DIR>/pr-body-1.md' || set -- "$?"
rm -rf -- '<PR_BODY_DIR>' || set -- "$1" "$?"
exit "$(( $1 ? $1 : ${2:-0} ))"
)

Why cleanup is guarded too. An unguarded rm masks the operation twice over. It masks a failure when it succeeds — the historical bug — and it masks the operation's status when it fails: under an inherited set -e, gh exiting 37 followed by rm exiting 5 reports 5 in bash, sh, zsh and dash alike. The arithmetic re-raise gives the operation precedence and lets a cleanup failure surface only when the operation succeeded, so a leaked body directory is never silent either.

Execute (--execute) — ask user for confirmation via AskUserQuestion, then run gh pr edit. Output:

PR updated: <URL>
Title: <old-title> → <new-title>
Changes: title updated, body updated

6. Output (dry-run, default) — Create Mode

Show the full gh pr create command. Dry-run runs no mutating `gh` call (`gh pr create` / `gh pr edit`) and leaves nothing on disk — read-only queries such as gh pr list and gh extension list still run, because the report's content depends on their answers — but it does allocate, write and sanitize, because Step 4b operates on files and a preview whose body was never sanitized is exactly the text a user copies into a real gh invocation. The distinction that matters is durability, not activity: the run directory dry-run creates is torn down by the skill before the report is delivered, using the teardown fence below, so no private PR body survives a preview. The gh fence is printed, never run.

The steps below are what dry-run renders for the user to copy. The skill's own dry-run pass runs steps 1–2 plus Step 4b against a directory it allocates and then removes; it stops short of step 3.

bash
# Step 1 — allocate. This fence holds nothing else; read the path it prints.
mktemp -d

Step 2 is out of band and is not shell: write the body into that directory as pr-body-1.md with the Write tool (or any editor). Only then, with <PR_BODY_DIR> replaced by the literal step-1 path:

bash
# Step 3 — the guarded operation. Nothing of the body appears in it.
(
set -- 0
gh pr create \
--head 'feat/PROJ-42-add-widget' \
--base 'main' \
--title 'feat: [PROJ-42] Add widget endpoint' \
--body-file '<PR_BODY_DIR>/pr-body-1.md' || set -- "$?"
rm -rf -- '<PR_BODY_DIR>' || set -- "$1" "$?"
exit "$(( $1 ? $1 : ${2:-0} ))"
)

The three steps are run in order, not pasted as one block, and step 3 needs one substitution: mktemp -d prints a path that does not exist until the user runs it, so <PR_BODY_DIR> in the write and in the command is replaced with what step 1 printed. The skill's own preview directory is not reusable for this — it is torn down before the report is delivered, precisely so a private body does not outlive the preview. Say this in the report rather than implying a single paste; the alternative is a user pasting a command with a literal <PR_BODY_DIR> in it. Re-running with --execute avoids the substitution entirely: the skill then performs the allocation, the write and the operation itself, and owns the cleanup.

7. Execute (--execute flag)

Ask user for confirmation, then run the command. Output:

PR created: <URL>
Title: <title>
Base: <base> ← Head: <head>

7b. Post-creation Verify (execute-only)

After gh pr create or gh pr edit completes in --execute mode, verify the published content for AI attribution leaks.

Verification runs twice — once on what was just published, and once more after a remediation attempt. Both runs are the same four-step cycle, defined here once and invoked by reference; writing it out a second time is how the second verification previously degraded into the prose "re-verify via gh pr view", which names no file for the sanitizer to read and therefore produces no verdict at all.

The verification cycle

It needs a directory of its own, and for two different reasons depending on the mode. In single-PR mode the Step 4b run directory is already gone by the time verification starts — step 3's guarded block removes it in the same command that ran gh. In stack mode it is still open: each layer's block removes only its own pr-body-N.md, and the directory itself comes down in a teardown fence after the last layer (references/stack-mode.md § Phase C), while Step 7b runs per layer between those. Either way it must not be reused — a private body must not outlive its operation so a later step can borrow the path, and in stack mode the directory still holds the bodies of the layers not yet published. <PR_BODY_DIR> below is this cycle's own fresh literal, as in the 3a remediation.

StepWhat it doesOn failure
V1Allocate — run the mktemp -d fence alone, read the literal path it printsNothing was created; stop
V2Capture the published content into itRun V4 immediately with <PRIOR_STATUS> = gh's exit, then stop
V3Scan the captured file (scan mode, exit 4 on a leak)Its status is the verdict V4 carries
V4Tear down, whatever V3 reported—

V2's redirect is what makes the scan possible: the sanitizer reads files, so a gh pr view printing only to the terminal leaves V3 scanning a path that does not exist (exit 2 — a fail-closed error, not a verdict). Writing to a different file than any input keeps the body-inplace truncation hazard from arising here. And V2's failure path is not optional: the redirect opens published.txt before gh runs, so a failed gh pr view can still leave a partial snapshot of a private repository's title and body in a directory that has no verdict to reach — only a directory to remove.

bash
mktemp -d
bash
gh pr view 42 --json title,body --template '{{.title}}{{"\n"}}{{.body}}' > '<PR_BODY_DIR>/published.txt'
bash
/bin/bash -p scripts/run-skill.sh create-pr sanitize-pr-content.sh scan '<PR_BODY_DIR>/published.txt'

V4's <PRIOR_STATUS> renders as the status the cycle already carries (0 clean, 4 leak, 2 fail-closed, or gh's own status when V2 failed), so cleanup cannot mask the verdict and the captured snapshot never outlives the check:

bash
(
set -- '<PRIOR_STATUS>'
case "$1" in ''|*[!0-9]*) set -- 2 ;; esac
rm -rf -- '<PR_BODY_DIR>' || set -- "$1" "$?"
exit "$(( $1 ? $1 : ${2:-0} ))"
)

Step 1–2 — run the verification cycle (V1–V4) against the PR just created or edited. Its result is the verdict: 0 clean and this section is done, 4 a leak, anything else a fail-closed error that stops the run.

Step 3: If leak detected — auto-remediate (single attempt, using pre-sanitized snapshot from Step 4b):

The Step 4b snapshot is held by the skill, not by a shell variable — a variable set in an earlier command does not survive into this one. Re-materialize it here with the same three steps as § Command Rendering — allocate, write out of band, operate — sub-numbered so they are not read as this section's own Steps 1–4:

3a — allocate. Run this fence alone and read the literal path it prints:

bash
mktemp -d

3b — write the snapshot, out of band. This step is not shell: write the Step 4b sanitized body into the directory from 3a as pr-body-1.md, using the Write tool. Skipping it is the one way this remediation silently does the wrong thing — gh is handed a --body-file that does not exist, and the remediation fails after the leak has already been published. If the write fails, run the teardown fence from § Command Rendering and stop.

3c — the guarded operation, with <PR_BODY_DIR> replaced by the literal path from 3a:

bash
(
set -- 0
gh pr edit 42 --title 'feat: [PROJ-42] Add widget endpoint' --body-file '<PR_BODY_DIR>/pr-body-1.md' || set -- "$?"
rm -rf -- '<PR_BODY_DIR>' || set -- "$1" "$?"
exit "$(( $1 ? $1 : ${2:-0} ))"
)

Step 4 — run the verification cycle again (V1–V4, a fresh directory each time), now against the remediated PR. This is the same sequence as Step 1–2, not a lighter check: the remediation published new content, so the only thing that establishes it is clean is capturing and scanning what GitHub now holds. A 0 here closes the section. If it still reports 4 → HARD FAIL:

❌ AI attribution leaked in PR #<number> after remediation attempt.
Manual fix: re-run the canonical guarded block (§ Command Rendering) with
`gh pr edit <number>` as its operation and a freshly sanitized body file.

This message names the canonical block rather than spelling a command out. A second rendering of the same operation is a second thing to keep correct, and this one sat in an untyped fence — outside the reach of the sweep that checks every bash fence — while carrying an unquoted <clean-body-file>.

Guardrails:

  1. Single remediation attempt only — no retry loop
  2. Use pre-sanitized snapshot (do not re-generate from commits)
  3. Fail-fast on GitHub API errors (no retry for transient errors)

Multi-PR Mode

When user specifies multiple branch pairs (e.g. "A → main, B → A"), create them sequentially and output all URLs at the end.

Stacked PR Mode

--stack <branch...> (bottom layer first) turns a linear branch chain into one PR per layer, each based on the layer below. Full detail — phase tables, chain model, shell-safety contract, native gh stack comparison — lives in references/stack-mode.md; what follows is the contract this skill will not deviate from.

Never executes `git push`, `git rebase`, or any `gh stack` subcommand. Under --execute only gh pr create / gh pr edit run, which is this skill's existing authorization (Step 5a, Steps 6-7). Branch pushes go to /push-ci or are emitted for the user to run; gh stack commands are printed, never invoked.

PhaseDoes
A. Sync classificationgit fetch --prune origin, then classify each layer by local/remote OID: IN_SYNC / LOCAL_AHEAD / ABSENT / REMOTE_AHEAD / DIVERGED / NO_SUCH_BRANCH. Every failure in the fence exits explicitly (`\\exit), so a failed fetch cannot be followed by probes reading stale refs — set -e would not do it, because a caller that tests the fence's status disables errexit inside it — in every POSIX shell; each ref probe accepts exit 1 and exits on anything else, because absence is an expected answer, and the probes' output is delimited by local: / remote: / end: markers — without them a missing ref prints nothing and ABSENT reads identically to remote-only, whose disposition is the opposite. Runs **first** — later phases read remote refs, so a missing origin/<head>` must surface here
B. Chain validationReal ancestry — every layer on its own base, bottom layer included (git merge-base --is-ancestor origin/<base> origin/<head>) plus each adjacent pair; non-empty layer; existing-PR policy via gh pr list --head <head> --state all --limit 100 (default is open-only, and the default page is 30 — both flags are mandatory); layer-count rules
C. Per-layer create/editBottom to top; existing PR → update (Step 5a), absent → create. Steps 2-4 + 4b per layer; Step 7b per layer under --execute. Execute mode is one guarded block per layer, not one && chain — an upper body carries the lower PR's number, which does not exist until the lower layer has run
D. Environment detectionTwo independent conditions, and both must hold before any native sequence is printed: the github/gh-stack extension is installed and native stacks are confirmed rolled out for this repo. Installed but rollout unconfirmed is the conservative case — say so and take the non-native path, exactly as if the extension were absent
Sync statedry-run--execute
IN_SYNCcontinuecontinue
LOCAL_AHEADcontinue with stale-content warningrefuse
ABSENTstop before PR planning, emit push remediationrefuse
REMOTE_AHEAD / DIVERGEDabort layer — user resolves via fetch/rebase (push is not the remedy)refuse
NO_SUCH_BRANCHabort — branch exists nowhere; pushing cannot fix a typorefuse
ContractWhat it means
ClassificationAncestry in both directions (merge-base --is-ancestor each way) — OID equality alone cannot tell LOCAL_AHEAD from REMOTE_AHEAD from DIVERGED. Refs fully qualified (refs/heads/…, refs/remotes/origin/…); any fetch or resolution error fails closed
`--update`Asserts every layer already has an OPEN PR: if any is absent, Phase B aborts before Phase C — --update never creates. Plain re-entry (no --update) keeps the auto-detected update/create routing
Dependency marker#<N> whenever the lower PR number is known (including in dry-run), a ` branch marker only when that PR is absent, upgraded to #<N> on --stack --update`. Never emit an unresolved placeholder
Fail-fast, not atomicLayers are independent mutations, so partial success is a real outcome. On failure, stop before the next layer and report every layer as succeeded / failed / pending; re-running detects created layers in Phase B and routes them to update mode
Shell safetySingle-quote rendering for every dynamic value (double quotes do not suppress $( )), -- as option terminator, and the body never enters shell syntax at all — no heredoc in any form; it is written to a file out of band and passed via --body-file
RejectionsNon-linear chain, layer with no unique commits, PR that is CLOSED / MERGED / base-mismatched / multiply matched, single-layer chain (use plain /create-pr), explicit-but-empty chain, and auto-detection with no authoritative source (existing PR base relations or native stack metadata). A dirty working tree only warns — all content derives from fetched remote refs

Edge Cases

CaseBehavior
No ticket ID in branch nameOmit [TICKET] from title, omit Ticket section from body
Branch suffix like -2, -3Strip suffix when extracting ticket ID
User provides --titleUse as-is (skip auto-generation), but still run Step 4b scan — fail immediately if forbidden pattern matched
Stacked PRs (B → A → main)Prefer --stack (per-layer PRs, chained bases). Without it: note dependency in body — Stacked on #<PR-number> when that PR exists, otherwise the lower branch name
--update but no existing PRError: "No PR found for branch <head> → <base>"
Auto-detect existing PRSwitch to update mode, show "Existing PR #N detected, switching to update mode"
PR body has manual editsRe-generate from commits; user reviews before/after diff
Title unchanged after new commitsSkip title update, only update body

Verification

Create and update modes

  • [ ] Step 4b: Title and body pass the forbidden-pattern scan before they are output or sent
  • [ ] Step 7b: Post-creation / post-edit verify finds no AI attribution (execute-only)
  • [ ] Dry-run commands are valid and runnable in order, with <PR_BODY_DIR> the only substitution the user makes
  • [ ] (create) Branch exists and is pushed to remote; no existing PR for the same head/base
  • [ ] (create) Title follows project convention; body includes summary and test plan
  • [ ] (update) Existing PR fetched successfully (gh pr view); new title/body generated from latest commits
  • [ ] (update) Before/after diff shown to user; only changed fields included in gh pr edit

Stacked mode (--stack)

  • [ ] No git push, git rebase, or gh stack subcommand executed
  • [ ] Phase A ran first: git fetch --prune origin + per-layer sync classification; ABSENT stopped before PR planning
  • [ ] Phase B validated real ancestry and queried PRs with --state all
  • [ ] Dependency markers resolved (#N when known, branch marker only when absent)
  • [ ] Failure reported per layer (succeeded / failed / pending); re-run created no duplicates
  • [ ] Displayed commands single-quote rendered; no heredoc anywhere, body passed via --body-file
← v1.0.0All versionsv1.0.2 →