#!/bin/bash # ============================================================================== # run-epic.sh — Automated Epic Development Pipeline # ============================================================================== # Orchestrates BMAD workflows (create-story → dev-story → code-review) # for each story in an epic. Each step runs in a clean Claude context. # # Usage: # ./scripts/run-epic.sh --epic [options] # ./scripts/run-epic.sh --stories ... [options] # # Examples: # # Process entire epic (creates stories first, then develops them) # ./scripts/run-epic.sh --epic docs/03-epics/04-banking/EPIC-001-transfers-payments/epic.md # # # Process specific stories only (skip story creation) # ./scripts/run-epic.sh --stories \ # docs/03-epics/04-banking/EPIC-001-transfers-payments/features/FEAT-001-1-p2p-transfers/stories/story-001.md \ # docs/03-epics/04-banking/EPIC-001-transfers-payments/features/FEAT-001-2-wire-transfers/stories/story-001.md # # # Dry run (show what would be executed) # ./scripts/run-epic.sh --epic docs/03-epics/04-banking/EPIC-001-transfers-payments/epic.md --dry-run # # # Skip already-completed stories # ./scripts/run-epic.sh --epic docs/03-epics/04-banking/EPIC-001-transfers-payments/epic.md --skip-completed # # # Start from a specific phase (useful for resuming) # ./scripts/run-epic.sh --stories story1.md --phase dev # # Options: # --epic Path to epic.md file (will discover/create stories) # --stories Explicit story paths (skip story creation phase) # --phase Start from phase: create | dev | review (default: create) # --skip-completed Skip stories with status: done/completed in their YAML frontmatter # --cooldown Pause between stories (default: 120) # --dry-run Show execution plan without running # --no-review Skip code-review phase # --log-dir Directory for logs (default: .logs/epic-pipeline/) # --help Show this help message # ============================================================================== set -euo pipefail # ── Portable Helper Functions ───────────────────────────────────────────────── # macOS-compatible replacement for `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" LOG_DIR="${PROJECT_ROOT}/.logs/epic-pipeline" COOLDOWN=10 DRY_RUN=false SKIP_COMPLETED=false NO_REVIEW=false START_PHASE="create" EPIC_PATH="" STORIES=() TIMESTAMP=$(date +"%Y%m%d-%H%M%S") # ── Colors ───────────────────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # No Color # ── Functions ────────────────────────────────────────────────────────────────── usage() { head -n 38 "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//' exit 0 } log() { local level="$1" shift local msg="$*" local ts ts=$(date +"%H:%M:%S") case "$level" in INFO) echo -e "${BLUE}[$ts]${NC} ${BOLD}INFO${NC} $msg" ;; OK) echo -e "${GREEN}[$ts]${NC} ${GREEN}${BOLD}OK${NC} $msg" ;; WARN) echo -e "${YELLOW}[$ts]${NC} ${YELLOW}${BOLD}WARN${NC} $msg" ;; ERROR) echo -e "${RED}[$ts]${NC} ${RED}${BOLD}ERROR${NC} $msg" ;; STEP) echo -e "${CYAN}[$ts]${NC} ${CYAN}${BOLD}STEP${NC} $msg" ;; esac # Also append to log file echo "[$ts] $level $msg" >> "${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 "" } 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 } # Check if a story is completed by reading its YAML frontmatter is_story_completed() { local story_path="$1" if [ ! -f "$story_path" ]; then return 1 # Not completed (doesn't exist yet) fi # Check for status: done, completed, or delivered in frontmatter if head -50 "$story_path" | grep -qiE '^status:\s*(done|completed|delivered)'; then return 0 # Completed fi return 1 # Not completed } # Extract story titles from epic.md # Parses headers like: ### Story 1.1: Lexer & Tokenizer extract_features_from_epic() { local epic_file="$1" grep -E '^### Story [0-9]+\.[0-9]+:' "$epic_file" \ | sed 's/^### Story [0-9]*\.[0-9]*: //' || true } # Convert feature name to story path convention feature_to_slug() { echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' } # Discover stories from _bmad-output/implementation-artifacts/ STORY_DIR="${PROJECT_ROOT}/_bmad-output/implementation-artifacts" discover_stories() { log INFO "Discovering stories in: $STORY_DIR" # Find story markdown files matching pattern like 1-1-lexer-tokenizer.md local found_stories=() while IFS= read -r -d '' story; do found_stories+=("$story") done < <(find "$STORY_DIR" -maxdepth 1 -name "[0-9]*-[0-9]*-*.md" -print0 2>/dev/null | sort -z) if [ ${#found_stories[@]} -eq 0 ]; then log WARN "No stories found. Stories will be created in the create phase." return 0 fi log OK "Found ${#found_stories[@]} stories" for s in "${found_stories[@]}"; do local rel_path rel_path=$(relpath "$s" "$PROJECT_ROOT") echo " → $rel_path" STORIES+=("$rel_path") done } # Generate simulated stories for dry-run mode (when stories don't exist yet) simulate_stories_from_epic() { local epic_rel epic_rel=$(relpath "$EPIC_PATH" "$PROJECT_ROOT") log INFO "Simulating stories from epic features..." local feature_num=0 while IFS= read -r feature_name; do [ -z "$feature_name" ] && continue ((feature_num++)) || true local slug slug=$(feature_to_slug "$feature_name") local simulated_path="_bmad-output/implementation-artifacts/${feature_num}-1-${slug}.md" STORIES+=("$simulated_path") log INFO " [simulated] $simulated_path" done < <(extract_features_from_epic "$EPIC_PATH") if [ ${#STORIES[@]} -eq 0 ]; then log WARN "Could not extract features from epic for simulation" else log OK "Simulated ${#STORIES[@]} stories from ${feature_num} features" fi } # Run a single Claude command with clean context run_claude() { local description="$1" local prompt="$2" local log_file="$3" 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" # Each invocation = fresh context (no --continue) # Unset CLAUDECODE to allow launching from within a Claude Code session # Redirect stdin from /dev/null to prevent consuming the caller's stdin (e.g., while-read loops) if env -u CLAUDECODE claude --dangerously-skip-permissions -p "$prompt" < /dev/null > "$log_file" 2>&1; then log OK "$description — completed successfully" 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: Create Stories ────────────────────────────────────────────────────── phase_create_stories() { if [ -z "$EPIC_PATH" ]; then log WARN "No epic path provided, skipping story creation" return 0 fi separator "PHASE: Create Stories from Epic" local epic_rel epic_rel=$(relpath "$EPIC_PATH" "$PROJECT_ROOT") log INFO "Epic: $epic_rel" # Create stories one per feature using the BMAD create-story workflow # Extract story titles and create a story for each local feature_num=0 while IFS= read -r feature_name; do [ -z "$feature_name" ] && continue ((feature_num++)) || true local create_log="${LOG_DIR}/${TIMESTAMP}-create-story-${feature_num}.log" run_claude \ "Creating story for feature ${feature_num}: $feature_name" \ "/bmad-create-story -- Create the next story from the epic at '${epic_rel}'. Focus on Feature ${feature_num}: ${feature_name}. Read the epic file first, then create the story following BMAD structure." \ "$create_log" done < <(extract_features_from_epic "$EPIC_PATH") if [ $feature_num -eq 0 ]; then log ERROR "No features found in epic file" return 1 fi log OK "Requested creation of $feature_num stories" # Re-discover stories after creation (or simulate for dry-run) STORIES=() discover_stories # Also check BMAD default output locations if [ ${#STORIES[@]} -eq 0 ]; then log INFO "Checking alternative story locations..." # Check _bmad-output/implementation-artifacts/ (BMAD default) for search_dir in \ "$PROJECT_ROOT/_bmad-output/implementation-artifacts" \ "$PROJECT_ROOT/.docs/sprint-artifacts/stories" \ "$PROJECT_ROOT/docs/stories"; do if [ -d "$search_dir" ]; then while IFS= read -r -d '' story; do local rel_path rel_path=$(relpath "$story" "$PROJECT_ROOT") STORIES+=("$rel_path") done < <(find "$search_dir" -name "*.md" -print0 2>/dev/null | sort -z) fi done if [ ${#STORIES[@]} -gt 0 ]; then log OK "Found ${#STORIES[@]} stories in alternative locations" for s in "${STORIES[@]}"; do echo " → $s" done fi fi # In dry-run mode, stories won't actually exist — simulate from epic features if [ "$DRY_RUN" = true ] && [ ${#STORIES[@]} -eq 0 ]; then simulate_stories_from_epic fi } # ── Phase: Develop Story ─────────────────────────────────────────────────────── phase_dev_story() { local story_path="$1" local story_name story_name=$(basename "$story_path" .md) local story_dir story_dir=$(dirname "$story_path") separator "DEV: $story_name" if [ "$SKIP_COMPLETED" = true ] && is_story_completed "${PROJECT_ROOT}/$story_path"; then log WARN "Skipping (already completed): $story_path" return 0 fi # Step 1: Plan the implementation local plan_log="${LOG_DIR}/${TIMESTAMP}-plan-${story_name}.log" run_claude \ "Planning: $story_name" \ "Read the story at '$story_path' and all related documents (epic, feature). Enter plan mode and create a detailed implementation plan. Consider the existing codebase architecture, DDD boundaries, and enterprise standards. Output the plan then exit." \ "$plan_log" || return 1 # Step 2: Develop the story local dev_log="${LOG_DIR}/${TIMESTAMP}-dev-${story_name}.log" run_claude \ "Developing: $story_name" \ "/bmad-dev-story $story_path" \ "$dev_log" || return 1 log OK "Development complete: $story_name" } # ── Phase: Code Review ───────────────────────────────────────────────────────── phase_review_story() { local story_path="$1" local story_name story_name=$(basename "$story_path" .md) separator "REVIEW: $story_name" local review_log="${LOG_DIR}/${TIMESTAMP}-review-${story_name}.log" run_claude \ "Code review: $story_name" \ "/bmad-code-review $story_path" \ "$review_log" || return 1 log OK "Code review complete: $story_name" } # ── Phase: Git Commit ────────────────────────────────────────────────────────── phase_commit_story() { local story_path="$1" local story_name story_name=$(basename "$story_path" .md) separator "COMMIT: $story_name" if [ "$DRY_RUN" = true ]; then log INFO "[DRY RUN] Would commit changes for: $story_name" return 0 fi cd "$PROJECT_ROOT" # Check if there are any changes to commit if 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 # Extract story title from file (e.g. "# Story B002.1: Fee Configuration") local story_title story_title=$(grep -m1 '^# Story' "${PROJECT_ROOT}/${story_path}" 2>/dev/null \ | sed 's/^# Story [A-Z0-9.]*: //' \ || echo "$story_name") # Determine epic number from story path for commit message prefix local epic_ref epic_ref=$(echo "$story_path" | grep -oE 'EPIC-[0-9]+' | head -1 || echo "EPIC") # Stage all tracked and untracked changes (excluding gitignored) git add -A # Commit with story reference — no co-author per project preference git commit -m "$(cat <