Files
calctext/run-epic.sh
2026-03-16 19:54:53 -04:00

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 "$@"