571 lines
18 KiB
Bash
Executable File
571 lines
18 KiB
Bash
Executable File
#!/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 <epic-path> [options]
|
|
# ./scripts/run-epic.sh --stories <story1> <story2> ... [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> Path to epic.md file (will discover/create stories)
|
|
# --stories <paths...> Explicit story paths (skip story creation phase)
|
|
# --phase <phase> Start from phase: create | dev | review (default: create)
|
|
# --skip-completed Skip stories with status: done/completed in their YAML frontmatter
|
|
# --cooldown <seconds> Pause between stories (default: 120)
|
|
# --dry-run Show execution plan without running
|
|
# --no-review Skip code-review phase
|
|
# --log-dir <path> 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 <<EOF
|
|
feat(${epic_ref,,}): implement $story_title
|
|
|
|
Story: $story_path
|
|
EOF
|
|
)"
|
|
log OK "Committed: $story_name"
|
|
}
|
|
|
|
# ── Parse Arguments ────────────────────────────────────────────────────────────
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--epic)
|
|
EPIC_PATH="$2"
|
|
shift 2
|
|
;;
|
|
--stories)
|
|
shift
|
|
while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do
|
|
STORIES+=("$1")
|
|
shift
|
|
done
|
|
;;
|
|
--phase)
|
|
START_PHASE="$2"
|
|
shift 2
|
|
;;
|
|
--skip-completed)
|
|
SKIP_COMPLETED=true
|
|
shift
|
|
;;
|
|
--cooldown)
|
|
COOLDOWN="$2"
|
|
shift 2
|
|
;;
|
|
--dry-run)
|
|
DRY_RUN=true
|
|
shift
|
|
;;
|
|
--no-review)
|
|
NO_REVIEW=true
|
|
shift
|
|
;;
|
|
--log-dir)
|
|
LOG_DIR="$2"
|
|
shift 2
|
|
;;
|
|
--help|-h)
|
|
usage
|
|
;;
|
|
*)
|
|
log ERROR "Unknown option: $1"
|
|
usage
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Validate
|
|
if [ -z "$EPIC_PATH" ] && [ ${#STORIES[@]} -eq 0 ]; then
|
|
log ERROR "Must provide --epic or --stories"
|
|
usage
|
|
fi
|
|
|
|
# Resolve epic path to absolute if needed
|
|
if [ -n "$EPIC_PATH" ] && [[ ! "$EPIC_PATH" = /* ]]; then
|
|
EPIC_PATH="${PROJECT_ROOT}/${EPIC_PATH}"
|
|
fi
|
|
|
|
if [ -n "$EPIC_PATH" ] && [ ! -f "$EPIC_PATH" ]; then
|
|
log ERROR "Epic file not found: $EPIC_PATH"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
main() {
|
|
parse_args "$@"
|
|
|
|
# Setup
|
|
mkdir -p "$LOG_DIR"
|
|
check_prerequisites
|
|
cd "$PROJECT_ROOT"
|
|
|
|
separator "Epic Development Pipeline"
|
|
log INFO "Timestamp: $TIMESTAMP"
|
|
log INFO "Project Root: $PROJECT_ROOT"
|
|
log INFO "Log Directory: $LOG_DIR"
|
|
log INFO "Start Phase: $START_PHASE"
|
|
log INFO "Cooldown: ${COOLDOWN}s"
|
|
log INFO "Dry Run: $DRY_RUN"
|
|
log INFO "Skip Completed: $SKIP_COMPLETED"
|
|
log INFO "No Review: $NO_REVIEW"
|
|
|
|
if [ -n "$EPIC_PATH" ]; then
|
|
local epic_rel
|
|
epic_rel=$(relpath "$EPIC_PATH" "$PROJECT_ROOT")
|
|
log INFO "Epic: $epic_rel"
|
|
fi
|
|
|
|
echo ""
|
|
|
|
# Phase 1: Create stories (if starting from create phase)
|
|
if [ "$START_PHASE" = "create" ] && [ -n "$EPIC_PATH" ]; then
|
|
phase_create_stories
|
|
|
|
if [ ${#STORIES[@]} -eq 0 ]; then
|
|
log ERROR "No stories found after creation phase. Aborting."
|
|
exit 1
|
|
fi
|
|
elif [ "$START_PHASE" = "create" ] && [ ${#STORIES[@]} -gt 0 ]; then
|
|
log INFO "Stories provided explicitly, skipping creation phase"
|
|
fi
|
|
|
|
# Discover stories if epic provided but starting from dev/review
|
|
if [ ${#STORIES[@]} -eq 0 ] && [ -n "$EPIC_PATH" ]; then
|
|
discover_stories
|
|
if [ ${#STORIES[@]} -eq 0 ]; then
|
|
log ERROR "No stories found to process"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Summary
|
|
separator "Execution Plan: ${#STORIES[@]} stories"
|
|
for i in "${!STORIES[@]}"; do
|
|
local num=$((i + 1))
|
|
echo -e " ${BOLD}${num}.${NC} ${STORIES[$i]}"
|
|
done
|
|
echo ""
|
|
|
|
# Track results
|
|
local total=${#STORIES[@]}
|
|
local succeeded=0
|
|
local failed=0
|
|
local skipped=0
|
|
|
|
# Phase 2 & 3: Dev + Review each story
|
|
for i in "${!STORIES[@]}"; do
|
|
local story="${STORIES[$i]}"
|
|
local num=$((i + 1))
|
|
|
|
separator "Story $num/$total: $(basename "$story" .md)"
|
|
|
|
# Dev phase
|
|
if [[ "$START_PHASE" = "create" || "$START_PHASE" = "dev" ]]; then
|
|
if phase_dev_story "$story"; then
|
|
: # continue to review
|
|
else
|
|
log ERROR "Dev failed for: $story"
|
|
((failed++)) || true
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
# Review phase
|
|
if [ "$NO_REVIEW" = false ] && [[ "$START_PHASE" != "review" || true ]]; then
|
|
if phase_review_story "$story"; then
|
|
((succeeded++)) || true
|
|
else
|
|
log WARN "Review found issues for: $story (counted as succeeded, review logged)"
|
|
((succeeded++)) || true
|
|
fi
|
|
else
|
|
((succeeded++)) || true
|
|
fi
|
|
|
|
# Commit phase — commit everything produced by dev + review
|
|
phase_commit_story "$story" || log WARN "Commit failed for: $story (continuing)"
|
|
|
|
# Cooldown between stories (skip after last)
|
|
if [ $((i + 1)) -lt $total ] && [ "$DRY_RUN" = false ]; then
|
|
log INFO "Cooldown: ${COOLDOWN}s before next story..."
|
|
sleep "$COOLDOWN"
|
|
fi
|
|
done
|
|
|
|
# Final summary
|
|
separator "Pipeline Complete"
|
|
echo -e " ${GREEN}Succeeded:${NC} $succeeded"
|
|
echo -e " ${RED}Failed:${NC} $failed"
|
|
echo -e " ${YELLOW}Skipped:${NC} $skipped"
|
|
echo -e " ${BOLD}Total:${NC} $total"
|
|
echo ""
|
|
log INFO "Full logs: $LOG_DIR/"
|
|
|
|
if [ $failed -gt 0 ]; then
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
main "$@"
|