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>
190 lines
6.7 KiB
Bash
190 lines
6.7 KiB
Bash
#!/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
|