* feat(#2473): ship refuses to open PR when HANDOFF.json declares in-progress work Add a preflight step to /gsd-ship that parses .planning/HANDOFF.json and refuses to run git push + gh pr create when any remaining_tasks[].status is not in the terminal set {done, cancelled, deferred_to_backend, wont_fix}. Refusal names each blocking task and lists four resolutions (finish, mark terminal, delete stale file, --force). Missing HANDOFF.json is a no-op so projects that do not use /gsd-pause-work see no behavior change. Also documents the terminal-statuses contract in references/artifact-types.md and adds tests/ship-handoff-preflight.test.cjs to lock in the contract. Closes #2473 * fix(#2473): capture node exit from $() so malformed HANDOFF.json hard-stops Command substitution BLOCKING=$(node -e "...") discards the inner process exit code, so a corrupted HANDOFF.json that fails JSON.parse would yield empty BLOCKING and fall through silently to push_branch — the opposite of what preflight is supposed to do. Capture node's exit into HANDOFF_EXIT via $? immediately after the assignment and branch on it. A non-zero exit is now a hard refusal with the parser error printed on the preceding stderr line. --force does not bypass this branch: if the file exists and can't be parsed, something is wrong and the user should fix it (option 3 in the refusal message — "Delete HANDOFF.json if it's stale" — still applies). Verified with a tmp-dir simulation: captured exit 2, hard-stop fires correctly on malformed JSON. Added a test case asserting the capture ($?) + branch (-ne 0) + parser exit (process.exit(2)) are all present, so a future refactor can't silently reintroduce the bug. Reported by @coderabbitai on PR #2553.
12 KiB
<required_reading> Read all files referenced by the invoking prompt's execution_context before starting. </required_reading>
Parse arguments and load project state:INIT=$(gsd-sdk query init.phase-op "${PHASE_ARG}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
Parse from init JSON: phase_found, phase_dir, phase_number, phase_name, padded_phase, commit_docs.
Also load config for branching strategy:
CONFIG=$(gsd-sdk query state.load)
Extract: branching_strategy, branch_name.
Detect base branch for PRs and merges:
BASE_BRANCH=$(gsd-sdk query config-get git.base_branch 2>/dev/null || echo "")
if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then
BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||')
BASE_BRANCH="${BASE_BRANCH:-main}"
fi
Detect the --force override once, before the checks. Set FORCE=true if --force appears in $ARGUMENTS, otherwise FORCE=false. The override applies to the "Pending handoff tasks?" check below; it does not skip the other preflight steps.
-
Verification passed?
VERIFICATION=$(cat ${PHASE_DIR}/*-VERIFICATION.md 2>/dev/null)Check for
status: passedorstatus: human_needed(with human approval). If no VERIFICATION.md or status isgaps_found: warn and ask user to confirm. -
Clean working tree?
git status --shortIf uncommitted changes exist: ask user to commit or stash first.
-
Pending handoff tasks?
If
.planning/HANDOFF.jsonexists, parseremaining_tasks[]and refuse when any entry'sstatusis not in the terminal set{done, cancelled, deferred_to_backend, wont_fix}. The canonical pause/resume contract treats non-terminal statuses as "work still in progress on this branch" —/gsd-shipmust not create a public PR while that signal is active.Missing
HANDOFF.jsonis a no-op (preserves existing behavior for projects that don't use/gsd-pause-work). A malformedHANDOFF.json(non-parseable JSON) is a hard stop — the exit code fromnodeis captured explicitly so a bad file can never fall through to a silent pass.HANDOFF_PATH=.planning/HANDOFF.json if [ -f "${HANDOFF_PATH}" ]; then BLOCKING=$(node -e " const fs = require('fs'); const TERMINAL = new Set(['done','cancelled','deferred_to_backend','wont_fix']); let h; try { h = JSON.parse(fs.readFileSync('${HANDOFF_PATH}','utf8')); } catch (e) { console.error('HANDOFF.json is not valid JSON: ' + e.message); process.exit(2); } const tasks = Array.isArray(h.remaining_tasks) ? h.remaining_tasks : []; const blocking = tasks.filter(t => t && !TERMINAL.has(t.status)); blocking.forEach(t => console.log(' • [' + (t.status || 'unknown') + '] ' + (t.name || ('task ' + t.id)))); ") HANDOFF_EXIT=$? if [ "${HANDOFF_EXIT}" -ne 0 ]; then echo "" echo "✗ Cannot ship: .planning/HANDOFF.json could not be parsed (node exited ${HANDOFF_EXIT})." echo " Fix the JSON or delete the file, then retry. The parser error is printed above." exit 1 fi if [ -n "${BLOCKING}" ]; then if [ "${FORCE}" = "true" ]; then echo "⚠ HANDOFF.json declares in-progress work — shipping anyway because --force was passed:" echo "${BLOCKING}" else cat <<EOF ✗ Cannot ship: HANDOFF.json declares in-progress work on this branch. Blocking tasks: ${BLOCKING} Options: 1. Complete the remaining tasks, then run /gsd-pause-work or /gsd-verify-work to close them 2. Mark each as terminal (done/cancelled/deferred_to_backend/wont_fix) in HANDOFF.json if they're no longer in scope for this branch 3. Delete HANDOFF.json if it's stale from an earlier session (resume-project.md documents this as the one-shot cleanup) 4. Pass --force to ship anyway (not recommended — the branch will land with declared-unfinished work) EOF exit 1 fi fi fiTerminal statuses contract: see
references/artifact-types.md(HANDOFF.json entry). -
On correct branch?
CURRENT_BRANCH=$(git branch --show-current)If on
${BASE_BRANCH}: warn — should be on a feature branch. If branching_strategy isnone: offer to create a branch now. -
Remote configured?
git remote -v | head -2Detect
originremote. If no remote: error — can't create PR. -
ghCLI available?which gh && gh auth status 2>&1If
ghnot found or not authenticated: provide setup instructions and exit.
git push origin ${CURRENT_BRANCH} 2>&1
If push fails (e.g., no upstream): set upstream:
git push --set-upstream origin ${CURRENT_BRANCH} 2>&1
Report: "Pushed {branch} to origin ({commit_count} commits ahead of ${BASE_BRANCH})"
1. Title:
Phase {phase_number}: {phase_name}
Or for milestone: Milestone {version}: {name}
2. Summary section: Read ROADMAP.md for phase goal. Read VERIFICATION.md for verification status.
## Summary
**Phase {N}: {Name}**
**Goal:** {goal from ROADMAP.md}
**Status:** Verified ✓
{One paragraph synthesized from SUMMARY.md files — what was built}
3. Changes section: For each SUMMARY.md in the phase directory:
## Changes
### Plan {plan_id}: {plan_name}
{one_liner from SUMMARY.md frontmatter}
**Key files:**
{key-files.created and key-files.modified from SUMMARY.md frontmatter}
4. Requirements section:
## Requirements Addressed
{REQ-IDs from plan frontmatter, linked to REQUIREMENTS.md descriptions}
5. Testing section:
## Verification
- [x] Automated verification: {pass/fail from VERIFICATION.md}
- {human verification items from VERIFICATION.md, if any}
6. Decisions section:
## Key Decisions
{Decisions from STATE.md accumulated context relevant to this phase}
gh pr create \
--title "Phase ${PHASE_NUMBER}: ${PHASE_NAME}" \
--body "${PR_BODY}" \
--base ${BASE_BRANCH}
If --draft flag was passed: add --draft.
Report: "PR #{number} created: {url}"
External code review command (automated sub-step):
Before prompting the user, check if an external review command is configured:
REVIEW_CMD=$(gsd-sdk query config-get workflow.code_review_command 2>/dev/null | jq -r '.' 2>/dev/null || echo "")
If REVIEW_CMD is non-empty and not "null", run the external review:
-
Generate diff and stats:
DIFF=$(git diff ${BASE_BRANCH}...HEAD) DIFF_STATS=$(git diff --stat ${BASE_BRANCH}...HEAD) -
Load phase context from STATE.md:
STATE_STATUS=$(gsd-sdk query state.load 2>/dev/null | head -20) -
Build review prompt and pipe to command via stdin: Construct a review prompt containing the diff, diff stats, and phase context, then pipe it to the configured command:
REVIEW_PROMPT="You are reviewing a pull request.\n\nDiff stats:\n${DIFF_STATS}\n\nPhase context:\n${STATE_STATUS}\n\nFull diff:\n${DIFF}\n\nRespond with JSON: { \"verdict\": \"APPROVED\" or \"REVISE\", \"confidence\": 0-100, \"summary\": \"...\", \"issues\": [{\"severity\": \"...\", \"file\": \"...\", \"line_range\": \"...\", \"description\": \"...\", \"suggestion\": \"...\"}] }" REVIEW_OUTPUT=$(echo "${REVIEW_PROMPT}" | timeout 120 ${REVIEW_CMD} 2>/tmp/gsd-review-stderr.log) REVIEW_EXIT=$? -
Handle timeout (120s) and failure: If
REVIEW_EXITis non-zero or the command times out:if [ $REVIEW_EXIT -ne 0 ]; then REVIEW_STDERR=$(cat /tmp/gsd-review-stderr.log 2>/dev/null) echo "WARNING: External review command failed (exit ${REVIEW_EXIT}). stderr: ${REVIEW_STDERR}" echo "Continuing with manual review flow..." fiOn failure, warn with stderr output and fall through to the manual review flow below.
-
Parse JSON result: If the command succeeded, parse the JSON output and report the verdict:
# Parse verdict and summary from REVIEW_OUTPUT JSON VERDICT=$(echo "${REVIEW_OUTPUT}" | node -e " let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{ try { const r=JSON.parse(d); console.log(r.verdict); } catch(e) { console.log('INVALID_JSON'); } }); ")- If
verdictis"APPROVED": report approval with confidence and summary. - If
verdictis"REVISE": report issues found, list each issue with severity, file, line_range, description, and suggestion. - If JSON is invalid (
INVALID_JSON): warn "External review returned invalid JSON" with stderr and continue.
Regardless of the external review result, fall through to the manual review options below.
- If
Manual review options:
Ask if user wants to trigger a code review:
Text mode (workflow.text_mode: true in config or --text flag): Set TEXT_MODE=true if --text is present in $ARGUMENTS OR text_mode from init JSON is true. When TEXT_MODE is active, replace every AskUserQuestion call with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where AskUserQuestion is not available.
AskUserQuestion:
question: "PR created. Run a code review before merge?"
options:
- label: "Skip review"
description: "PR is ready — merge when CI passes"
- label: "Self-review"
description: "I'll review the diff in the PR myself"
- label: "Request review"
description: "Request review from a teammate"
If "Request review":
gh pr edit ${PR_NUMBER} --add-reviewer "${REVIEWER}"
If "Self-review": Report the PR URL and suggest: "Review the diff at {url}/files"
Update STATE.md to reflect the shipping action:gsd-sdk query state.update "Last Activity" "$(date +%Y-%m-%d)"
gsd-sdk query state.update "Status" "Phase ${PHASE_NUMBER} shipped — PR #${PR_NUMBER}"
If commit_docs is true:
gsd-sdk query commit "docs(${padded_phase}): ship phase ${PHASE_NUMBER} — PR #${PR_NUMBER}" .planning/STATE.md
✓ Phase {X}: {Name} — Shipped
PR: #{number} ({url}) Branch: {branch} → ${BASE_BRANCH} Commits: {count} Verification: ✓ Passed Requirements: {N} REQ-IDs addressed
Next steps:
- Review/approve PR
- Merge when CI passes
- /gsd:complete-milestone (if last phase in milestone)
- /gsd:progress (to see what's next)
───────────────────────────────────────────────────────────────
</step>
</process>
<offer_next>
After shipping:
- /gsd:complete-milestone — if all phases in milestone are done
- /gsd:progress — see overall project state
- /gsd:execute-phase {next} — continue to next phase
</offer_next>
<success_criteria>
- [ ] Preflight checks passed (verification, clean tree, branch, remote, gh)
- [ ] Branch pushed to remote
- [ ] PR created with rich auto-generated body
- [ ] STATE.md updated with shipping status
- [ ] User knows PR number and next steps
</success_criteria>