# /ship - Ship Changes via PR

Ship the current slot's changes. Commits, pushes, creates PR, and handles Traqr-specific lifecycle.

## Arguments
- `[title]` — Optional PR title. If omitted, uses last commit message.

---

## Instructions

### 1. Gather State (2 parallel calls)

Show progress: `{{RAQR_PROGRESS_1}}`

**Call A — Git state:**
```bash
git fetch origin main && \
echo "=== BRANCH ===" && git rev-parse --abbrev-ref HEAD && \
echo "=== STATUS ===" && git status --porcelain && \
echo "=== LOG ===" && git log origin/main..HEAD --oneline && \
echo "=== DIFF_STAT ===" && git diff --stat origin/main..HEAD && \
echo "=== UNCOMMITTED ===" && git diff --stat HEAD
```

**Checks:** On main? → Stop. Nothing to ship? → Stop. Uncommitted? → Commit next step.

**First-time detection:** If this appears to be the user's first session for this slot (no prior session markers detected), show this boxed explanation before proceeding with Step 1.5:

```
╭─── FIRST TIME? ────────────────────────────────────────────╮
│                                                             │
│  /ship creates a Pull Request and notifies                  │
│  {{#IF_SLACK}}Slack{{/IF_SLACK}}{{^IF_SLACK}}the team{{/IF_SLACK}}.                                          │
│  Think of it like packaging a gift and dropping it          │
│  at the post office.                                        │
│                                                             │
│  Steps: commit → build → push → create PR → notify          │
│                                                             │
│  (You won't see this message again.)                        │
╰─────────────────────────────────────────────────────────────╯
```

**Complexity assessment:**
```
STANDARD: files <= 10 AND lines <= 200
COMPLEX:  everything else
```

| Level | Staleness | Memory Capture | PR Body |
|-------|-----------|----------------|---------|
| STANDARD | Run | Run | Standard |
| COMPLEX | Run + deps | Run | Full |

### 1.5. Staleness Gate

Show progress: `{{RAQR_PROGRESS_2}}`

```bash
behind=$(git log HEAD..origin/main --oneline | wc -l | tr -d ' ')
echo "Behind main: $behind"
```

If behind > 0, check file overlap inline:

| Behind | Overlap? | Action |
|--------|----------|--------|
| 0 | N/A | **PROCEED** — fully current |
| 1+ | None | **PROCEED** — rebase handles it |
| 1+ | Different regions | **WARN + PROCEED** — rebase will likely auto-merge |
| 1+ | Same regions | **HARD STOP** — run `/resync` for semantic analysis |

Only defer to `/resync` for genuine same-region overlap. Everything else, `/ship` handles.

### 2. Commit + Rebase

Show progress: `{{RAQR_PROGRESS_3}}`

If uncommitted: stage, commit with `{{CO_AUTHOR}}` co-author, then rebase.
If committed: just rebase.

If rebase conflicts → Show DIZZY mood and **STOP**:
```
{{RAQR_FRAME_START}}
{{RAQR_ART_DIZZY}}
{{RAQR_FRAME_END}}

🦝 Raqr · /ship                             Traqr · {{PROJECT_NAME}}
{{RAQR_HR}}

{{RAQR_PROGRESS_FAIL}}

Rebase stopped — <N> files need untangling:
  - <file1>
  - <file2>

To resolve:
  1. Edit the conflicting files to resolve conflicts
  2. git add <resolved-files>
  3. git rebase --continue
  4. Run /ship again

Or abort and analyze first:
  git rebase --abort
  /resync check    (diagnostic only)
  /resync           (full analysis + rebase)

{{RAQR_HR}}
```

### 3. Build

Show progress: `{{RAQR_PROGRESS_4}}`

```bash
{{BUILD_COMMAND}} 2>&1 | tail -30
```

If fails → Show SAD mood and retry ONCE:
```
{{RAQR_FRAME_START}}
{{RAQR_ART_SAD}}
{{RAQR_FRAME_END}}

🦝 Raqr · /ship                             Traqr · {{PROJECT_NAME}}
{{RAQR_HR}}

{{RAQR_PROGRESS_FAIL}}

Build failed... But I can see what's wrong:
<focused error output>

Attempting fix...
```

Still fails → STOP with SAD mood and error details.

### 4. Push

Show progress: `{{RAQR_PROGRESS_5}}`

{{#IF_CONTROL_CENTER}}**Set "shipping" phase** (fire-and-forget):
```bash
SLOT_NAME=$(basename $(pwd))
BODY=$(jq -nc --arg s "$SLOT_NAME" '{slot:$s, phase:"shipping", meta:"Pushing + creating PR"}')
# Bearer required — /api/control-center/ is an internal-auth route. Precedence mirrors the
# receiving middleware: INTERNAL_API_KEY first, CRON_SECRET only as fallback (they can differ).
_envkey() { grep -m1 "^$1=" "$(git rev-parse --show-toplevel)/.env.local" 2>/dev/null | cut -d= -f2- | tr -d '"'; }
: "${INTERNAL_KEY:=$(_envkey INTERNAL_API_KEY)}"; : "${INTERNAL_KEY:=$(_envkey CRON_SECRET)}"
curl -s --max-time 3 -X POST "{{PROJECT_API_BASE}}/control-center/state" \
  -H "Authorization: Bearer ${INTERNAL_KEY}" \
  -H "Content-Type: application/json" -d "$BODY" > /dev/null &
```
{{/IF_CONTROL_CENTER}}

{{#IF_DAEMON}}**Active plan:** nothing to clear — `plan:slot:*:active` has had no writer since TD-843, and
Guardian reads planned files from Linear comments (`verifyPlanIntegrity`, pr-scanning.ts). The old
`orchestrate/complete-plan` POST had 404'd since the 2026-03-21 platform split (TD-1391).
{{/IF_DAEMON}}

**Push:**
```bash
{{SHIP_ENV_VAR}}=true git push --force-with-lease -u origin <branch> 2>&1 && \
echo "=== VERIFY ===" && git log origin/<branch> -1 --oneline
```

If push fails → retry once:
```bash
git fetch origin && \
{{SHIP_ENV_VAR}}=true git push --force-with-lease -u origin <branch> 2>&1 && \
echo "=== VERIFY ===" && git log origin/<branch> -1 --oneline
```
If still failing → STOP: report error. Common causes: auth expired, branch protection, network.

### 5. Create {{VCS_PR_NOUN}}

Show progress: `{{RAQR_PROGRESS_6}}`

{{#IF_GITHUB}}```bash
existing=$(gh pr list --head "<branch>" --json number --jq '.[0].number' 2>/dev/null)
if [ -n "$existing" ]; then
  echo "EXISTING:$existing"
  {{#IF_GUARDIAN}}# Ensure Guardian label is present
  gh pr edit "$existing" --add-label "{{GUARDIAN_PR_LABEL}}" 2>/dev/null || true
  labels=$(gh pr view "$existing" --json labels --jq '.labels[].name' 2>/dev/null)
  echo "LABELS:$labels"{{/IF_GUARDIAN}}
else gh pr create --title "<title>" --base main --body "$(cat <<'EOF'
## Summary
<what changed and why>

## Slot
Shipped from: <slot> | Branch: <branch>

## Files Changed
<file list with +/- counts>
{{#IF_TIER_1+}}
## Memory Loop
<if memoryLoaded>
- Context loaded: Yes (task: "<query>")
- Searched: <N> | Cited: <N> | Captured: <N>
<else>
- Context loaded: No (quick session)
- Captured: <N> new learnings
</if>
{{/IF_TIER_1+}}

## Test Plan
- [ ] Verify changes work as expected

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
fi
```{{/IF_GITHUB}}

{{#IF_GITLAB}}{{#IF_VCS_PRIMED_SESSION}}**Prime GitLab session** (corporate auth cookie):
```bash
curl -s --max-time 5 "{{VCS_BASE_URL}}/api/v4/projects/{{VCS_PROJECT_ID}}" \
  -H "PRIVATE-TOKEN: $GITLAB_TOKEN" > /dev/null 2>&1
```
{{/IF_VCS_PRIMED_SESSION}}

```bash
existing=$(glab mr list --source-branch "<branch>" --state opened --json iid --jq '.[0].iid' 2>/dev/null || \
  curl -s "{{VCS_BASE_URL}}/api/v4/projects/{{VCS_PROJECT_ID}}/merge_requests?source_branch=<branch>&state=opened" \
  -H "PRIVATE-TOKEN: $GITLAB_TOKEN" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['iid'] if d else '')" 2>/dev/null)
if [ -n "$existing" ]; then
  echo "EXISTING:$existing"
else
  glab mr create --title "<title>" --target-branch main \
    --description "## Summary
<what changed and why>

## Slot
Shipped from: <slot> | Branch: <branch>

## Files Changed
<file list with +/- counts>
{{#IF_TIER_1+}}
## Memory Loop
<if memoryLoaded>
- Context loaded: Yes (task: \"<query>\")
- Searched: <N> | Cited: <N> | Captured: <N>
<else>
- Context loaded: No (quick session)
- Captured: <N> new learnings
</if>
{{/IF_TIER_1+}}

## Test Plan
- [ ] Verify changes work as expected

🤖 Generated with [Claude Code](https://claude.com/claude-code)" \
    {{#IF_VCS_REMOVE_SOURCE_BRANCH}}--remove-source-branch{{/IF_VCS_REMOVE_SOURCE_BRANCH}} \
    2>&1
  existing=$(glab mr list --source-branch "<branch>" --state opened --json iid --jq '.[0].iid' 2>/dev/null)
fi
echo "MR_IID:$existing"
```

{{#IF_VCS_AUTO_MERGE}}**Enable auto-merge** (merge when pipeline succeeds):
```bash
curl -s -X PUT "{{VCS_BASE_URL}}/api/v4/projects/{{VCS_PROJECT_ID}}/merge_requests/${existing}/merge" \
  -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"merge_when_pipeline_succeeds": true}' 2>&1 | head -5
```
{{/IF_VCS_AUTO_MERGE}}

**GitLab label management** (read-then-append — GitLab replaces all labels on update):
```bash
current_labels=$(curl -s "{{VCS_BASE_URL}}/api/v4/projects/{{VCS_PROJECT_ID}}/merge_requests/${existing}" \
  -H "PRIVATE-TOKEN: $GITLAB_TOKEN" | python3 -c "import json,sys; print(','.join(json.load(sys.stdin).get('labels',[])))" 2>/dev/null)
{{#IF_GUARDIAN}}new_labels="${current_labels:+$current_labels,}{{GUARDIAN_PR_LABEL}}"
curl -s -X PUT "{{VCS_BASE_URL}}/api/v4/projects/{{VCS_PROJECT_ID}}/merge_requests/${existing}" \
  -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"labels\":\"$new_labels\"}" > /dev/null 2>&1
echo "Labels: $new_labels"{{/IF_GUARDIAN}}
```
{{/IF_GITLAB}}

{{#IF_GUARDIAN}}**Signal Guardian — pre-validated by /ship** (fire-and-forget):
```bash
BODY=$(jq -nc --arg k "guardian:ship-validated:<NUMBER>" --argjson v 'true' '{key:$k, value:$v}')
curl -s --max-time 3 -X POST "{{PROJECT_API_BASE}}/daemon/kv-set" \
  -H "Content-Type: application/json" -d "$BODY" > /dev/null &
```
Agent fills in `<NUMBER>` from the {{VCS_PR_NOUN}} number obtained above.
{{/IF_GUARDIAN}}

{{#IF_CONTROL_CENTER}}**Set "ready-to-ship" phase:**
```bash
SLOT_NAME=$(basename $(pwd))
BODY=$(jq -nc --arg s "$SLOT_NAME" --arg m "PR #<NUMBER> created — ready for merge" '{slot:$s, phase:"ready-to-ship", meta:$m}')
# Bearer required — see the note at the "shipping" phase call above.
_envkey() { grep -m1 "^$1=" "$(git rev-parse --show-toplevel)/.env.local" 2>/dev/null | cut -d= -f2- | tr -d '"'; }
: "${INTERNAL_KEY:=$(_envkey INTERNAL_API_KEY)}"; : "${INTERNAL_KEY:=$(_envkey CRON_SECRET)}"
curl -s --max-time 3 -X POST "{{PROJECT_API_BASE}}/control-center/state" \
  -H "Authorization: Bearer ${INTERNAL_KEY}" \
  -H "Content-Type: application/json" -d "$BODY" > /dev/null &
```
{{/IF_CONTROL_CENTER}}

{{#IF_TIER_1+}}### 6. Capture Learnings

```bash
BODY=$(jq -nc \
  --arg slot "<slot>" --arg branch "<branch>" --arg diff "<stat>" \
  --arg src "{{TRAQR_PROJECT_SLUG}}" \
  --argjson files '[<files>]' --argjson msgs '[<msgs>]' \
  '{slot:$slot, branch:$branch, diffSummary:$diff, filesChanged:$files, commitMessages:$msgs, sourceProject:$src}')
curl -s --max-time 20 -X POST "{{MEMORY_API_BASE}}/memory/capture-session" \
  -H "Content-Type: application/json" -d "$BODY"
```

If fails, proceed — learnings captured on merge.
{{/IF_TIER_1+}}

{{#IF_CONTROL_CENTER}}### 6.5. Session Summary to Control Center

```bash
SLOT_NAME=$(basename $(pwd))
SESSION_FILE="/tmp/{{SESSION_PREFIX}}-session-metrics-${SLOT_NAME}.json"
# The session-metrics writer (scripts/hooks/track-session-*.sh) emits epoch-seconds
# fields — .slot / .start_ts / .prompt_count / .last_prompt_ts — and has no Linear
# ticket or task at hook time. So read .start_ts (an integer, NOT an ISO string) and
# leave ticket/task null; they are not capturable here (TD-1056).
START_TS=$(jq -r '.start_ts // empty' "$SESSION_FILE" 2>/dev/null)
if [ -n "$START_TS" ]; then
  ELAPSED_MIN=$(( ($(date "+%s") - START_TS) / 60 ))
  [ "$ELAPSED_MIN" -lt 60 ] && DURATION="${ELAPSED_MIN}m" || DURATION="$((ELAPSED_MIN / 60))h $((ELAPSED_MIN % 60))m"
else DURATION="unknown"; fi

CC_BODY=$(jq -nc \
  --arg slot "$SLOT_NAME" \
  --arg phase "shipped" \
  --arg meta "PR #<PR_NUMBER>" \
  --arg duration "$DURATION" \
  --argjson filesChanged <FILES_CHANGED> \
  --argjson insertions <INSERTIONS> \
  --argjson deletions <DELETIONS> \
  --argjson prNumber <PR_NUMBER> \
  --arg prUrl "<PR_URL>" \
  '{slot:$slot, phase:$phase, meta:$meta, sessionSummary:{
    filesChanged:$filesChanged, insertions:$insertions, deletions:$deletions,
    prNumber:$prNumber, prUrl:$prUrl, learningsCaptured:0, duration:$duration,
    ticketId:null, task:null
  }}')

# Bearer required — see the note at the "shipping" phase call above.
_envkey() { grep -m1 "^$1=" "$(git rev-parse --show-toplevel)/.env.local" 2>/dev/null | cut -d= -f2- | tr -d '"'; }
: "${INTERNAL_KEY:=$(_envkey INTERNAL_API_KEY)}"; : "${INTERNAL_KEY:=$(_envkey CRON_SECRET)}"
curl -s --max-time 5 -X POST "{{PROJECT_API_BASE}}/control-center/state" \
  -H "Authorization: Bearer ${INTERNAL_KEY}" \
  -H "Content-Type: application/json" -d "$CC_BODY" > /dev/null &
```

Agent fills in `<FILES_CHANGED>`, `<INSERTIONS>`, `<DELETIONS>`, `<PR_NUMBER>`, `<PR_URL>` from earlier steps.

If fails, proceed — cosmetic only.
{{/IF_CONTROL_CENTER}}

{{#IF_TIER_3+}}### 7. Notify Slack

```bash
curl -s -X POST "{{PROJECT_API_BASE}}/github/pr?action=notify&pr=<number>"
```
{{/IF_TIER_3+}}

{{#IF_TIER_2+}}### 8. Update Ticket

If ticket linked:
{{#IF_LINEAR}}- Linear: `mcp__linear__update_issue` → Done{{/IF_LINEAR}}
{{#IF_GITHUB_ISSUES}}- GitHub: `gh issue close`{{/IF_GITHUB_ISSUES}}
{{#IF_GITLAB_ISSUES}}- GitLab: Close issue via API:
```bash
curl -s -X PUT "{{VCS_BASE_URL}}/api/v4/projects/{{VCS_PROJECT_ID}}/issues/<issue_iid>" \
  -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"state_event":"close"}' > /dev/null 2>&1
```
{{/IF_GITLAB_ISSUES}}
{{/IF_TIER_2+}}

### Relay to #control-center

Post a structured update so other worktree slots know what shipped:

```
slack_send_message(
  channel_id: "{{SLACK_CONTROL_CENTER_CHANNEL}}",
  message: "[<SlotName>] Shipped PR #<number> — <title>\n\n*What changed:* <1-2 sentence summary>\n*Affected:* <apps/packages touched>\n*Key insight:* <if anything was learned that other slots should know>"
)
```

Skip if Slack MCP unavailable.

### Report

Show progress: `{{RAQR_PROGRESS_7}}`

Calculate session duration from marker `startedAt` to now.

**Session duration lines:**
- <15 min: "<Xm> speedrun! Quick and clean."
- 15min-2h: "<Xh Ym> session. Solid work."
- 2h-4h: "<Xh Ym> session. You've been in the zone — go stretch."
- >4h: "<Xh Ym> session. Raqr's impressed, but maybe take a break?"

**Milestone celebrations** (check PR count via `gh pr list --state merged --author @me --limit 100 --json number | jq length`):
- 10th PR: Use GREEDY mood `{{RAQR_ART_GREEDY}}` — "Double digits! 10 PRs shipped — you're on a roll."
- 25th PR: Use GREEDY mood — "25 PRs! Quarter-century mark."
- 50th PR: Use EXCITED mood `{{RAQR_ART_EXCITED}}` — "50 PRs shipped! That's serious velocity."
- 100th PR: Use EXCITED mood — "100 PRs! This is what momentum looks like."

**First rebase survived**: If rebase ran in this session and succeeded, and this appears to be the first rebase: Use RELIEVED mood `{{RAQR_ART_RELIEVED}}` — "First rebase! That wasn't so bad, right?"

**Success:**
```
{{RAQR_FRAME_START}}
{{RAQR_ART_CELEBRATE}}
{{RAQR_FRAME_END}}

🦝 Raqr · /ship                             Traqr · {{PROJECT_NAME}}
{{RAQR_HR}}

{{RAQR_PROGRESS_7}}

{{VCS_PR_NOUN}} #<number>: <title>
URL: {{VCS_MR_URL_PREFIX}}<number>
{{#IF_TIER_3+}}
Notification sent to #{{SLACK_DEPLOY_CHANNEL}}
  Click "Merge (Squash)" when ready
{{/IF_TIER_3+}}

<session duration personality line — see rules below>

<if any post-ship operations returned errors>
──────────────────────────────────────────────────────────────
Post-ship notes:
  - <Capture failed>: Learnings will be captured on merge (no action needed)
  - <Slack failed>: Run `/pr notify <N>` to retry
  - <Control center failed>: Dashboard may be stale (cosmetic only)
</if>

{{RAQR_HR}}
What's next? → /sync after merge | /compact to free context for next task
```

**Session duration lines:**
- <15 min: "<Xm> speedrun! Quick and clean."
- 15min-2h: "<Xh Ym> session. Solid work."
- 2h-4h: "<Xh Ym> session. You've been in the zone — go stretch."
- >4h: "<Xh Ym> session. Raqr's impressed, but maybe take a break?"

**Failure (general):**
```
{{RAQR_FRAME_START}}
{{RAQR_ART_ALERT}}
{{RAQR_FRAME_END}}

🦝 Raqr · /ship                             Traqr · {{PROJECT_NAME}}
{{RAQR_HR}}

<failure details + options>

{{RAQR_HR}}
```

## Commit Conventions

- Types: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
- Imperative mood, lowercase first letter, max 72 char subject
- HEREDOC format, always include co-author line
- Co-author: `{{CO_AUTHOR}} <noreply@anthropic.com>`

## Slot Reference

{{SLOT_TABLE}}
