ship:finish
Completes verified features by automating the PR and merge lifecycle.
Install
mkdir -p .claude/skills/ship-finish && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/16230" && unzip -o skill.zip -d .claude/skills/ship-finish && rm skill.zipInstalls to .claude/skills/ship-finish
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Use when a feature has been verified and needs to be completed — creates PR, merges locally, or keeps branchKey capabilities
- →Parse arguments for feature name and inconclusive acceptance
- →Identify the active feature from injected state or file system
- →Check for inconclusive verdicts in VERIFY.md
- →Run project test suite as a prerequisite
- →Create a Pull Request for the finished feature
- →Merge the feature to the base branch locally
How it works
The skill parses arguments, identifies the active feature, checks for inconclusive verdicts, runs tests, and then presents options to the user for creating a PR, merging locally, or keeping the branch.
Inputs & outputs
When to use ship:finish
- →Merging finished feature branches
- →Creating pull requests
- →Closing out feature development
About this skill
Finish the active feature after verification passes.
Parse Arguments
Parse $ARGUMENTS (a single string) into two components:
--accept-inconclusive "reason"— if this flag appears anywhere in the string, setACCEPT_INCONCLUSIVE = trueand extract the quoted reason text (everything between the matching"s after the flag).- Remaining tokens (after removing the flag + reason) — treat as feature name.
If --accept-inconclusive appears WITHOUT a quoted reason, abort and tell the user: --accept-inconclusive requires a non-empty reason in quotes. Example: /ship:finish my-feature --accept-inconclusive "manually verified end-to-end on staging".
If ACCEPT_INCONCLUSIVE = false, behave as before.
Find Active Feature
Feature state is injected by hooks at session start and after compaction — check conversation context for "SHIP ACTIVE FEATURES" or "SHIP FEATURE STATE" blocks first.
- If
$ARGUMENTSis provided, use it as the feature name - Otherwise, use injected feature state to identify the feature with status
done - If no injected state is available, fall back to scanning
.planning/features/*/CONTEXT.md - If no candidates, report that no finished features were found
Check INCONCLUSIVE Verdicts
Read .planning/features/{name}/VERIFY.md. Search for any of:
status: INCONCLUSIVEin the frontmatter, OR- Any row in the Stage 1 table with verdict
INCONCLUSIVE.
If found:
- If
ACCEPT_INCONCLUSIVE = false: Display:
Stop. Do not proceed to Prerequisites.Cannot finish — VERIFY.md contains INCONCLUSIVE verdicts: {list each INCONCLUSIVE criterion} Options: 1. Add runnable <verify> commands to PLAN.md for the inconclusive criteria, then re-run /ship:verify. 2. Override with: /ship:finish {name} --accept-inconclusive "reason for manual acceptance" - If
ACCEPT_INCONCLUSIVE = true: Append the override record to VERIFY.md's## Inconclusive Overridesection:- Set
Override applied: yes - Set
Reason: {the reason text} - Set
Operator: $(git config user.email || echo unknown) - Set
Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)Then continue to Prerequisites.
- Set
If VERIFY.md has no INCONCLUSIVE markers, proceed directly to Prerequisites.
Prerequisites
Run the project's test suite to confirm everything passes:
# Auto-detect test command from package.json, Cargo.toml, etc.
# If unclear, ask the user for the test command
If tests fail, stop and report failures. Do not proceed.
Present Options
Feature '{name}' is verified and complete.
1. Create a Pull Request (push branch + gh pr create)
2. Merge to {base-branch} locally
3. Keep as-is (I'll handle it later)
Which option?
Use AskUserQuestion to get the user's choice.
Execute Choice
Option 1: Create PR
First, check that gh is available: gh auth status. If it fails, tell the user to install/authenticate gh and abort.
# Detect base branch
git rev-parse --verify main &>/dev/null && echo main || echo master
# Determine PR title type from branch commits
# Look at commit prefixes (feat, fix, refactor, etc.) — use the most common one
# If mixed or unclear, default to "feat"
# Get feature summary from CONTEXT.md for PR body
# Push and create PR
git push -u origin HEAD
PR_URL=$(gh pr create --title "{type}: {feature-name}" --body "$(cat <<'EOF'
## Summary
{2-3 bullets from CONTEXT.md acceptance criteria}
## Test plan
{key verify commands from PLAN.md}
Built with [Ship](https://github.com/dilhanz/ship)
EOF
)")
# `gh pr create` prints the URL on success. When its output is empty or is not
# a URL (an existing PR, a warning-only run), ask for the URL directly.
case "$PR_URL" in
https://*) ;;
*) PR_URL=$(gh pr view --json url -q .url) ;;
esac
Report the PR URL ($PR_URL) to the user, then stamp it into the feature record.
Stamp the PR URL
Record the PR on the feature itself, so merge provenance is a lookup on disk rather than git archaeology later. The URL is written verbatim as gh printed it — the PR number stays derivable from it rather than stored twice.
Stamp it before the archive move, while the directory is still at .planning/features/{feature-name}/. Stamping after the mv would target a path that no longer exists — in a worktree-isolated session it would silently do nothing. This is the same ordering rule the outcome: stamp follows, and for the same reason.
This skill has no Write or Edit tool, so the stamp goes through Bash. Replace an existing pr: line if there is one, otherwise insert one directly after the status: line inside the leading frontmatter block, and leave every other byte alone:
CTX=".planning/features/{feature-name}/CONTEXT.md"
node -e '
const fs = require("fs"), [p, v] = process.argv.slice(1);
if (!v) process.exit(1);
const s = fs.readFileSync(p, "utf8");
const m = s.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!m) process.exit(1);
let fm = m[1];
fm = /^pr:/m.test(fm)
? fm.replace(/^pr:.*$/m, "pr: " + v)
: fm.replace(/^(status:.*)$/m, "$1\npr: " + v);
if (!/^pr:/m.test(fm)) fm += "\npr: " + v;
fs.writeFileSync(p, s.slice(0, m.index) + "---\n" + fm + "\n---" + s.slice(m.index + m[0].length));
' "$CTX" "$PR_URL" && grep -n 'pr: ' "$CTX"
A failed or impossible stamp is not fatal — report the failure, leave the field absent, and let the archive proceed. An empty or unavailable URL (neither gh pr create nor the gh pr view fallback produced one) exits non-zero and writes nothing: an absent field is a recorded gap, whereas a literal pr: line with no value would be a false record that the trailing grep would read back as success. A CONTEXT.md with no leading frontmatter block behaves the same way — exits non-zero, left byte-identical.
Option 2 and Option 3 open no PR and stamp nothing.
Option 2: Merge Locally
# Detect base branch
BASE=$(git rev-parse --verify main &>/dev/null && echo main || echo master)
git checkout $BASE
git merge {feature-branch}
Run tests again on the merged result. If tests pass, report success.
Option 3: Keep As-Is
Report: "Feature '{name}' kept on current branch. Run /ship:finish again when ready."
Do NOT archive the feature directory for this option.
Archive Feature
After Option 1 or Option 2 completes successfully, archive the feature — first stamp the outcome, then move the directory.
Stamp the archive outcome
Ask the user, with AskUserQuestion, which outcome this archive records:
- shipped (default) — the feature was built and verified, and this archive records working work.
- abandoned — the work stopped and is not coming back.
- superseded — another feature replaced it.
- umbrella — a container for other features rather than shippable work of its own.
Stamp the answer into the feature's CONTEXT.md frontmatter before the move, while the directory is still at .planning/features/{feature-name}/. Stamping after the mv would target a path that no longer exists — in a worktree-isolated session it would silently do nothing.
This skill has allowed-tools: Read, Bash, Glob, AskUserQuestion and no Write or Edit, so the stamp goes through Bash. Replace an existing outcome: line if there is one, otherwise insert one directly after the status: line inside the leading frontmatter block, and leave every other byte alone:
CTX=".planning/features/{feature-name}/CONTEXT.md"
OUTCOME={shipped|abandoned|superseded|umbrella}
node -e '
const fs = require("fs"), [p, v] = process.argv.slice(1);
const s = fs.readFileSync(p, "utf8");
const m = s.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!m) process.exit(1);
let fm = m[1];
fm = /^outcome:/m.test(fm)
? fm.replace(/^outcome:.*$/m, "outcome: " + v)
: fm.replace(/^(status:.*)$/m, "$1\noutcome: " + v);
if (!/^outcome:/m.test(fm)) fm += "\noutcome: " + v;
fs.writeFileSync(p, s.slice(0, m.index) + "---\n" + fm + "\n---" + s.slice(m.index + m[0].length));
' "$CTX" "$OUTCOME" && grep -n '^outcome:' "$CTX"
A failed stamp is not fatal — the archive still proceeds. CONTEXT.md then carries no outcome:, which is a visible gap rather than a false shipped. Report the failure and move on; never block the archive on it.
Move the directory
Move the feature directory to the main worktree's archive. Resolve the main worktree root first:
MAIN_ROOT=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")
mkdir -p "$MAIN_ROOT/.planning/archive"
mv .planning/features/{feature-name} "$MAIN_ROOT/.planning/archive/{feature-name}"
In the main worktree, MAIN_ROOT resolves to the current root — behavior unchanged. From a linked worktree, this moves the record to the main worktree so the audit trail (CONTEXT.md, PLAN.md, VERIFY.md) survives git worktree remove. If git rev-parse fails (not a git repo), fall back to the local .planning/archive/ exactly as before.
The move relocates the record for everyone, out of band. Once it lands on main, every open branch still carries .planning/features/{feature-name}/, so a branch that later amends that record — a follow-up fix PR marking a carried finding resolved in VERIFY.md, say — is editing a path main no longer has. Local git resolves this as the rename it is, but GitHub has been observed to report mergeable: CONFLICTING with no file named even for an isolated, near-pure-rename archive commit, so rename detection is not something to rely on. Two consequences worth stating when you archive:
- Commit the move on its own, touching nothing else. It does not guarantee clean rename detection, but it is the shape most likely to get it, and it keeps the resolution obvious when detection fails.
- A branch that will amend the archived record must sync
mainfirst. Mergemaininto the PR branch locally, confirm the resolution leaves exactly one copy of the record — at the archive path, carrying the branch's
Content truncated.
When not to use it
- →When `--accept-inconclusive` is used without a quoted reason
- →When project tests fail before proceeding to options
- →When the feature is not yet verified
Limitations
- →Cannot finish if VERIFY.md contains INCONCLUSIVE verdicts and `ACCEPT_INCONCLUSIVE` is false.
- →If tests fail, the process stops and reports failures.
- →Requires `gh` to be installed and authenticated for creating Pull Requests.
How it compares
This skill automates the finalization of a feature by integrating verification checks, test execution, and options for PR creation or local merging, unlike a manual process that would require separate steps.
Compared to similar skills
ship:finish side by side with the closest alternatives in the catalog.
| Skill | Installs | Updated | Safety | Difficulty |
|---|---|---|---|---|
| ship:finish (this skill) | 0 | 4mo | Review | Intermediate |
| github-release-management | 4 | 7mo | Review | Advanced |
| agent-release-swarm | 1 | 7mo | Review | Advanced |
| magerun-release | 1 | 7mo | No flags | Intermediate |
Try saying
Example prompts that trigger this skill in your AI assistant.
More by dilhanz
View all by dilhanz →You might also like
github-release-management
ruvnet
Comprehensive GitHub release orchestration with AI swarm coordination for automated versioning, testing, deployment, and rollback management
agent-release-swarm
ruvnet
Agent skill for release-swarm - invoke with $agent-release-swarm
magerun-release
netz98
Technical release process for n98-magerun2
release
ziggy42
Use this skill when the user wants to cut a new release.
release-branch
mono
Create a release branch for SkiaSharp. Use when user says "release X", "start release X", "create release branch for X", "I want to release", or "release now". This is the FIRST step of releasing - creates branch and pushes to trigger CI. Can auto-detect next preview version from main branch.
release-minor
knuckleswtf
Automates the process of tagging a new minor release for Scribe by analyzing commit messages, updating the changelog, and creating a GitHub release.