---
name: ship
description: "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, composes the PR title from the modules the diff really touches, pushes from the tree that holds the commits, opens the PR, then verifies the Closing Link with verify_issue_link.py and performs at most ONE repair. Writes pr:, issue_link: and status: shipped back into the brief. Use when: user says /ship, 'open the PR', 'ship it', 'raise a pull request', or after /verify-before-done passes on a completed item. This is the last stage of the delivery chain."
user_invocable: true
---
# /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.
1. If the user named one, use it.
2. 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.
3. If nothing matches, list the briefs whose `status:` is not `shipped` and 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`.
**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 `` 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 `. Pushing from the parent tree pushes a branch with none of the work on
it, and the pull request opens empty.
Confirm before continuing:
```bash
git -C rev-parse --abbrev-ref HEAD # must equal the brief's branch:
git -C 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
```bash
git -C fetch origin
git -C log --oneline origin/..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.
```
[]...[]
```
The `` 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:
```bash
git -C diff --name-only origin/...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.globs` first.** 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 of `src` and never a nested file. **Python's `fnmatch` disagrees** — 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. Use
`pathlib.PurePath.match` or an explicit segment-wise matcher.
- **`atLeastOne: true`.** A title with no module tag is invalid.
### 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:
```markdown
## What this delivers
## Acceptance criteria
- [x]
## Out of scope
## Design
Contract: item, routed direct">
## Verification
## North-star alignment
## Notes
Closes #
```
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 #`. Cross-repository: `Closes /#`.
- **`partial: true` in the brief means `Refs #`, not `Closes`.** The item is deliberately
not completed by this pull request, and a closing keyword would shut a live work item.
- **`id: none` means 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 three 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. 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 and `/task` Step 2.5 point 6 share their wording — one mechanism, two call
sites.** `/task` asked at intake; this is the second and last ask, at the moment the work
becomes visible. One `AskUserQuestion`, asked once, quoting the exact title and body:
```bash
gh issue create --title "[] " --body ""
```
On accept, write `id:`, `url:` and `repo:` back into the brief **before** Step 4 composes
the body, so the closing line can reference it. On decline, `id: none` stays, the body
carries no closing line, and Step 4d's verdict will be `not-applicable` — which resolves to
`issue_link: none` and is a **success**, not a warning.
**Invent nothing.** If the brief's problem statement is empty, the offer does not fire.
## Step 4: Push and open the pull request
### 4a. Push from the right tree
```bash
git -C push -u origin
```
**In a detached advance worktree** publish with `git push origin HEAD:refs/heads/` (`` 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/` here, then retry".
### 4b. Open it
**Before the create, check the merge direction:** `py -3 .claude/scripts/merge_direction.py --head --base --session-root `. Any non-zero exit (11 refused, 10 unverifiable), a missing script or a timeout halts with the printed reason. Always pass `--head ` as well as `--base` to `gh pr create`; in a detached advance worktree that is `--head --base `.
Write the body to a file rather than passing it inline — a shell will mangle the backticks
and the checklist.
```bash
gh pr create --base --head \
--title "" --body-file --draft
```
`` 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 --body-file `) — 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.
### 4c. Capture the number
```bash
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:
```bash
py -3 .claude/scripts/verify_issue_link.py --brief --pr --json
```
The script performs **no mutation** of any GitHub object; it only reads. It returns a
`verdict` from a closed set of sixteen. 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`), or no conventions data file | 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 |
| `still-absent` | Repair was attempted and the link is still missing | HALT. `issue_link: unresolved` |
| everything else | `unverifiable`, `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 `, then
re-invoke the script **with `--repair-attempted`** and take the second verdict as final:
```bash
gh pr edit --body-file
py -3 .claude/scripts/verify_issue_link.py --brief --pr --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. `deferred` is written only by the `deferred-close` verdict — the declared base
will not fire the closing keyword on merge, and `/pr-merged` owns the eventual close.
**Never write `manual`.** That value exists in `linkStates` for a human who reconciled an
issue by hand, and an agent writing it fakes a human action.
- `status:` — `shipped` only 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
Only after the pull request is merged, and only for a worktree this chain created:
```bash
git worktree remove .claude/worktrees/
git worktree prune
```
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, and `resolvesToClosingLink: false` says so in the
conventions file.
- **Treating an `UNKNOWN` id 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-done` failed.**
- **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 — `/ship` is a terminal stage, not a checkpoint.
- A change with no work item and no intent to create one; use `/git-commit` and 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 the `id`, `url`, `repo`, `contract`, `branch`, `worktree`
and `partial` fields this skill reads back.
- **Runs** `/verify-before-done` at Step 1d as a hard gate.
- **Drives** `.claude/scripts/verify_issue_link.py` at Step 4d — the only production caller.
- **Shares** `.claude/work-item-conventions.json` with `/task`: that file owns the effort
tags, the module map and the `issueLink` keyword data, and neither skill restates them.
- **Shares its Step 3.5 wording** with `/task` Step 2.5 point 6 — one mechanism, two call
sites, so a free-text item is offered an issue at intake and once more at ship time.
- **Follows** `/git-commit`, which produces the commits this skill pushes.