#!/bin/bash # ============================================================================== # run-pipeline.sh — CalcPad Epic Development Pipeline # ============================================================================== # Runs dev → review → commit for each story. Auto-discovers stories and skips # already-committed ones via git history. Supports parallel execution via # git worktrees. # # Usage: # ./run-pipeline.sh # All pending stories # ./run-pipeline.sh --phase1 # Phase 1 stories (Epics 1-5, 10) # ./run-pipeline.sh --phase1 --epic 1 # Only Epic 1 stories # ./run-pipeline.sh --parallel 3 # 3 stories in parallel # ./run-pipeline.sh --from 3-1 # Start from story 3-1 # ./run-pipeline.sh --dry-run # Show plan only # # Options: # --phase1 Phase 1: Engine + MVP (Epics 1-5, 10) # --phase2 Phase 2: Power features (Epics 6, 7.1-7.6, 8.1-8.6, 9.1-9.3) # --phase3 Phase 3: System integration (Epics 7.7-7.14, 8.7-8.10, 9.4-9.8, 11) # --phase4 Phase 4: Ecosystem (Epics 12, 13, 14, 15, 16) # --parallel Run N stories in parallel via git worktrees (default: 1) # --epic Filter to epic N (e.g. --epic 1) # --from Start from story matching prefix (e.g. --from 3-1) # --stories Explicit story paths (skip auto-discovery) # --no-review Skip code-review phase # --force Ignore git-based skip detection # --cooldown Pause between stories (default: 10, ignored in parallel) # --dry-run Show execution plan without running # --log-dir Directory for logs (default: .logs/epic-pipeline/) # --help Show this help message # ============================================================================== set -euo pipefail # ── Portable relpath (macOS lacks realpath --relative-to) ──────────────────── relpath() { python3 -c "import os; print(os.path.relpath('$1', '${2:-$PWD}'))" } # ── Configuration ───────────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$SCRIPT_DIR" STORY_DIR="${PROJECT_ROOT}/_bmad-output/implementation-artifacts" LOG_DIR="${PROJECT_ROOT}/.logs/epic-pipeline" WORKTREE_BASE="${PROJECT_ROOT}/.worktrees" COOLDOWN=10 PARALLEL=1 DRY_RUN=false NO_REVIEW=false FORCE=false MERGE_ONLY=false PHASE_FILTER="" EPIC_FILTER="" FROM_PREFIX="" STORIES=() TIMESTAMP=$(date +"%Y%m%d-%H%M%S") # Phase 1: Engine + MVP (Epics 1-5, 10) PHASE1_STORIES=( 1-1-lexer-and-tokenizer 1-2-parser-and-ast 1-3-interpreter-evaluator 1-4-arbitrary-precision-arithmetic 1-5-c-ffi-layer-for-swift 1-6-wasm-bindings-for-web 1-7-error-handling-and-graceful-degradation 1-8-sheet-context 2-1-unit-registry-and-base-conversion 2-2-si-prefix-support 2-3-css-and-screen-units 2-4-data-units-binary-vs-decimal 2-5-natural-language-unit-expressions 2-6-custom-user-defined-units 3-1-fiat-currency-provider 3-2-cryptocurrency-provider 3-3-historical-rates 3-4-currency-symbol-and-code-recognition 3-5-multi-currency-arithmetic 4-1-date-math 4-2-time-math 4-3-time-zone-conversions 4-4-business-day-calculations 4-5-unix-timestamp-conversions 4-6-relative-time-expressions 5-1-variable-declaration-and-usage 5-2-line-references 5-3-previous-line-reference 5-4-section-aggregators 5-5-subtotals-and-grand-total 5-6-autocomplete 10-1-headers-comments-and-labels 10-2-click-to-copy-answer 10-3-drag-to-resize-columns 10-4-answer-column-formatting 10-5-line-numbers 10-6-find-and-replace ) # Phase 2: Power features (Epics 6, 7.1-7.6, 8.1-8.6, 9.1-9.3) PHASE2_STORIES=() # Phase 3: System integration (Epics 7.7-7.14, 8.7-8.10, 9.4-9.8, 11) PHASE3_STORIES=() # Phase 4: Ecosystem (Epics 12, 13, 14, 15, 16) PHASE4_STORIES=() # ── Colors ──────────────────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' # ── Logging ─────────────────────────────────────────────────────────────────── log() { local level="$1"; shift local ts; ts=$(date +"%H:%M:%S") case "$level" in INFO) echo -e "${BLUE}[$ts]${NC} ${BOLD}INFO${NC} $*" ;; OK) echo -e "${GREEN}[$ts]${NC} ${GREEN}${BOLD}OK${NC} $*" ;; WARN) echo -e "${YELLOW}[$ts]${NC} ${YELLOW}${BOLD}WARN${NC} $*" ;; ERROR) echo -e "${RED}[$ts]${NC} ${RED}${BOLD}ERROR${NC} $*" ;; STEP) echo -e "${CYAN}[$ts]${NC} ${CYAN}${BOLD}STEP${NC} $*" ;; esac echo "[$ts] $level $*" >> "${LOG_DIR}/run-${TIMESTAMP}.log" 2>/dev/null || true } separator() { echo "" echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}" echo -e "${BOLD} $1${NC}" echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}" echo "" } usage() { head -n 32 "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//' exit 0 } # ── Prerequisites ───────────────────────────────────────────────────────────── check_prerequisites() { if ! command -v claude &> /dev/null; then log ERROR "claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code" exit 1 fi if [ ! -d "${PROJECT_ROOT}/_bmad" ]; then log ERROR "Not in a valid BMAD project root (_bmad/ not found)" exit 1 fi if [ ! -d "$STORY_DIR" ]; then log ERROR "Story directory not found: $STORY_DIR" exit 1 fi # Parallel mode requires at least one commit on main (for branching) if [ "$PARALLEL" -gt 1 ]; then if ! git -C "$PROJECT_ROOT" rev-parse HEAD &>/dev/null; then log ERROR "No commits on main. Parallel mode needs an initial commit." log ERROR "Run: git add -A && git commit -m 'initial commit'" exit 1 fi fi } # ── Git-based completion detection ──────────────────────────────────────────── is_story_committed() { local story_path="$1" if git log --all --grep="Story: $story_path" --oneline -1 2>/dev/null | grep -q .; then return 0 fi return 1 } # ── Auto-discover stories ──────────────────────────────────────────────────── discover_stories() { local all_stories=() while IFS= read -r story; do local rel_path rel_path=$(relpath "$story" "$PROJECT_ROOT") all_stories+=("$rel_path") done < <(find "$STORY_DIR" -maxdepth 1 -name "[0-9]*-[0-9]*-*.md" -type f | sort -V) if [ ${#all_stories[@]} -eq 0 ]; then log ERROR "No story files found in $STORY_DIR" exit 1 fi # Apply phase filter if [ -n "$PHASE_FILTER" ]; then local phase_stories_ref="PHASE${PHASE_FILTER}_STORIES[@]" local phase_stories=("${!phase_stories_ref}") if [ ${#phase_stories[@]} -eq 0 ]; then log ERROR "No stories defined for phase $PHASE_FILTER" exit 1 fi local filtered=() for s in "${all_stories[@]}"; do local base; base=$(basename "$s" .md) for ps in "${phase_stories[@]}"; do if [ "$base" = "$ps" ]; then filtered+=("$s") break fi done done all_stories=("${filtered[@]}") if [ ${#all_stories[@]} -eq 0 ]; then log ERROR "No phase $PHASE_FILTER stories found on disk" exit 1 fi fi # Apply --epic filter if [ -n "$EPIC_FILTER" ]; then local filtered=() for s in "${all_stories[@]}"; do local base; base=$(basename "$s") if [[ "$base" =~ ^${EPIC_FILTER}- ]]; then filtered+=("$s") fi done all_stories=("${filtered[@]}") if [ ${#all_stories[@]} -eq 0 ]; then log ERROR "No stories found for epic $EPIC_FILTER" exit 1 fi fi # Apply --from filter if [ -n "$FROM_PREFIX" ]; then local filtered=() local found=false for s in "${all_stories[@]}"; do local base; base=$(basename "$s" .md) if [ "$found" = true ] || [[ "$base" =~ ^${FROM_PREFIX} ]]; then found=true filtered+=("$s") fi done if [ "$found" = false ]; then log ERROR "No story matching prefix '$FROM_PREFIX' found" exit 1 fi all_stories=("${filtered[@]}") fi STORIES=("${all_stories[@]}") } # ── Filter out already-committed stories ────────────────────────────────────── filter_committed() { if [ "$FORCE" = true ]; then log INFO "Force mode: skipping git-based filtering" return fi local pending=() local skipped=0 for story in "${STORIES[@]}"; do if is_story_committed "$story"; then ((skipped++)) || true else pending+=("$story") fi done if [ $skipped -gt 0 ]; then log INFO "Skipped $skipped already-committed stories" fi if [ ${#pending[@]} -eq 0 ]; then log OK "All ${#STORIES[@]} stories already committed. Nothing to do." exit 0 fi STORIES=("${pending[@]}") } # ── Extract story title from file ───────────────────────────────────────────── story_title() { local story_path="$1" local root="${2:-$PROJECT_ROOT}" grep -m1 '^# ' "${root}/${story_path}" 2>/dev/null \ | sed 's/^# Story [A-Z0-9.]*: //' \ | sed 's/^# //' \ || basename "$story_path" .md } # ============================================================================== # Sequential mode (--parallel 1, default) # ============================================================================== # ── Run Claude with clean context ───────────────────────────────────────────── run_claude() { local description="$1" local prompt="$2" local log_file="$3" local work_dir="${4:-$PROJECT_ROOT}" if [ "$DRY_RUN" = true ]; then log INFO "[DRY RUN] Would execute: claude -p \"${prompt:0:80}...\"" return 0 fi log STEP "$description" log INFO "Log: $log_file" if (cd "$work_dir" && env -u CLAUDECODE claude --dangerously-skip-permissions -p "$prompt" < /dev/null > "$log_file" 2>&1); then if grep -q "Unknown skill" "$log_file"; then log ERROR "$description — skill not found (check skill name)" log ERROR "Log contains: $(grep 'Unknown skill' "$log_file" | head -1)" return 1 fi log OK "$description — completed" return 0 else local exit_code=$? log ERROR "$description — failed (exit code: $exit_code)" log ERROR "Check log: $log_file" return $exit_code fi } phase_dev() { local story_path="$1" local work_dir="${2:-$PROJECT_ROOT}" local story_name; story_name=$(basename "$story_path" .md) local dev_log="${LOG_DIR}/${TIMESTAMP}-dev-${story_name}.log" run_claude \ "Developing: $story_name" \ "You are running in a fully autonomous pipeline — do NOT ask questions, do NOT wait for input. If the story file is missing task breakdowns, create them yourself and proceed immediately. Always choose option 1 (do it yourself). /bmad-dev-story $story_path" \ "$dev_log" \ "$work_dir" || return 1 if [ "$DRY_RUN" = false ]; then if (cd "$work_dir" && git diff --quiet && git diff --staged --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]); then log ERROR "Dev produced no file changes for: $story_name" if grep -q "Unknown skill" "$dev_log"; then log ERROR "Cause: Unknown skill in log" fi return 1 fi log OK "Dev produced changes for: $story_name" fi } phase_review() { local story_path="$1" local work_dir="${2:-$PROJECT_ROOT}" local story_name; story_name=$(basename "$story_path" .md) local review_log="${LOG_DIR}/${TIMESTAMP}-review-${story_name}.log" run_claude \ "Code review: $story_name" \ "/bmad-code-review $story_path" \ "$review_log" \ "$work_dir" || return 1 log OK "Code review complete: $story_name" } phase_commit() { local story_path="$1" local work_dir="${2:-$PROJECT_ROOT}" local story_name; story_name=$(basename "$story_path" .md) if [ "$DRY_RUN" = true ]; then log INFO "[DRY RUN] Would commit changes for: $story_name" return 0 fi if (cd "$work_dir" && git diff --quiet && git diff --staged --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]); then log WARN "No changes to commit for: $story_name" return 0 fi local title title=$(story_title "$story_path" "$work_dir") (cd "$work_dir" && git add -A && git commit -m "$(cat < 1) — uses git worktrees # ============================================================================== # Track results per story: .worktrees/.result = 0 (ok) or 1 (fail) RESULTS_DIR="" # Cleanup worktrees on exit (keeps unmerged branches safe) cleanup_worktrees() { if [ -d "$WORKTREE_BASE" ]; then log INFO "Cleaning up worktrees..." for wt in "$WORKTREE_BASE"/*/; do [ -d "$wt" ] || continue local name; name=$(basename "$wt") # Remove worktree directory only (branch stays for manual merge) git -C "$PROJECT_ROOT" worktree remove --force "$wt" 2>/dev/null || true done rmdir "$WORKTREE_BASE" 2>/dev/null || true fi # Show unmerged branches if any remain local unmerged unmerged=$(git -C "$PROJECT_ROOT" branch --list 'epic/*' --no-merged main 2>/dev/null || true) if [ -n "$unmerged" ]; then log WARN "Unmerged branches (work preserved):" echo "$unmerged" | while read -r b; do echo " $b"; done log INFO "Re-run to merge, or: git merge " fi if [ -n "$RESULTS_DIR" ] && [ -d "$RESULTS_DIR" ]; then rm -rf "$RESULTS_DIR" fi } # Process one story in an isolated worktree process_story_worktree() { local story="$1" local story_name; story_name=$(basename "$story" .md) local branch="epic/${story_name}" local wt_path="${WORKTREE_BASE}/${story_name}" # Create branch + worktree if [ "$DRY_RUN" = true ]; then log INFO "[DRY RUN] [parallel] Would create worktree for: $story_name" log INFO "[DRY RUN] [parallel] Would develop: $story_name" [ "$NO_REVIEW" = false ] && log INFO "[DRY RUN] [parallel] Would review: $story_name" log INFO "[DRY RUN] [parallel] Would commit: $story_name" echo "0" > "${RESULTS_DIR}/${story_name}.result" return 0 fi log STEP "Creating worktree: $story_name → $branch" # Reset branch to main if it already exists, or create fresh if git -C "$PROJECT_ROOT" rev-parse --verify "$branch" &>/dev/null; then git -C "$PROJECT_ROOT" branch -f "$branch" main 2>/dev/null || true log INFO "Reset existing branch: $branch" else if ! git -C "$PROJECT_ROOT" branch "$branch" main 2>&1; then log ERROR "[parallel] Failed to create branch: $branch" echo "1" > "${RESULTS_DIR}/${story_name}.result" return 1 fi fi # Remove stale worktree if path exists if [ -d "$wt_path" ]; then git -C "$PROJECT_ROOT" worktree remove --force "$wt_path" 2>/dev/null || rm -rf "$wt_path" fi if ! git -C "$PROJECT_ROOT" worktree add "$wt_path" "$branch" 2>&1; then log ERROR "[parallel] Failed to create worktree: $wt_path" echo "1" > "${RESULTS_DIR}/${story_name}.result" return 1 fi # Symlink .claude/ so skills are available in worktree if [ -d "${PROJECT_ROOT}/.claude" ] && [ ! -e "${wt_path}/.claude" ]; then ln -s "${PROJECT_ROOT}/.claude" "${wt_path}/.claude" # Exclude symlink from git so it doesn't get committed echo ".claude" >> "${wt_path}/.git/info/exclude" fi # Dev if ! phase_dev "$story" "$wt_path"; then log ERROR "[parallel] Dev failed: $story_name" echo "1" > "${RESULTS_DIR}/${story_name}.result" return 1 fi # Review if [ "$NO_REVIEW" = false ]; then phase_review "$story" "$wt_path" || log WARN "[parallel] Review had issues: $story_name" fi # Commit in worktree branch phase_commit "$story" "$wt_path" || log WARN "[parallel] Commit failed: $story_name" echo "0" > "${RESULTS_DIR}/${story_name}.result" log OK "[parallel] Story complete: $story_name (branch: $branch)" } # Merge completed branches back to main in order merge_branches() { local merged=0 merge_failed=0 separator "Merging branches into main" cd "$PROJECT_ROOT" for story in "${STORIES[@]}"; do local story_name; story_name=$(basename "$story" .md) local branch="epic/${story_name}" local result_file="${RESULTS_DIR}/${story_name}.result" # Skip failed stories if [ -f "$result_file" ] && [ "$(cat "$result_file")" != "0" ]; then log WARN "Skipping merge (dev failed): $story_name" continue fi # Skip if branch has no new commits if ! git rev-parse --verify "$branch" &>/dev/null; then continue fi local title title=$(story_title "$story") log STEP "Merging: $branch → main" if git merge --no-ff "$branch" -m "$(cat </dev/null; then git rm -f .claude &>/dev/null && git commit --amend --no-edit &>/dev/null || true fi log OK "Merged: $story_name" ((merged++)) || true else log ERROR "Merge conflict: $story_name — aborting merge" git merge --abort 2>/dev/null || true ((merge_failed++)) || true log WARN "Remaining stories not merged. Resolve conflict and re-run." break fi # Cleanup branch + worktree local wt_path="${WORKTREE_BASE}/${story_name}" git worktree remove --force "$wt_path" 2>/dev/null || true git branch -d "$branch" 2>/dev/null || true done log INFO "Merged: $merged, Failed: $merge_failed" return $merge_failed } run_parallel() { local total=${#STORIES[@]} RESULTS_DIR=$(mktemp -d) mkdir -p "$WORKTREE_BASE" # Register cleanup trap trap cleanup_worktrees EXIT log INFO "Parallel mode: $PARALLEL workers, $total stories" if [ "$DRY_RUN" = true ]; then for story in "${STORIES[@]}"; do process_story_worktree "$story" done print_summary "$total" 0 "$total" return 0 fi # Launch workers with semaphore using a slot-based approach local pids=() local slots=() # Initialize slots for ((s=0; s/dev/null; then wait "${slots[$s]}" 2>/dev/null || true slots[$s]=0 found_slot=true break fi done if [ "$found_slot" = false ]; then sleep 1 fi done log INFO "Launching [$num/$total]: $story_name (slot $s)" process_story_worktree "$story" & slots[$s]=$! pids+=($!) done # Wait for all remaining local remaining=0 for ((s=0; s/dev/null || true done # Count results local succeeded=0 failed=0 for story in "${STORIES[@]}"; do local story_name; story_name=$(basename "$story" .md) local result_file="${RESULTS_DIR}/${story_name}.result" if [ -f "$result_file" ] && [ "$(cat "$result_file")" = "0" ]; then ((succeeded++)) || true else ((failed++)) || true fi done log OK "All workers done. Succeeded: $succeeded, Failed: $failed" # Merge branches sequentially into main if [ $succeeded -gt 0 ]; then merge_branches fi print_summary $succeeded $failed $total } # ============================================================================== # Common # ============================================================================== print_summary() { local succeeded=$1 failed=$2 total=$3 separator "Pipeline Complete" echo -e " ${GREEN}Succeeded:${NC} $succeeded" echo -e " ${RED}Failed:${NC} $failed" echo -e " ${BOLD}Total:${NC} $total" [ "$PARALLEL" -gt 1 ] && echo -e " ${CYAN}Workers:${NC} $PARALLEL" echo "" log INFO "Logs: $LOG_DIR/" [ "$failed" -gt 0 ] && return 1 return 0 } # ── Parse Arguments ─────────────────────────────────────────────────────────── parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --phase1) PHASE_FILTER="1" shift ;; --phase2) PHASE_FILTER="2" shift ;; --phase3) PHASE_FILTER="3" shift ;; --phase4) PHASE_FILTER="4" shift ;; --parallel) PARALLEL="$2" if ! [[ "$PARALLEL" =~ ^[0-9]+$ ]] || [ "$PARALLEL" -lt 1 ]; then log ERROR "--parallel must be a positive integer" exit 1 fi shift 2 ;; --epic) EPIC_FILTER="$2" shift 2 ;; --from) FROM_PREFIX="$2" shift 2 ;; --stories) shift while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do STORIES+=("$1") shift done ;; --no-review) NO_REVIEW=true shift ;; --force) FORCE=true shift ;; --merge-only) MERGE_ONLY=true shift ;; --cooldown) COOLDOWN="$2" shift 2 ;; --dry-run) DRY_RUN=true shift ;; --log-dir) LOG_DIR="$2" shift 2 ;; --help|-h) usage ;; *) log ERROR "Unknown option: $1" usage ;; esac done } # ── Main ────────────────────────────────────────────────────────────────────── main() { parse_args "$@" mkdir -p "$LOG_DIR" check_prerequisites cd "$PROJECT_ROOT" # Merge-only mode: merge existing epic/* branches into main if [ "$MERGE_ONLY" = true ]; then separator "Merge-only mode" # Discover stories from existing epic/* branches local branches branches=$(git branch --list 'epic/*' 2>/dev/null | sed 's/^[* ]*//') if [ -z "$branches" ]; then log OK "No epic/* branches to merge." exit 0 fi # Build STORIES array from branches STORIES=() while IFS= read -r branch; do local story_name="${branch#epic/}" local story_path="_bmad-output/implementation-artifacts/${story_name}.md" STORIES+=("$story_path") echo -e " ${BOLD}-${NC} $story_name" done <<< "$branches" echo "" RESULTS_DIR=$(mktemp -d) # Mark all as succeeded (they were already developed) for story in "${STORIES[@]}"; do local sn; sn=$(basename "$story" .md) echo "0" > "${RESULTS_DIR}/${sn}.result" done merge_branches rm -rf "$RESULTS_DIR" # Cleanup merged branches local remaining remaining=$(git branch --list 'epic/*' 2>/dev/null | wc -l) log INFO "Branches remaining: $remaining" exit 0 fi # Discover stories if not provided explicitly if [ ${#STORIES[@]} -eq 0 ]; then discover_stories fi # Filter out already-committed local total_before=${#STORIES[@]} filter_committed local total_pending=${#STORIES[@]} # Show execution plan local mode_label="sequential" [ "$PARALLEL" -gt 1 ] && mode_label="parallel ($PARALLEL workers)" separator "Pipeline: $total_pending pending / $total_before total [$mode_label]" log INFO "Timestamp: $TIMESTAMP" log INFO "Mode: $mode_label" log INFO "Review: $([ "$NO_REVIEW" = true ] && echo 'off' || echo 'on')" [ "$PARALLEL" -eq 1 ] && log INFO "Cooldown: ${COOLDOWN}s" log INFO "Dry Run: $DRY_RUN" [ -n "$EPIC_FILTER" ] && log INFO "Epic: $EPIC_FILTER" [ -n "$FROM_PREFIX" ] && log INFO "From: $FROM_PREFIX" echo "" for i in "${!STORIES[@]}"; do echo -e " ${BOLD}$((i + 1)).${NC} $(basename "${STORIES[$i]}" .md)" done echo "" # Run if [ "$PARALLEL" -gt 1 ]; then run_parallel else run_sequential fi local exit_code=$? exit $exit_code } main "$@"