chore(skills): add workflow chain — grill-me → prd → prd-to-issues → ship-it
Four workflow skills that take a feature from fuzzy idea to merged code.
Two human-in-the-loop phases (grill-me, prd), one mostly-together (prd-to-issues
files only on explicit 'create'), and one AFK (ship-it).
grill-me TOGETHER pressure-test the idea with hard interview questions
prd TOGETHER synthesize PRD; gaps stay explicit, not papered over
prd-to-issues MOSTLY thin vertical-slice issues with coverage matrix +
per-issue Slice check; self-audits before showing
ship-it AFK shell loop ships each slice end-to-end with one
commit per issue, status streams to terminal,
Ctrl-C-able, survives session close
Vertical-slice principle throughout: every issue cuts end-to-end through every
integration layer (no horizontal "do all the DB work first" issues). The
AFK loop only ships against acceptance criteria already locked in by the PRD
phase — autonomous code never runs against undefined contracts.
ship-it tracker support: gh (GitHub) and tea (Gitea). For this repo, set
SHIP_IT_TRUNK=development to override the main default.
See .claude/skills/README.md for the full how-to and a worked example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
189
.claude/skills/ship-it/loop.sh
Normal file
189
.claude/skills/ship-it/loop.sh
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
# ship-it AFK loop — works through every ready issue end-to-end.
|
||||
# See SKILL.md for design. Ctrl-C to stop; partial work is preserved on disk.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "not in a git repo"; exit 1; }
|
||||
|
||||
# ---- config (env-overridable) ----
|
||||
MAX_ITERATIONS="${SHIP_IT_MAX:-50}"
|
||||
MAX_CONSECUTIVE_FAILURES="${SHIP_IT_MAX_FAIL:-3}"
|
||||
TRUNK_BRANCH="${SHIP_IT_TRUNK:-main}"
|
||||
ITERATION_TIMEOUT="${SHIP_IT_TIMEOUT:-30m}" # per-issue cap
|
||||
LOG_DIR="${SHIP_IT_LOG_DIR:-$REPO_ROOT/.ship-it-logs}"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
LOG_FILE="$LOG_DIR/run-$RUN_ID.log"
|
||||
|
||||
# ---- logging ----
|
||||
log() {
|
||||
local ts; ts="$(date -u +%H:%M:%S)"
|
||||
printf '[%s] %s\n' "$ts" "$*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
die() { log "FATAL: $*"; exit 1; }
|
||||
|
||||
# ---- graceful interrupt ----
|
||||
INTERRUPTED=0
|
||||
on_interrupt() {
|
||||
INTERRUPTED=1
|
||||
log ""
|
||||
log "interrupt received — finishing current step cleanly, then stopping"
|
||||
}
|
||||
trap on_interrupt INT
|
||||
|
||||
# ---- tracker detection ----
|
||||
ORIGIN_URL="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || true)"
|
||||
if [[ "$ORIGIN_URL" == *"github.com"* ]]; then
|
||||
TRACKER_CLI="gh"
|
||||
command -v gh >/dev/null || die "gh CLI not installed"
|
||||
gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
|
||||
list_ready_issues() {
|
||||
gh issue list --state open --label slice --limit 100 \
|
||||
--json number,title,labels \
|
||||
--jq '[.[] | select(.labels | map(.name) | (contains(["blocked"]) or contains(["needs-decision"]) or contains(["ci-failed"])) | not)] | sort_by(.number)'
|
||||
}
|
||||
elif [[ "$ORIGIN_URL" == *"gitea"* ]]; then
|
||||
TRACKER_CLI="tea"
|
||||
command -v tea >/dev/null || die "tea CLI not installed (Gitea repo detected) — install tea or switch to a GitHub remote"
|
||||
list_ready_issues() {
|
||||
tea issues list --state open --output json 2>/dev/null \
|
||||
| jq '[.[] | select((.labels // []) | map(.name) | (contains(["blocked"]) or contains(["needs-decision"]) or contains(["ci-failed"])) | not) | select((.labels // []) | map(.name) | contains(["slice"]))] | sort_by(.index)'
|
||||
}
|
||||
else
|
||||
die "unknown tracker for origin: '$ORIGIN_URL' (need github.com or gitea.*)"
|
||||
fi
|
||||
|
||||
# ---- preflight ----
|
||||
cd "$REPO_ROOT"
|
||||
[[ -z "$(git status --porcelain)" ]] || die "git tree is dirty — commit or stash before starting"
|
||||
CURRENT_BRANCH="$(git branch --show-current)"
|
||||
[[ "$CURRENT_BRANCH" == "$TRUNK_BRANCH" ]] || die "not on $TRUNK_BRANCH (on '$CURRENT_BRANCH')"
|
||||
git fetch origin "$TRUNK_BRANCH" >/dev/null 2>&1 || die "git fetch failed"
|
||||
LOCAL_SHA="$(git rev-parse HEAD)"
|
||||
REMOTE_SHA="$(git rev-parse "origin/$TRUNK_BRANCH")"
|
||||
[[ "$LOCAL_SHA" == "$REMOTE_SHA" ]] || die "$TRUNK_BRANCH not up-to-date with origin (pull first)"
|
||||
command -v claude >/dev/null || die "claude CLI not on PATH"
|
||||
|
||||
# ---- banner ----
|
||||
log "ship-it run $RUN_ID"
|
||||
log " tracker: $TRACKER_CLI ($ORIGIN_URL)"
|
||||
log " trunk: $TRUNK_BRANCH @ ${LOCAL_SHA:0:8}"
|
||||
log " log: $LOG_FILE"
|
||||
log " config: max_iter=$MAX_ITERATIONS, max_fail=$MAX_CONSECUTIVE_FAILURES, timeout=$ITERATION_TIMEOUT"
|
||||
log ""
|
||||
|
||||
ITERATE_PROMPT_TEMPLATE="$(cat "$SCRIPT_DIR/iterate.md")"
|
||||
SHIPPED=()
|
||||
FAILED=()
|
||||
NEEDS_DECISION=()
|
||||
CONSECUTIVE_FAILURES=0
|
||||
ITERATION=0
|
||||
|
||||
# ---- main loop ----
|
||||
while (( ITERATION < MAX_ITERATIONS )); do
|
||||
(( INTERRUPTED )) && break
|
||||
ITERATION=$((ITERATION + 1))
|
||||
|
||||
READY_JSON="$(list_ready_issues 2>/dev/null || echo '[]')"
|
||||
READY_COUNT="$(echo "$READY_JSON" | jq 'length' 2>/dev/null || echo 0)"
|
||||
|
||||
if (( READY_COUNT == 0 )); then
|
||||
log "backlog empty — stopping"
|
||||
break
|
||||
fi
|
||||
|
||||
ISSUE_NUM="$(echo "$READY_JSON" | jq -r '.[0].number // .[0].index')"
|
||||
ISSUE_TITLE="$(echo "$READY_JSON" | jq -r '.[0].title')"
|
||||
|
||||
log "─────────────────────────────────────────────────────────────"
|
||||
log "iter $ITERATION | #$ISSUE_NUM \"$ISSUE_TITLE\" ($READY_COUNT ready) → starting"
|
||||
|
||||
ITER_LOG="$LOG_DIR/iter-$RUN_ID-$ISSUE_NUM.log"
|
||||
PROMPT="$ITERATE_PROMPT_TEMPLATE
|
||||
|
||||
## Variables for this iteration
|
||||
- ISSUE_NUMBER=$ISSUE_NUM
|
||||
- TRACKER_CLI=$TRACKER_CLI
|
||||
- TRUNK_BRANCH=$TRUNK_BRANCH
|
||||
- REPO_ROOT=$REPO_ROOT
|
||||
|
||||
Begin."
|
||||
|
||||
ITER_START="$(date +%s)"
|
||||
set +e
|
||||
timeout "$ITERATION_TIMEOUT" claude -p "$PROMPT" \
|
||||
--allowed-tools "Bash,Edit,Write,Read,Grep,Glob,WebFetch" \
|
||||
--output-format text \
|
||||
>"$ITER_LOG" 2>&1
|
||||
CLAUDE_EXIT=$?
|
||||
set -e
|
||||
ITER_END="$(date +%s)"
|
||||
ITER_DURATION=$((ITER_END - ITER_START))
|
||||
|
||||
RESULT_LINE="$(grep -E '^ITERATION_RESULT:' "$ITER_LOG" | tail -1 || true)"
|
||||
STATUS="$(echo "$RESULT_LINE" | sed -n 's/.*status=\([^ ]*\).*/\1/p')"
|
||||
PR_FIELD="$(echo "$RESULT_LINE" | sed -n 's/.*pr=\([^ ]*\).*/\1/p')"
|
||||
REASON="$(echo "$RESULT_LINE" | sed -n 's/.*reason=\(.*\)/\1/p')"
|
||||
|
||||
if (( CLAUDE_EXIT == 124 )); then
|
||||
STATUS="failed"
|
||||
REASON="timeout after $ITERATION_TIMEOUT"
|
||||
fi
|
||||
|
||||
case "$STATUS" in
|
||||
shipped)
|
||||
log "iter $ITERATION | #$ISSUE_NUM ✓ shipped → PR $PR_FIELD (${ITER_DURATION}s)"
|
||||
SHIPPED+=("#$ISSUE_NUM→$PR_FIELD")
|
||||
CONSECUTIVE_FAILURES=0
|
||||
;;
|
||||
failed)
|
||||
log "iter $ITERATION | #$ISSUE_NUM ✗ failed: $REASON (${ITER_DURATION}s, see $ITER_LOG)"
|
||||
FAILED+=("#$ISSUE_NUM ($REASON)")
|
||||
CONSECUTIVE_FAILURES=$((CONSECUTIVE_FAILURES + 1))
|
||||
;;
|
||||
needs-decision)
|
||||
log "iter $ITERATION | #$ISSUE_NUM ? needs-decision: $REASON (${ITER_DURATION}s)"
|
||||
NEEDS_DECISION+=("#$ISSUE_NUM ($REASON)")
|
||||
CONSECUTIVE_FAILURES=0
|
||||
;;
|
||||
*)
|
||||
log "iter $ITERATION | #$ISSUE_NUM ! unknown outcome (claude exit=$CLAUDE_EXIT, ${ITER_DURATION}s) — see $ITER_LOG"
|
||||
FAILED+=("#$ISSUE_NUM (unknown outcome)")
|
||||
CONSECUTIVE_FAILURES=$((CONSECUTIVE_FAILURES + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
if (( CONSECUTIVE_FAILURES >= MAX_CONSECUTIVE_FAILURES )); then
|
||||
log "$MAX_CONSECUTIVE_FAILURES consecutive failures — stopping for human review"
|
||||
break
|
||||
fi
|
||||
|
||||
# back to trunk for next iteration
|
||||
if [[ "$(git branch --show-current)" != "$TRUNK_BRANCH" ]]; then
|
||||
git switch "$TRUNK_BRANCH" >/dev/null 2>&1 || log " warn: could not return to $TRUNK_BRANCH"
|
||||
fi
|
||||
git pull --ff-only origin "$TRUNK_BRANCH" >/dev/null 2>&1 || log " warn: could not fast-forward $TRUNK_BRANCH"
|
||||
done
|
||||
|
||||
# ---- summary ----
|
||||
log ""
|
||||
log "==== ship-it summary ===="
|
||||
log "iterations: $ITERATION"
|
||||
log "shipped: ${#SHIPPED[@]} ${SHIPPED[*]:-}"
|
||||
log "failed: ${#FAILED[@]} ${FAILED[*]:-}"
|
||||
log "needs-decision: ${#NEEDS_DECISION[@]} ${NEEDS_DECISION[*]:-}"
|
||||
log "log: $LOG_FILE"
|
||||
|
||||
if (( INTERRUPTED )); then
|
||||
log "stop reason: user-interrupt"
|
||||
exit 130
|
||||
elif (( CONSECUTIVE_FAILURES >= MAX_CONSECUTIVE_FAILURES )); then
|
||||
log "stop reason: consecutive-failures"
|
||||
exit 1
|
||||
else
|
||||
log "stop reason: backlog-empty"
|
||||
exit 0
|
||||
fi
|
||||
Reference in New Issue
Block a user