ship
Turn a finished work item into a pull request that GitHub actually links back to its issue. Reads the Work Item Brief for the branch, worktree, contract and issue id, runs /verify-before-done, compose
SKILL.mdskills/ship/SKILL.md
GitHub/ship — Pull Request and Closing Link
The final stage of the delivery chain. /task opened the work item and /design-first,
/tdd-first and the reviewers delivered it; this skill turns those commits into a pull
request and proves the tracker learned about it.
Chain position:
/task → /design-first → /tdd-first → reviewers → /verify-before-done → /git-commit → /ship
^^^^^
Context — why this skill exists
Opening a pull request is the easy half. The half that silently fails is the link back to the work item, and it fails in a way that looks exactly like success.
A Closes #N line in a pull-request body is a REQUEST for a link, never proof of one.
GitHub declines the request without saying so when the reference is cross-repository, or
when several numbers are comma-joined onto one keyword. A pull request whose base is not the
default branch sits in a third, unsettled case: whether the reference resolves there at all
has not been shown either way, but merging into that base does not close the issue even when
the reference did resolve -- resolving a reference and closing an issue are two different
things, and this file used to conflate them. The body still reads Closes #12, the pull
request still merges, and the issue may still be open afterward regardless of which reading
turns out to be true. .claude/work-item-conventions.json states the cross-repository and
comma-joined cases as resolvesToClosingLink: false, and verify_issue_link.py exists
because of a measurement: five of the last twelve merged pull requests in the originating
repository carried no resolved link at all.
So this skill's real job is not "open a pull request." It is: open one, then ask GitHub
whether the link resolved, and treat the answer as the deliverable. The only admissible
proof is the forge's own resolved reference, which gh pr view --json closingIssuesReferences reports — and which populates on OPEN pull requests, so the repair
can happen before merge rather than being discovered after it.
Hard rule that governs the whole skill: never report a link you have not seen resolved.
Writing issue_link: closes because the body says Closes #N reintroduces the exact defect
this skill exists to prevent.
Step 0: Find the Work Item Brief
The brief is the input. Everything else is read from it, never re-derived from the chat.
- If the user named one, use it.
- Otherwise match the current branch against the
branch:field of every brief in.claude/work-items/. That is the reliable key — the branch is what carries the commits. - If nothing matches, list the briefs whose
status:is notshippedand ask.
No brief → halt. Do not improvise one, and do not open a pull request without one. A
brief written after the fact records what you did, not what was asked, and the acceptance
criteria are the only thing that makes the PR body checkable. Send the user to /task.
Read and hold: id, url, repo, title, type, branch, worktree, contract,
partial, issue_link, status, base, and the recorded outcome of the issue offer,
issue_offer (with issue_offer_reason when it is unavailable). Their names and allowed
values are the offerOutcomeField, offerOutcomes, offerReasonField and offerReasons keys
in work-item-conventions.json → issueLink.
The declared base. The brief's base: field, when present, is the declared base this
whole skill measures against: what "commits ahead" is counted from, what the pull request is
opened against, and what the closing link is verified against. No brief, or a brief with no
base: field, means the declared base falls back to the repository's default branch (confirm
it with git symbolic-ref refs/remotes/origin/HEAD rather than assuming main or master).
An ordinary work item with no parent therefore reads exactly as it always has, because its
declared base is the default branch. Every <base> placeholder below this point means the
declared base, never a hardcoded default branch name.
An id: reading of UNKNOWN or unknown halts here. Those are the
unresolvedIdTokens in work-item-conventions.json → issueLink, and they mean nobody
filled the field in — which is not the same as "this work has no issue" and must never be
treated as one. Ask the user for the issue number, or for permission to set id: none.
Step 1: Pre-flight
Four checks, in order. Each has produced a wasted pull request at least once.
1a. Stand in the tree that holds the commits
If the brief's worktree: field names a path, the commits are there, not here. Either
enter it (EnterWorktree with path:) or run every git command in this skill with
git -C <worktree>. Pushing from the parent tree pushes a branch with none of the work on
it, and the pull request opens empty.
Confirm before continuing:
git -C <tree> rev-parse --abbrev-ref HEAD # must equal the brief's branch:
git -C <tree> status --short # uncommitted work is not shipped
Also halt in the main checkout: if py -3 .claude/scripts/flat_worktree.py --describe reports current_tree: main-checkout, stop and say "run /task, or EnterWorktree into a worktree, then /ship". A detached HEAD is otherwise a halt, with one exception: the detached flat worktree /advance makes when the parent branch is held by the main checkout, where the branch to ship is the brief's branch:.
1b. The branch must actually differ from the base
git -C <tree> fetch origin
git -C <tree> log --oneline origin/<base>..HEAD
If the declared base names a branch absent from origin, this fetch or log step fails
outright. Halt and say so plainly — that is a readable reason, and reporting it as an empty
commit list would be a worse, misleading halt.
An empty list means the implementation happened somewhere else — the classic outcome of a contract implemented in the parent tree while the branch lived in a worktree. Halt and say so; do not open an empty pull request.
1c. Never ship from the default branch
If HEAD is master or main, halt. /ship opens pull requests; it does not push to the
default branch, force-push, or merge. Those three are the user's, always. The one merge this
tooling performs is a completed contract's parent pull request, and land_contract.py owns it,
never this skill: /ship opens drafts and runs before any merge it could confirm.
1d. Run the verification gauntlet
Invoke /verify-before-done. A failure halts the ship. This is the last gate before the
work becomes visible to other people, and "the tests were already red before I started" is
not a reason to open the pull request — it is a reason to say so and stop.
Step 2: Compose the pull-request title
Issue titles carry ONE effort tag; pull-request titles carry EVERY touched module. The
two conventions are different on purpose and both live in
.claude/work-item-conventions.json. Read them from there. Do not restate the tag lists in
this file — that file is the single source of truth, and duplicating it is how /task and
/ship drift apart.
[<MODULE>]...[<MODULE>] <Title>
The <Title> is the brief's title: field, which /task already stripped of its
[EFFORT] prefix. Do not re-add the effort tag. A pull request is described by what it
touches, not by why it was raised.
Deriving the modules
From the diff, never from memory. Run the script; do not apply the rules by hand.
land_contract.py --module-tags reads the diff and the prTitle block and returns the tags,
the unmapped paths and the ready-made prefix:
LANDER=
S=.claude/scripts/land_contract.py
if [ -f "$S" ]; then LANDER=$S
elif [ -n "$CLAUDE_PLUGIN_ROOT" ] && [ -f "$CLAUDE_PLUGIN_ROOT/$S" ]; then LANDER=$CLAUDE_PLUGIN_ROOT/$S
else
# What Claude Code recorded for this plugin (this project's entry, then the user entry), then the newest cache folder by version number.
CFG=${CLAUDE_CONFIG_DIR:-$HOME/.claude}
ME=$(printf %s "$CLAUDE_PROJECT_DIR" | tr 'A-Z\134' 'a-z/' | tr -s /)
REC=$(tr -d '\r\n' 2>/dev/null < "$CFG/plugins/installed_plugins.json" | grep -o '"agentic-auto-improve@[^"]*" *: *\[[^]]*]' | grep -o '{[^{}]*}' | tr '\134' / | tr -s /)
MINE=$(printf '%s\n' "$REC" | grep '"scope" *: *"project"' | while IFS= read -r O; do
P=$(printf %s "$O" | sed -n 's/.*"projectPath" *: *"\([^"]*\)".*/\1/p' | tr 'A-Z' 'a-z' | tr -s /)
[ -n "$ME" ] && [ "${P%/}" = "${ME%/}" ] && printf '%s\n' "$O"
done)
NEWEST=$(ls -d "$CFG"/plugins/cache/*/agentic-auto-improve/*/ 2>/dev/null | sed -n 's|^\(.*/\([0-9][0-9.]*\)/\)$|\2 \1|p' | sort -rV | cut -d' ' -f2-)
LANDER=$({ printf '%s\n' "$MINE"; printf '%s\n' "$REC" | grep '"scope" *: *"user"'; } | sed -n 's/.*"installPath" *: *"\([^"]*\)".*/\1/p' | { cat; printf '%s\n' "$NEWEST"; } | while IFS= read -r D; do
[ -n "$D" ] && [ -f "${D%/}/$S" ] && { printf '%s\n' "${D%/}/$S"; break; }
done)
fi
py -3 "$LANDER" --module-tags --base origin/<base> --json
Read tags (already ordered), unmapped and prefix. Exit 0 means the tags were read. Exit 9
means the --base ref cannot be read: fetch origin and pass a ref that exists, then run it again. An
unmapped path is listed in unmapped and tagged with nothing; the script never asks, so the
ask-then-add rule under "When a path matches nothing" below still applies. If LANDER comes
back empty (an older install), derive the tags by hand from the changed paths:
git -C <tree> diff --name-only origin/<base>...HEAD
Then, for each path, walk prTitle.map and collect every tag whose globs match. Emit them
in prTitle.order, never in discovery order.
Three traps, all of which have fired before:
ignore.globsfirst. Those paths contribute no tag and must not trigger the unmapped prompt. They are not shippable source.**crosses a directory separator; a single*does not.src/*means the direct children ofsrcand never a nested file. Python'sfnmatchdisagrees — there*crosses/— so a matcher built on it over-matches and tags modules the diff never touched. This is a verified failure mode, recorded in the conventions file itself. Usepathlib.PurePath.matchor an explicit segment-wise matcher.atLeastOne: true. A title with no module tag is invalid.
A map entry may also carry coveredBy, a list of tags that cover it. Drop the entry's tag
when the diff also carries a tag named in its coveredBy; keep it when the diff does not. It
is data on the map entry in the conventions file, so a repository states its own covering
pairs and the template ships none.
When a path matches nothing
prTitle.map ships as an empty template — its order and map arrays name a project's
own source layout, and nothing else in the generic asset set does. So in a repository that
has not populated it, every path is unmapped.
Do not invent a tag. Ask the user via AskUserQuestion, quoting the unmapped paths and
proposing tags derived from the top-level directories, then write the accepted answer back
into prTitle.map and prTitle.order in the same turn. That is the extension seam: the
map is meant to grow the first time each module is shipped, so the second pull request
touching that module needs no prompt.
Step 3: Compose the pull-request body
Built from the brief, so the reviewer reads what was asked rather than what you remember:
## What this delivers
<the brief's problem statement, one paragraph>
## Acceptance criteria
- [x] <each criterion from the brief, ticked only if genuinely met>
## Out of scope
<verbatim from the brief, so a reviewer does not ask for it>
## Design
Contract: <the brief's contract: path, or "none — <size> item, routed direct">
## Verification
<the actual /verify-before-done output: what ran, what passed, what failed and why
that failure is acceptable. Never "all tests pass" without the numbers.>
## North-star alignment
<the brief's grade, verbatim>
## Notes
<anything a reviewer should not have to discover on their own — see the declared-base
rule below for one thing that belongs here. When the brief has no issue, add one line:
"No issue: declined by the user", or "No issue: could not be opened (<reason>)" using the
brief's `issue_offer_reason:`, which at this point is `create-failed` or
`no-problem-statement`, the two reasons that stay valid at ship time>
Closes #<id>
Rules for the closing line:
- One keyword per line (
oneKeywordPerLine: true). Comma-joining numbers onto one keyword is one of the ways GitHub silently declines the link. - Same repository:
Closes #<n>. Cross-repository:Closes <owner>/<repo>#<n>. partial: truein the brief meansRefs #<n>, notCloses. The item is deliberately not completed by this pull request, and a closing keyword would shut a live work item.id: nonemeans no line at all. That is a success path, not a defect — see Step 3.5.
If the declared base is not the default branch, write into the Notes section that the
closing reference will not fire the issue closed on this merge, and that /pr-merged
performs the close afterward — see Step 4d's deferred-close row for the mechanism. Draft
this now: Step 0 already knows the declared base before this step starts, so there is no
need to wait for Step 4d's verdict and edit the body back in afterward.
An unticked acceptance criterion is not a blocker to shipping, but it must be visible in the body with a one-line reason. Silently ticking everything is how an item is reported delivered and reopened a week later.
Step 3.5: Offer to open an issue when there is none
Gate — all four must hold: the brief's id reading is absent (the absentIdTokens list
in work-item-conventions.json → issueLink, whose values are not restated here) and
gh is available and authenticated and the brief's parent_issue: reading is none or
the key is absent and the brief's issue_offer: outcome is not already a final answer.
The final answers are declined, and unavailable with the reason no-problem-statement
(there is still nothing honest to put in an issue body). Every other reading fires the gate:
pending, a missing issue_offer: line, unavailable with a gh-missing or
gh-unauthenticated reason (stale, because gh works now), and unavailable with
create-failed. Gate on the id, never on the source: string — that field is free
prose in practice. A resolved parent_issue: means this brief belongs to a Sub-Task Work
Item whose own sub-issue was already created at intake time — this offer never fires for one,
because offering to create a second issue for work that already has one is how duplicates get
made. This skill has no parent-aware mode of its own; this gate is the one place it needs to
tell the two cases apart, and it does so on the normalized reading, never on free prose.
A brief whose id is unresolved never reaches this offer; Step 0 already halted on it.
Offering to create an issue for work that may already have one is how duplicates get made.
This point, /task Step 2.5 point 6 and /design-first Step 3.6 share their wording — one
mechanism, three call sites. /task asks at intake and /design-first asks when a run
starts there; this is the last ask, at the moment the work becomes visible. One
AskUserQuestion, asked once, quoting the exact title and body. Write the body (the problem
statement, then the acceptance criteria) to a file first and pass the file, never the text
inline — Git Bash can mangle free text that starts an argument:
gh issue create --title "[<TYPE>] <bare title>" --body-file <path to the body file>
Record the answer in the brief's issue_offer: line. Never write declined unless the user
answered this question in this run. An assumed answer writes pending and never declined.
- On accept, write
id:,url:andrepo:back into the brief before Step 4 composes the body, so the closing line can reference it, and setissue_offer: opened. If the create command fails, writeissue_offer: unavailableandissue_offer_reason: create-failed. - On decline, write
issue_offer: declined.id: nonestays and the body carries no closing line. With the recorded-offer requirement on, Step 4d's verdict isoffer-declined; with it off, the verdict isnot-applicable. Both resolve toissue_link: noneand are a success, not a warning. - If
issue_offer: openedis recorded but the id is absent, the issue has probably already been created and only the id was lost. First look for it: search the repository's issues by its exact title, and if one is found, write its id back into the brief (id:,url:,repo:). Only offer to create an issue when none is found. - If
ghis not available here, writeissue_offer: unavailablewith the matchingissue_offer_reason:. That is not a final answer. Withoutgh, Step 4d halts earlier still, withunverifiableand the causegh-missing(orgh-unauthenticated), untilghworks again.
Invent nothing. If the brief's problem statement is empty, the offer does not fire; write
issue_offer: unavailable with issue_offer_reason: no-problem-statement.
Step 4: Push and open the pull request
4a. Push from the right tree
git -C <tree> push -u origin <branch>
In a detached advance worktree publish with git push origin HEAD:refs/heads/<parent> (<parent> is the brief's branch:), with no force form and no + refspec. If refused, halt with: "the parent moved on origin; git fetch and git merge --ff-only origin/<parent> here, then retry".
4b. Open it
Before the create, check the merge direction: py -3 .claude/scripts/merge_direction.py --head <branch> --base <declared base> --session-root <main_root from flat_worktree.py --describe>. Any non-zero exit (11 refused, 10 unverifiable), a missing script or a timeout halts with the printed reason. Always pass --head <branch> as well as --base to gh pr create; in a detached advance worktree that is --head <parent> --base <default branch>.
Write the body to a file rather than passing it inline — a shell will mangle the backticks and the checklist.
gh pr create --base <base> --head <branch> \
--title "<composed title>" --body-file <path> --draft
<base> is Step 0's declared base — the brief's base: field, falling back to the
repository's default branch when the brief or the field is absent. For an ordinary work item
with no parent the declared base is the default branch, so this reads exactly as it always
has.
Ordering matters, and no verdict below can catch a violation of it. GitHub parses the
body's closing keywords against the base at the moment the body is saved, and does not
re-parse when the base changes afterwards. The command above already satisfies this — --base
and --body-file are one gh pr create call. If this pull request already exists and needs
retargeting, the retarget and the body must be written in the same gh pr edit call
(gh pr edit --base <base> --body-file <path>) — never a --base edit followed by a later,
separate body edit. The command looks correct either way; only the ordering decides whether
the link resolves.
Open as a draft unless the user asked otherwise, and say that you did. A draft is reversible; a review request pinging five people is not.
In a Claude Code cloud session GitHub's GraphQL endpoint is blocked (HTTP 403), but REST works.
The scripts read through REST there. For the actions gh would send over GraphQL, use the CCR
routes on api.github.com named in the 403 text instead of gh pr ready / gh pr merge --auto:
POST /repos/{owner}/{repo}/pulls/{n}/ccr/ready_for_review, POST .../ccr/convert_to_draft,
PUT or DELETE .../ccr/auto_merge. A pull request's resolved closing issue links are
GraphQL-only, so Step 4d returns unverifiable with cause graphql-unavailable there: confirm the
link on github.com or from a local checkout, and never accept the body text as proof.
4c. Capture the number
gh pr view --json number,url,isDraft
4d. Verify the Closing Link — the step that earns the skill its name
Never conclude this from the body you just wrote. Ask the forge:
py -3 .claude/scripts/verify_issue_link.py --brief <brief path> --pr <n> --json
The script performs no mutation of any GitHub object; it only reads. It returns a
verdict from a closed set of nineteen. Branch on it:
| Verdict | Meaning | Action |
|---|---|---|
deferred-close |
The declared base is not the default branch — either GitHub resolved the closing reference or the body carries one GitHub has not resolved; either way this base will not fire it on merge | Proceed. issue_link: deferred. /pr-merged owns the close, after confirming the merge. |
linked |
GitHub reports a resolved closing reference | Success. issue_link: closes |
not-applicable |
No issue to link (id: none) and the recorded-offer requirement is off, or no conventions data file |
Success. issue_link: none |
offer-declined |
No issue, and the brief records that the user declined the offer | Success. issue_link: none |
offer-unavailable |
No issue, and the brief records a reason the offer could not be made that still holds (create-failed or no-problem-statement) |
Success. issue_link: none |
exempt-partial |
Refs #N on a deliberately partial item |
Success. issue_link: refs |
absent-repairable |
No link, and the body can be repaired | The one repairable row — see below |
offer-unrecorded |
No issue, and the brief records no decision about opening one (or records a reason that no longer holds) | HALT. Ask the Step 3.5 question, record the answer in issue_offer:, then re-run Step 4d. Do not create the pull request again: it is already open. If issue_offer is opened, first look for the issue already created, searching by its exact title, write its id back into the brief, and only offer to create one when none is found. |
still-absent |
Repair was attempted and the link is still missing | HALT. issue_link: unresolved |
| everything else | unverifiable (including the cause brief-path-unresolved, which means pass the brief path as it exists from the current directory), malformed-id, unresolved-id, issue-unreachable, issue-already-closed, pr-not-open, foreign-closing-ref, unexpected-closing-ref, undeclared-target, refs-without-partial |
HALT. Report the reason and remediation verbatim |
Whether a closing reference resolves at all from a non-default base is unsettled — do not
build a check on either reading of it. What is settled: merging into a non-default base
does not close the issue, whether or not the reference resolved. That is why deferred-close
fires either way: its two conditions (a resolved match, or a body carrying a reference GitHub
has not resolved) cover both readings, so a pull request against a declared, non-default base
reaches this verdict regardless of which reading turns out to be true. /pr-merged performs
the close after confirming the merge and reading the sub-issue's own state first — that
exception belongs to /pr-merged, never to this skill, which runs before the merge it would
need to confirm even exists.
The repair loop runs at most ONCE (maxRepairAttempts: 1). On absent-repairable the
result carries a bodyDiff. Apply it with a single gh pr edit --body-file <path>, then
re-invoke the script with --repair-attempted and take the second verdict as final:
gh pr edit <n> --body-file <repaired path>
py -3 .claude/scripts/verify_issue_link.py --brief <brief path> --pr <n> --repair-attempted --json
Do not loop further. A second failure is a real condition — usually a cross-repository
reference or a non-default base — and hammering gh pr edit will not change it.
On any halting verdict the pull request stays open and the work is not lost. Report the
verdict, the reason and the remediation, and say plainly that the tracker was not linked.
That is an honest partial delivery; a silent status: shipped is not.
Step 5: Write the outcome back into the brief
Only from Step 4d's verdict, never from intent:
pr:— the URL from Step 4c.issue_link:—closes|refs|none|unresolved|deferred, exactly as the verdict maps.deferredis written only by thedeferred-closeverdict — the declared base will not fire the closing keyword on merge, and/pr-mergedowns the eventual close. Never writemanual. That value exists inlinkStatesfor a human who reconciled an issue by hand, and an agent writing it fakes a human action.status:—shippedonly when Step 4d returned a non-halting verdict. On a halting verdict leave the previous status and record why.
Commit the brief update on the branch so the record travels with the work.
/ship never flips the contract's Status. The flip is automatic after the merge, by /pr-merged (see Step 6 of the design-first skill). A flip here would reach the default branch as implemented, and the script would then find the contract already implemented, with no checklist fill and no run log.
Step 6: Worktree lifecycle, after the merge
Nothing to run here for a delivered work item. Its worktrees and merged branches are removed
automatically by Delivery cleanup (cleanup_delivered.py), which /pr-merged and /advance
run on every call once the contract reads implemented at the default branch. An abandoned
worktree that was never delivered is removed with git worktree remove <path> alone, never with
git's worktree prune, which would also remove other work items' stale entries.
Never rm -rf the directory — that leaves stale metadata and git keeps the branch reserved.
Do not remove a worktree the user entered by path for their own purposes.
Anti-patterns (halt immediately)
- Reporting a link because the body says
Closes #N. The body is a request. Only the forge's resolved reference is proof, andresolvesToClosingLink: falsesays so in the conventions file. - Treating an
UNKNOWNid as "no issue". Absent and unresolved mean opposite things. Folding them together is how an unresolved field ships unlinked and is reported as success. - Writing
issue_link: manual. Reserved for a human. - Looping the repair. One attempt, by contract.
- Pushing from the parent tree when the brief names a worktree. The pull request opens with none of the work in it.
- Guessing module tags instead of deriving them from the diff, or building the matcher
on
fnmatch, where*crosses/and silently over-matches. - Re-adding the
[EFFORT]tag to the pull-request title. Effort tags belong to issues, module tags to pull requests. - Ticking every acceptance criterion to make the body look clean.
- Opening the pull request when
/verify-before-donefailed. - Merging, force-pushing, or pushing to the default branch. None of the three belong to this skill.
When NOT to use this skill
- Mid-flight work that is not finished —
/shipis a terminal stage, not a checkpoint. - A change with no work item and no intent to create one; use
/git-commitand stop. - Repositories whose forge is not GitHub. The verification step is
gh-only, and without it the link cannot be proven — which, by this skill's own rule, means it must not be claimed.
Skill integrations
- Fed by
/task, which writes theid,url,repo,contract,branch,worktreeandpartialfields this skill reads back. - Runs
/verify-before-doneat Step 1d as a hard gate. - Drives
.claude/scripts/verify_issue_link.pyat Step 4d — the only production caller. - Shares
.claude/work-item-conventions.jsonwith/task: that file owns the effort tags, the module map and theissueLinkkeyword data, and neither skill restates them. - Shares its Step 3.5 wording with
/taskStep 2.5 point 6 and/design-firstStep 3.6 — one mechanism, three call sites, so a free-text item is offered an issue at intake (or at design time) and once more at ship time unless the user already answered. - Follows
/git-commit, which produces the commits this skill pushes.