#!/bin/bash # # BMAD Epic Execute - Automated Story Execution with Context Isolation # # Usage: ./epic-execute.sh [options] # # Options: # --dry-run Show what would be executed without running # --skip-review Skip code review phase (not recommended) # --no-commit Stage changes but don't commit # --parallel Run independent stories in parallel (experimental) # --verbose Show detailed output # --start-from ID Start from a specific story (e.g., 31-2) # --skip-done Skip stories with Status: Done # --skip-arch Skip architecture compliance check # --skip-test-quality Skip test quality review # --skip-traceability Skip traceability check (not recommended) # --skip-static-analysis Skip static analysis gate (runs real tooling) # set -e # Allow nested Claude Code sessions (when launched from within Claude Code) unset CLAUDECODE 2>/dev/null || true # ============================================================================= # Cleanup and Signal Handling # ============================================================================= # Track execution state for cleanup CURRENT_STORY_INDEX=0 CLEANUP_DONE=false cleanup() { # Prevent recursive cleanup if [ "$CLEANUP_DONE" = true ]; then return fi CLEANUP_DONE=true local exit_code=$? # Disable trap during cleanup trap - EXIT INT TERM # Stop any in-flight heartbeat background process first if type stop_phase_heartbeat >/dev/null 2>&1; then stop_phase_heartbeat fi echo "" log "Cleaning up (exit code: $exit_code)..." # Finalize metrics if initialized if [ -n "$METRICS_FILE" ] && [ -f "$METRICS_FILE" ]; then local duration=0 if [ -n "$EPIC_START_SECONDS" ]; then duration=$(($(date +%s) - EPIC_START_SECONDS)) fi # Only finalize if we have story data if [ "${#STORIES[@]}" -gt 0 ] 2>/dev/null; then finalize_metrics "${#STORIES[@]}" "${COMPLETED:-0}" "${FAILED:-0}" "${SKIPPED:-0}" "$duration" log "Metrics finalized: $METRICS_FILE" fi # Roll up trace spans into a deterministic telemetry block (no-op unless traced) if type rollup_telemetry >/dev/null 2>&1; then rollup_telemetry fi fi # Report git status if command -v git >/dev/null 2>&1 && [ -d "$PROJECT_ROOT/.git" ] 2>/dev/null; then local uncommitted uncommitted=$(git -C "$PROJECT_ROOT" status --porcelain 2>/dev/null | wc -l | tr -d ' ') if [ "$uncommitted" -gt 0 ]; then log_warn "Uncommitted changes remain ($uncommitted files). Review with 'git status'" fi fi # Save checkpoint for resume capability if [ -n "$SPRINT_ARTIFACTS_DIR" ] && [ -n "$EPIC_ID" ] && [ -d "$SPRINT_ARTIFACTS_DIR" ] 2>/dev/null; then local checkpoint_file="$SPRINT_ARTIFACTS_DIR/.epic-${EPIC_ID}-checkpoint" { echo "# Epic $EPIC_ID checkpoint - $(date '+%Y-%m-%d %H:%M:%S')" echo "LAST_STORY_INDEX=$CURRENT_STORY_INDEX" echo "COMPLETED=${COMPLETED:-0}" echo "FAILED=${FAILED:-0}" echo "SKIPPED=${SKIPPED:-0}" echo "EXIT_CODE=$exit_code" } > "$checkpoint_file" 2>/dev/null || true if [ $exit_code -ne 0 ]; then log "Checkpoint saved: $checkpoint_file" fi fi # Log final state on non-zero exit if [ $exit_code -ne 0 ]; then log_error "Epic execution interrupted (exit code: $exit_code)" if [ -n "$EPIC_ID" ]; then log "Resume with: $0 $EPIC_ID --start-from " fi fi # Kill orphaned node/test processes kill_orphaned_test_processes # Clean up phase output temp files rm -f "$PHASE_OUTPUT_FILE" 2>/dev/null rm -f "$PHASE_STREAM_FILE" 2>/dev/null # Save log to repo before exiting save_log_to_repo if [ -n "$FINAL_LOG_FILE" ] && [ -f "$FINAL_LOG_FILE" ]; then echo " - Log saved: $FINAL_LOG_FILE" fi exit $exit_code } # Register trap for cleanup on exit, interrupt, or termination trap cleanup EXIT INT TERM # ============================================================================= # Configuration # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" BMAD_DIR="$PROJECT_ROOT/bmad" # ============================================================================= # Source Modular Components # ============================================================================= LIB_DIR="$SCRIPT_DIR/epic-execute-lib" [ -f "$LIB_DIR/utils.sh" ] && source "$LIB_DIR/utils.sh" [ -f "$LIB_DIR/decision-log.sh" ] && source "$LIB_DIR/decision-log.sh" [ -f "$LIB_DIR/regression-gate.sh" ] && source "$LIB_DIR/regression-gate.sh" [ -f "$LIB_DIR/test-failure-filter.sh" ] && source "$LIB_DIR/test-failure-filter.sh" [ -f "$LIB_DIR/design-phase.sh" ] && source "$LIB_DIR/design-phase.sh" [ -f "$LIB_DIR/json-output.sh" ] && source "$LIB_DIR/json-output.sh" [ -f "$LIB_DIR/tdd-flow.sh" ] && source "$LIB_DIR/tdd-flow.sh" [ -f "$LIB_DIR/contract-harness.sh" ] && source "$LIB_DIR/contract-harness.sh" [ -f "$LIB_DIR/contract-exec.sh" ] && source "$LIB_DIR/contract-exec.sh" [ -f "$LIB_DIR/observability.sh" ] && source "$LIB_DIR/observability.sh" STORIES_DIR="$PROJECT_ROOT/docs/stories" SPRINT_ARTIFACTS_DIR="$PROJECT_ROOT/docs/sprint-artifacts" SPRINTS_DIR="$PROJECT_ROOT/docs/sprints" EPICS_DIR="$PROJECT_ROOT/docs/epics" UAT_DIR="$PROJECT_ROOT/docs/uat" LOGS_DIR="$SPRINT_ARTIFACTS_DIR/logs" # Per-story design plans (persisted so dev phase can read them after resume) DESIGN_DIR="$SPRINT_ARTIFACTS_DIR/design" # Temporary log file during execution - will be copied to LOGS_DIR on completion LOG_FILE="/tmp/bmad-epic-execute-$$.log" # Final log path set after EPIC_ID is known (in save_log_to_repo) FINAL_LOG_FILE="" # ============================================================================= # BMAD Workflow Paths # ============================================================================= # Source workflow files from the BMAD-METHOD repository BMAD_SRC_DIR="$SCRIPT_DIR/.." # Our automation workflows (epic-execute, epic-chain, uat-validate) under src/bmm/workflows/ WORKFLOWS_DIR="$BMAD_SRC_DIR/src/bmm/workflows/4-implementation" # Upstream BMM skills (post-v6.0 architecture) under src/bmm-skills/ SKILLS_DIR="$BMAD_SRC_DIR/src/bmm-skills/4-implementation" # Dev Story Skill (upstream) DEV_WORKFLOW_DIR="$SKILLS_DIR/bmad-dev-story" DEV_WORKFLOW_SKILL="$DEV_WORKFLOW_DIR/SKILL.md" DEV_WORKFLOW_CHECKLIST="$DEV_WORKFLOW_DIR/checklist.md" # Code Review Skill (upstream) REVIEW_WORKFLOW_DIR="$SKILLS_DIR/bmad-code-review" REVIEW_WORKFLOW_SKILL="$REVIEW_WORKFLOW_DIR/SKILL.md" # UAT Generation (from epic-execute workflow) UAT_STEP_TEMPLATE="$WORKFLOWS_DIR/epic-execute/steps/step-04-generate-uat.md" UAT_DOC_TEMPLATE="$WORKFLOWS_DIR/epic-execute/templates/uat-template.md" # Epic-Execute Step Templates (lean, pipe-mode prompts) DEV_STORY_STEP="$WORKFLOWS_DIR/epic-execute/steps/step-02-dev-story.md" CODE_REVIEW_STEP="$WORKFLOWS_DIR/epic-execute/steps/step-03-code-review.md" ARCH_COMPLIANCE_STEP="$WORKFLOWS_DIR/epic-execute/steps/step-02b-arch-compliance.md" TEST_QUALITY_STEP="$WORKFLOWS_DIR/epic-execute/steps/step-03b-test-quality.md" TRACEABILITY_STEP="$WORKFLOWS_DIR/epic-execute/steps/step-03c-traceability.md" # Traceability output directory TRACEABILITY_DIR="$PROJECT_ROOT/docs/sprint-artifacts/traceability" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # ============================================================================= # Helper Functions # ============================================================================= log() { echo -e "${BLUE}[BMAD]${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" } log_success() { echo -e "${GREEN}[✓]${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [SUCCESS] $1" >> "$LOG_FILE" } log_error() { echo -e "${RED}[✗]${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $1" >> "$LOG_FILE" } log_warn() { echo -e "${YELLOW}[!]${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $1" >> "$LOG_FILE" } # ============================================================================= # Log Persistence Functions # ============================================================================= # Save the temporary log file to the repo's logs directory # Called during cleanup to ensure logs are preserved save_log_to_repo() { # Skip if no log file or it's empty if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then return 0 fi # Create logs directory if needed if [ -n "$LOGS_DIR" ]; then mkdir -p "$LOGS_DIR" 2>/dev/null || true else return 0 fi # Generate descriptive filename: epic-{id}-{timestamp}.log or epic-execute-{timestamp}.log local timestamp timestamp=$(date '+%Y%m%d-%H%M%S') local log_filename if [ -n "$EPIC_ID" ]; then log_filename="epic-${EPIC_ID}-${timestamp}.log" else log_filename="epic-execute-${timestamp}.log" fi FINAL_LOG_FILE="$LOGS_DIR/$log_filename" # Copy the log file if cp "$LOG_FILE" "$FINAL_LOG_FILE" 2>/dev/null; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] Log saved to: $FINAL_LOG_FILE" >> "$LOG_FILE" # Also append to the saved copy echo "[$(date '+%Y-%m-%d %H:%M:%S')] Log saved to: $FINAL_LOG_FILE" >> "$FINAL_LOG_FILE" return 0 else echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] Failed to save log to $FINAL_LOG_FILE" >> "$LOG_FILE" return 1 fi } # Flush the current log to the repo after each story completes/fails. # Unlike save_log_to_repo (which runs once at exit), this overwrites # the same file incrementally so the repo always has the latest progress. flush_log_to_repo() { # Skip if no log file or it's empty if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then return 0 fi # Create logs directory if needed if [ -n "$LOGS_DIR" ]; then mkdir -p "$LOGS_DIR" 2>/dev/null || true else return 0 fi # Use a stable filename so each flush overwrites the previous snapshot if [ -n "$EPIC_ID" ]; then local flush_file="$LOGS_DIR/epic-${EPIC_ID}-latest.log" else return 0 fi cp "$LOG_FILE" "$flush_file" 2>/dev/null || true } # ============================================================================= # Orphaned Process Cleanup # ============================================================================= kill_orphaned_test_processes() { # Kill orphaned node/test processes that may have been spawned during story execution # These can accumulate and consume memory if tests or dev servers aren't cleaned up local killed=0 # Kill orphaned node test runners (jest, vitest, playwright) for pattern in "node.*jest" "node.*vitest" "node.*playwright" "node.*next.*dev" "node.*tsx.*watch"; do local pids pids=$(pgrep -f "$pattern" 2>/dev/null || true) if [ -n "$pids" ]; then echo "$pids" | while read -r pid; do if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true ((killed++)) || true fi done fi done # Kill orphaned pytest processes local pytest_pids pytest_pids=$(pgrep -f "python.*pytest" 2>/dev/null || true) if [ -n "$pytest_pids" ]; then echo "$pytest_pids" | while read -r pid; do if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true ((killed++)) || true fi done fi if [ "${killed:-0}" -gt 0 ]; then log "Killed orphaned test processes" fi } # ============================================================================= # Git Safety Functions # ============================================================================= # Patterns for sensitive files that should never be committed SENSITIVE_FILE_PATTERNS=( "\.env$" "\.env\." "credentials\.json$" "secrets\.json$" "\.secrets$" "\.pem$" "\.key$" "\.p12$" "id_rsa" "\.credentials$" "\.npmrc$" "\.pypirc$" ) # Check for untracked sensitive files that would be staged by git add -A # Returns 0 if safe, 1 if sensitive files found check_sensitive_files() { if [ ! -d "$PROJECT_ROOT/.git" ]; then return 0 # Not a git repo, nothing to check fi local has_issues=false # Get list of untracked files that would be staged local untracked_files untracked_files=$(git -C "$PROJECT_ROOT" ls-files --others --exclude-standard 2>/dev/null || true) if [ -z "$untracked_files" ]; then return 0 # No untracked files fi # Check each sensitive pattern for pattern in "${SENSITIVE_FILE_PATTERNS[@]}"; do local matches matches=$(echo "$untracked_files" | grep -E "$pattern" 2>/dev/null || true) if [ -n "$matches" ]; then while IFS= read -r file; do [ -z "$file" ] && continue # Check if file is gitignored (it shouldn't match if we got it from ls-files --others) if ! git -C "$PROJECT_ROOT" check-ignore -q "$PROJECT_ROOT/$file" 2>/dev/null; then log_error "SAFETY: Sensitive file '$file' is untracked and not gitignored" has_issues=true fi done <<< "$matches" fi done if [ "$has_issues" = true ]; then log_error "Add sensitive files to .gitignore before committing" log_error "Or use --no-commit to skip automatic commits" return 1 fi return 0 } # ============================================================================= # Prompt Size Management # ============================================================================= # Maximum prompt size in bytes (default ~150KB, well under Claude's context limit) MAX_PROMPT_SIZE="${MAX_PROMPT_SIZE:-150000}" # Priority levels for content inclusion CONTENT_PRIORITY_CRITICAL=1 # Story, core workflow instructions (always include) CONTENT_PRIORITY_HIGH=2 # Architecture, checklist CONTENT_PRIORITY_MEDIUM=3 # Decision log, design context CONTENT_PRIORITY_LOW=4 # Full workflow YAML (truncate first) # Get size of a string in bytes get_byte_size() { local content="$1" printf '%s' "$content" | wc -c | tr -d ' ' } # Truncate content to a maximum size, preserving structure # Arguments: # $1 - content to truncate # $2 - max size in bytes # $3 - label for logging (optional) truncate_content() { local content="$1" local max_size="$2" local label="${3:-Content}" local current_size current_size=$(get_byte_size "$content") if [ "$current_size" -le "$max_size" ]; then printf '%s' "$content" return 0 fi [ "$VERBOSE" = true ] && log_warn "$label truncated: ${current_size}B -> ${max_size}B" # Truncate and add notice local truncated truncated=$(printf '%s' "$content" | head -c "$max_size") printf '%s\n\n... [CONTENT TRUNCATED - %sB total, showing first %sB] ...' "$truncated" "$current_size" "$max_size" } # Build a prompt with size limits # Adds content in priority order until limit reached # Arguments: # $1 - base prompt (critical, always included) # Remaining args: triplets of "label|priority|content" # Note: This is a simplified version - for complex prompts, build manually with truncate_content build_sized_prompt() { local base_prompt="$1" shift local current_size current_size=$(get_byte_size "$base_prompt") local final_prompt="$base_prompt" local remaining=$((MAX_PROMPT_SIZE - current_size - 5000)) # Reserve 5KB for output # Process content blocks while [ $# -ge 3 ]; do local label="$1" local priority="$2" local content="$3" shift 3 local content_size content_size=$(get_byte_size "$content") if [ "$content_size" -eq 0 ]; then continue fi if [ "$content_size" -le "$remaining" ]; then # Fits entirely final_prompt+="$content" remaining=$((remaining - content_size)) elif [ "$priority" -le "$CONTENT_PRIORITY_HIGH" ]; then # Critical/high priority - truncate but include local truncated truncated=$(truncate_content "$content" "$remaining" "$label") final_prompt+="$truncated" remaining=0 else # Lower priority - skip entirely [ "$VERBOSE" = true ] && log_warn "Skipping $label (${content_size}B) due to size limit" fi if [ "$remaining" -le 0 ]; then [ "$VERBOSE" = true ] && log_warn "Prompt size limit reached (${MAX_PROMPT_SIZE}B)" break fi done printf '%s' "$final_prompt" } # Log prompt size in verbose mode log_prompt_size() { local prompt="$1" local phase_name="${2:-prompt}" if [ "$VERBOSE" = true ]; then local prompt_size prompt_size=$(get_byte_size "$prompt") log "Prompt size ($phase_name): ${prompt_size}B / ${MAX_PROMPT_SIZE}B limit" fi } # ============================================================================= # Memory-safe Claude execution helpers # ============================================================================= # Instead of capturing claude output into a bash variable (which can consume # gigabytes of RAM), pipe output directly to a temp file and read only the # tail for completion signal parsing. # Temp file for current phase output (reused across phases, cleaned up on exit) PHASE_OUTPUT_FILE="/tmp/bmad-phase-output-$$.txt" # Raw stream-json (JSONL) for the current phase when tracing is enabled. PHASE_STREAM_FILE="/tmp/bmad-phase-stream-$$.jsonl" # Run claude and pipe output directly to file + LOG_FILE (no bash variable). # # When BMAD_TRACE=1, uses --output-format stream-json so the per-call telemetry # envelope (session id, tokens, cost, latency) is captured. The raw JSONL is # tee'd to PHASE_STREAM_FILE *before* the live jq renderer, so a renderer # failure can never corrupt capture (Open Decision 2). The clean .result text # is then written to PHASE_OUTPUT_FILE, so all downstream parsers that read # PHASE_OUTPUT_FILE keep working unchanged — and parse cleaner text than before. # # When tracing is off, falls back to the legacy text path verbatim. # # Callers set CURRENT_PHASE/CURRENT_STORY_ID (via set_span_context) beforehand. # # Arguments: # $1 - prompt text (use "-f" as first arg to use file-based prompt) # $2 - prompt file path (only when $1 is "-f") # Sets: PHASE_OUTPUT_FILE with the clean assistant text run_claude_to_file() { # Truncate phase output file : > "$PHASE_OUTPUT_FILE" # Legacy text path (tracing disabled) — unchanged behavior. if [ "${TRACE_ENABLED:-false}" != true ]; then if [ "$1" = "-f" ]; then local prompt_file="$2" claude --dangerously-skip-permissions -f "$prompt_file" 2>&1 | tee -a "$LOG_FILE" > "$PHASE_OUTPUT_FILE" || true else local prompt="$1" claude --dangerously-skip-permissions -p "$prompt" 2>&1 | tee -a "$LOG_FILE" > "$PHASE_OUTPUT_FILE" || true fi return 0 fi # Traced path: stream-json + telemetry capture. : > "$PHASE_STREAM_FILE" # Intra-phase heartbeat: liveness trail so a hard kill mid-phase is debuggable. start_phase_heartbeat "$PHASE_STREAM_FILE" # Live renderer prints assistant text to the terminal/log. It reads the # raw JSONL *after* the tee has already persisted it, so if jq chokes on a # partial line the capture in PHASE_STREAM_FILE is unaffected (cosmetic only). # stdin is redirected from /dev/null to suppress the CLI's "no stdin" warning. if [ "$1" = "-f" ]; then local prompt_file="$2" claude --dangerously-skip-permissions --output-format stream-json --verbose -f "$prompt_file" >"$LOG_FILE" \ | tee -a "$PHASE_STREAM_FILE" \ | jq -r --unbuffered 'select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text' 2>/dev/null \ | tee -a "$LOG_FILE" || true else local prompt="$1" claude --dangerously-skip-permissions --output-format stream-json --verbose -p "$prompt" >"$LOG_FILE" \ | tee -a "$PHASE_STREAM_FILE" \ | jq -r --unbuffered 'select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text' 2>/dev/null \ | tee -a "$LOG_FILE" || true fi # Stop the heartbeat now that the phase's claude call has returned. stop_phase_heartbeat # Derive the clean assistant text from the final result envelope so that # all downstream parsers (extract_json_result, check_phase_completion) keep # reading PHASE_OUTPUT_FILE exactly as before. The result line is always last. local result_text="" if [ -s "$PHASE_STREAM_FILE" ]; then result_text=$(tail -n 1 "$PHASE_STREAM_FILE" 2>/dev/null | jq -r 'select(.type=="result") | .result // empty' 2>/dev/null) || true fi if [ -n "$result_text" ]; then printf '%s' "$result_text" > "$PHASE_OUTPUT_FILE" else # No result envelope (crash/timeout) — fall back to raw stream so the # downstream "unclear result" handling still has something to inspect. cp "$PHASE_STREAM_FILE" "$PHASE_OUTPUT_FILE" 2>/dev/null || true fi # Record one span for this phase. Status is best-effort from the JSON result # block in the clean text; the core telemetry comes from the envelope itself. local span_status="" if [ -n "$result_text" ] && type extract_json_result >/dev/null 2>&1; then extract_json_result "$result_text" >/dev/null 2>&1 || true span_status=$(get_result_status 2>/dev/null || echo "") fi record_span "$PHASE_STREAM_FILE" "$span_status" } # Read the tail of phase output for completion signal parsing. # Only reads the last 32KB — enough for JSON result blocks and signal lines, # but avoids loading megabytes of tool output into a bash variable. # Arguments: none (reads from PHASE_OUTPUT_FILE) # Returns: tail content on stdout read_phase_tail() { tail -c 32768 "$PHASE_OUTPUT_FILE" 2>/dev/null || echo "" } # Read full phase output (use sparingly — only when you must search the entire output) # Arguments: none (reads from PHASE_OUTPUT_FILE) # Returns: full content on stdout read_phase_output() { cat "$PHASE_OUTPUT_FILE" 2>/dev/null || echo "" } # ============================================================================= # Shared Automated Prompt Builder # ============================================================================= # Build a lean automated prompt from a step template + automation wrapper. # Instead of embedding full workflow files (~40KB), loads the step template (~4-9KB) # and wraps it with automation rules and completion signals. # # Arguments: # $1 - step_template_file: Path to the step template .md file # $2 - story_file: Path to the story .md file (passed as path, NOT embedded) # $3 - phase_name: Name of the phase (dev, review, fix, arch, test_quality, trace, uat) # $4 - extra_context: Optional extra context (review findings, decisions, etc.) # $5 - completion_signals: Phase-specific completion signal instructions # # Outputs: # The assembled prompt string to stdout build_automated_prompt() { local step_template_file="$1" local story_file="$2" local phase_name="$3" local extra_context="${4:-}" local completion_signals="${5:-}" local story_id story_id=$(basename "$story_file" .md) # Load the step template local template="" if [ -f "$step_template_file" ]; then template=$(cat "$step_template_file") else log_warn "Step template not found: $step_template_file" fi # Perform variable substitution on template template="${template//\{story_id\}/$story_id}" template="${template//\{story_file\}/$story_file}" template="${template//\{story_file_contents\}/Read the story file at: $story_file}" template="${template//\{project_root\}/$PROJECT_ROOT}" template="${template//\{epic_id\}/$EPIC_ID}" template="${template//\{date\}/$(date '+%Y-%m-%d')}" cat < ... - Do NOT use 'git add -A' or 'git add .' ### Execution Variables (Pre-resolved) - story_path: $story_file - story_key: $story_id - project_root: $PROJECT_ROOT - implementation_artifacts: $STORIES_DIR - sprint_status: $SPRINT_ARTIFACTS_DIR/sprint-status.yaml - date: $(date '+%Y-%m-%d') - user_name: Epic Executor - communication_language: English - user_skill_level: expert - document_output_language: English ## Step Template Instructions $template ## Story File **Read this file before proceeding:** $story_file **Story ID:** $story_id $extra_context $completion_signals ## Begin Execution Execute the $phase_name workflow now. PROMPT_EOF } # ============================================================================= # Metrics Functions # ============================================================================= METRICS_DIR="" METRICS_FILE="" METRICS_RESUMED=false init_metrics() { METRICS_DIR="$SPRINT_ARTIFACTS_DIR/metrics" METRICS_FILE="$METRICS_DIR/epic-${EPIC_ID}-metrics.yaml" mkdir -p "$METRICS_DIR" local start_time=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # If metrics file already exists, preserve it and seed in-memory counters if [ -f "$METRICS_FILE" ]; then METRICS_RESUMED=true log "Resuming with existing metrics: $METRICS_FILE" # Seed in-memory counters from existing YAML so they accumulate if command -v yq >/dev/null 2>&1; then COMPLETED=$(yq '.stories.completed // 0' "$METRICS_FILE") FAILED=$(yq '.stories.failed // 0' "$METRICS_FILE") SKIPPED=$(yq '.stories.skipped // 0' "$METRICS_FILE") log "Restored counters: completed=$COMPLETED failed=$FAILED skipped=$SKIPPED" # Record resume event yq -i ".execution.resumed_at = \"$start_time\"" "$METRICS_FILE" else log_warn "yq not found - cannot restore counters from existing metrics" fi return fi # No existing file - create fresh metrics cat > "$METRICS_FILE" << EOF epic_id: "$EPIC_ID" execution: start_time: "$start_time" end_time: "" duration_seconds: 0 stories: total: 0 completed: 0 failed: 0 skipped: 0 fix_loop: total_fix_attempts: 0 stories_requiring_fixes: 0 max_retries_hit: 0 validation: gate_executed: false gate_status: "PENDING" issues: [] story_details: [] EOF log "Metrics initialized: $METRICS_FILE" } update_story_metrics() { local status="$1" # completed|failed|skipped if [ -z "$METRICS_FILE" ] || [ ! -f "$METRICS_FILE" ]; then return fi # Check if yq is available for YAML manipulation if command -v yq >/dev/null 2>&1; then case "$status" in completed) yq -i '.stories.completed += 1' "$METRICS_FILE" ;; failed) yq -i '.stories.failed += 1' "$METRICS_FILE" ;; skipped) yq -i '.stories.skipped += 1' "$METRICS_FILE" ;; esac else # Fallback: log warning (metrics will be finalized at end) [ "$VERBOSE" = true ] && log_warn "yq not found - metrics update deferred" fi } add_metrics_issue() { local story_id="$1" local issue_type="$2" local message="$3" if [ -z "$METRICS_FILE" ] || [ ! -f "$METRICS_FILE" ]; then return fi local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") if command -v yq >/dev/null 2>&1; then yq -i ".issues += [{\"story\": \"$story_id\", \"type\": \"$issue_type\", \"message\": \"$message\", \"timestamp\": \"$timestamp\"}]" "$METRICS_FILE" fi } record_fix_attempt() { local story_id="$1" local attempt_num="$2" local outcome="$3" # success|failed|max_retries if [ -z "$METRICS_FILE" ] || [ ! -f "$METRICS_FILE" ]; then return fi local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") if command -v yq >/dev/null 2>&1; then # Increment total fix attempts yq -i '.fix_loop.total_fix_attempts += 1' "$METRICS_FILE" # Track per-story fix details yq -i ".story_details += [{\"story\": \"$story_id\", \"fix_attempt\": $attempt_num, \"outcome\": \"$outcome\", \"timestamp\": \"$timestamp\"}]" "$METRICS_FILE" if [ "$outcome" = "max_retries" ]; then yq -i '.fix_loop.max_retries_hit += 1' "$METRICS_FILE" fi fi } record_story_required_fixes() { local story_id="$1" if [ -z "$METRICS_FILE" ] || [ ! -f "$METRICS_FILE" ]; then return fi if command -v yq >/dev/null 2>&1; then yq -i '.fix_loop.stories_requiring_fixes += 1' "$METRICS_FILE" fi } finalize_metrics() { local total_stories="$1" local completed="$2" local failed="$3" local skipped="$4" local duration="$5" if [ -z "$METRICS_FILE" ] || [ ! -f "$METRICS_FILE" ]; then return fi local end_time=$(date -u +"%Y-%m-%dT%H:%M:%SZ") if command -v yq >/dev/null 2>&1; then # Add current session duration to any prior duration (for resumed runs) local prior_duration prior_duration=$(yq '.execution.duration_seconds // 0' "$METRICS_FILE") local total_duration=$((prior_duration + duration)) yq -i ".execution.end_time = \"$end_time\"" "$METRICS_FILE" yq -i ".execution.duration_seconds = $total_duration" "$METRICS_FILE" yq -i ".stories.total = $total_stories" "$METRICS_FILE" yq -i ".stories.completed = $completed" "$METRICS_FILE" yq -i ".stories.failed = $failed" "$METRICS_FILE" yq -i ".stories.skipped = $skipped" "$METRICS_FILE" else # Fallback without yq: only update counters, don't overwrite the file # This preserves issues, story_details, and fix_loop data log_warn "yq not found - metrics finalization limited (counters may be stale)" fi log "Metrics finalized: $METRICS_FILE" } # ============================================================================= # Status Update Functions # ============================================================================= update_story_status() { local story_file="$1" local new_status="$2" local story_id=$(basename "$story_file" .md) if [ ! -f "$story_file" ]; then log_warn "Story file not found for status update: $story_file" return 1 fi # Update Status field in story file using sed # Matches "Status: " and replaces with "Status: " if grep -q "^Status:" "$story_file"; then # Use cross-platform sed function if available, fallback to direct sed if type sed_inplace >/dev/null 2>&1; then sed_inplace "s/^Status:.*$/Status: $new_status/" "$story_file" else # Fallback: use backup and remove approach sed -i.bak "s/^Status:.*$/Status: $new_status/" "$story_file" && rm -f "${story_file}.bak" fi log_success "Updated story file status: $story_id → $new_status" else log_warn "No Status field found in story file: $story_id" return 1 fi return 0 } update_sprint_status() { local story_id="$1" local new_status="$2" # Find sprint-status.yaml file local sprint_file="" for search_dir in "$SPRINT_ARTIFACTS_DIR" "$SPRINTS_DIR" "$PROJECT_ROOT/_bmad-output" "$PROJECT_ROOT/docs"; do if [ -f "$search_dir/sprint-status.yaml" ]; then sprint_file="$search_dir/sprint-status.yaml" break fi done if [ -z "$sprint_file" ] || [ ! -f "$sprint_file" ]; then [ "$VERBOSE" = true ] && log_warn "No sprint-status.yaml found - skipping sprint status update" return 0 fi # Extract story key from story_id (e.g., "1-2-user-auth" from various naming formats) # Story files can be named: 1-2-user-auth.md, story-1.2-user-auth.md, etc. local story_key="" # Try to extract the key pattern: {epic}-{seq}-{name} if [[ "$story_id" =~ ^([0-9]+)-([0-9]+)-(.+)$ ]]; then story_key="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}" elif [[ "$story_id" =~ ^story-([0-9]+)\.([0-9]+)-(.+)$ ]]; then story_key="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}" elif [[ "$story_id" =~ ^story-([0-9]+)-([0-9]+)-(.+)$ ]]; then story_key="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}" else # Use story_id as-is if no pattern matches story_key="$story_id" fi # Check if yq is available for YAML manipulation if command -v yq >/dev/null 2>&1; then # Check if story key exists in development_status if yq -e ".development_status[\"$story_key\"]" "$sprint_file" >/dev/null 2>&1; then yq -i ".development_status[\"$story_key\"] = \"$new_status\"" "$sprint_file" log_success "Updated sprint status: $story_key → $new_status" else [ "$VERBOSE" = true ] && log_warn "Story key '$story_key' not found in sprint-status.yaml" fi else # Fallback: use sed for simple replacement # This handles the format: " 1-2-user-auth: in-progress" if grep -q "^[[:space:]]*${story_key}:" "$sprint_file"; then # Use cross-platform sed function if available if type sed_inplace >/dev/null 2>&1; then sed_inplace "s/^\([[:space:]]*${story_key}:\).*/\1 $new_status/" "$sprint_file" else # Fallback: use backup and remove approach sed -i.bak "s/^\([[:space:]]*${story_key}:\).*/\1 $new_status/" "$sprint_file" && rm -f "${sprint_file}.bak" fi log_success "Updated sprint status: $story_key → $new_status (via sed)" else [ "$VERBOSE" = true ] && log_warn "Story key '$story_key' not found in sprint-status.yaml (sed fallback)" fi fi return 0 } mark_story_done() { local story_file="$1" local story_id=$(basename "$story_file" .md) log "Marking story as done: $story_id" # Update story file Status to done update_story_status "$story_file" "done" # Update sprint-status.yaml if it exists update_sprint_status "$story_id" "done" } # ============================================================================= # Help Function # ============================================================================= show_help() { cat << 'EOF' BMAD Epic Execute - Automated Story Execution with Context Isolation USAGE: epic-execute.sh [OPTIONS] ARGUMENTS: epic-id Numeric ID of the epic to execute (e.g., 1, 42) OPTIONS: Execution Control: --dry-run Show what would be executed without running --start-from ID Start from a specific story (e.g., 31-2) --resume Resume from last checkpoint (auto-detected) --parallel Run independent stories in parallel (experimental) --verbose Show detailed output including Claude responses --legacy-output Use legacy text-based output parsing (no JSON) Gate Skipping: --skip-review Skip code review phase (not recommended) --skip-arch Skip architecture compliance check --skip-test-quality Skip test quality review --skip-traceability Skip traceability check (not recommended) --skip-static-analysis Skip static analysis gate --skip-regression Skip regression test gate TDD/Testing Options: --skip-design Skip pre-implementation design phase --skip-design-critic Skip the design plan critic/revision loop --skip-tdd Skip all test-first development phases --skip-test-spec Skip test specification phase only --skip-test-impl Skip test implementation phase only Contract Validation: --init-harness Scaffold a contract-harness.yaml template and exit --preflight-deep Also run a connectivity smoke (boots the sample env) --skip-contract-validation Skip the contract harness preflight Commit Control: --no-commit Stage changes but don't commit --skip-done Skip stories with Status: Done Help: -h, --help Show this help message EXAMPLES: # Execute epic 1 with all quality gates ./epic-execute.sh 1 # Dry run to preview what will be executed ./epic-execute.sh 1 --dry-run --verbose # Resume from last checkpoint (after interruption) ./epic-execute.sh 1 --resume # Start from a specific story ./epic-execute.sh 1 --start-from 1-3 # Skip already-completed stories ./epic-execute.sh 1 --skip-done # Fast mode (skip optional quality gates) ./epic-execute.sh 1 --skip-arch --skip-traceability # Development mode (no commits, verbose output) ./epic-execute.sh 1 --no-commit --verbose ENVIRONMENT VARIABLES: CLAUDE_TIMEOUT Timeout for Claude invocations (default: 600s) PROJECT_ROOT Override project root detection PROTECTED_BRANCHES Space-separated list of protected branches (default: "main master") MAX_PROMPT_SIZE Maximum prompt size in bytes (default: 150000) RETRY_MAX_ATTEMPTS Max retry attempts for transient failures (default: 3) RETRY_INITIAL_DELAY Initial retry delay in seconds (default: 5) MAX_DESIGN_CRITIC_ATTEMPTS Max design plan revision rounds (default: 2) FILES: Logs: docs/sprint-artifacts/logs/epic--.log Metrics: docs/sprint-artifacts/metrics/epic--metrics.yaml Checkpoint: docs/sprint-artifacts/.epic--checkpoint Harness: contract-harness.yaml (project root or docs/) - optional CONTRACT VALIDATION: If a contract-harness.yaml is present, startup runs a preflight that checks every credential, command, and file the harness needs (inferred from the harness itself). This covers API/database contracts and, via the ui: section, frontend user-flow contracts (Playwright driver, tests dir, and role seeds). A dry run prints a readiness report and exits non-zero when anything required is missing, so it works as a CI readiness gate: ./epic-execute.sh --dry-run # presence checks only ./epic-execute.sh --dry-run --preflight-deep # + connectivity smoke ./epic-execute.sh --init-harness # scaffold a starter harness For more information, see: docs/bmad_improvements_v2_fixes.md EOF exit 0 } # ============================================================================= # Argument Parsing # ============================================================================= EPIC_ID="" DRY_RUN=false SKIP_REVIEW=false NO_COMMIT=false PARALLEL=false VERBOSE=false START_FROM="" RESUME_FROM_CHECKPOINT=false SKIP_DONE=false SKIP_ARCH=false SKIP_TEST_QUALITY=false SKIP_TRACEABILITY=false SKIP_STATIC_ANALYSIS=false SKIP_DESIGN=false SKIP_DESIGN_CRITIC=false SKIP_REGRESSION=false SKIP_CONTRACT_VALIDATION=false PREFLIGHT_DEEP=false INIT_HARNESS=false SKIP_TDD=false SKIP_TEST_SPEC=false SKIP_TEST_IMPL=false LEGACY_OUTPUT=false # Check for help flag before processing other arguments if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then show_help fi while [[ $# -gt 0 ]]; do case $1 in -h|--help) show_help ;; --dry-run) DRY_RUN=true shift ;; --skip-review) SKIP_REVIEW=true shift ;; --no-commit) NO_COMMIT=true shift ;; --parallel) PARALLEL=true shift ;; --verbose) VERBOSE=true shift ;; --start-from) START_FROM="$2" shift 2 ;; --resume) RESUME_FROM_CHECKPOINT=true shift ;; --skip-done) SKIP_DONE=true shift ;; --skip-arch) SKIP_ARCH=true shift ;; --skip-test-quality) SKIP_TEST_QUALITY=true shift ;; --skip-traceability) SKIP_TRACEABILITY=true shift ;; --skip-static-analysis) SKIP_STATIC_ANALYSIS=true shift ;; --skip-design) SKIP_DESIGN=true shift ;; --skip-design-critic) SKIP_DESIGN_CRITIC=true shift ;; --skip-regression) SKIP_REGRESSION=true shift ;; --skip-contract-validation) SKIP_CONTRACT_VALIDATION=true shift ;; --preflight-deep) PREFLIGHT_DEEP=true shift ;; --init-harness) INIT_HARNESS=true shift ;; --skip-tdd) SKIP_TDD=true shift ;; --skip-test-spec) SKIP_TEST_SPEC=true shift ;; --skip-test-impl) SKIP_TEST_IMPL=true shift ;; --legacy-output) LEGACY_OUTPUT=true shift ;; -*) echo "Unknown option: $1" exit 1 ;; *) EPIC_ID="$1" shift ;; esac done # --init-harness: scaffold a contract-harness.yaml template and exit (no epic needed) if [ "$INIT_HARNESS" = true ]; then if type init_contract_harness >/dev/null 2>&1; then init_contract_harness exit $? else echo "Contract harness module not available (scripts/epic-execute-lib/contract-harness.sh)" exit 1 fi fi if [ -z "$EPIC_ID" ]; then echo "Usage: $0 [options]" echo "" echo "Options:" echo " --dry-run Show what would be executed" echo " --skip-review Skip code review phase" echo " --no-commit Don't commit after stories" echo " --parallel Parallel execution (experimental)" echo " --verbose Detailed output" echo " --start-from ID Start from a specific story (e.g., 31-2)" echo " --skip-done Skip stories with Status: Done" echo " --skip-arch Skip architecture compliance check" echo " --skip-test-quality Skip test quality review" echo " --skip-traceability Skip traceability check (not recommended)" echo " --skip-static-analysis Skip static analysis gate (runs real tooling)" echo " --skip-design Skip pre-implementation design phase" echo " --skip-design-critic Skip the design plan critic/revision loop" echo " --skip-regression Skip regression test gate" echo " --skip-tdd Skip test-first development phases" echo " --skip-test-spec Skip test specification phase only" echo " --skip-test-impl Skip test implementation phase only" echo " --legacy-output Use legacy text-based output parsing (no JSON)" exit 1 fi # ============================================================================= # Setup # ============================================================================= log "Starting epic execution for: $EPIC_ID" log "Project root: $PROJECT_ROOT" # ============================================================================= # Validate BMAD Workflow Files # ============================================================================= validate_workflows() { local missing=0 local invalid=0 log "Validating BMAD workflow files..." # Dev-story skill if [ ! -f "$DEV_WORKFLOW_SKILL" ]; then log_error "Missing: Dev SKILL.md at $DEV_WORKFLOW_SKILL" ((missing++)) else if type validate_workflow_content >/dev/null 2>&1; then if ! validate_workflow_content "$DEV_WORKFLOW_SKILL"; then ((invalid++)) fi fi fi # Code-review skill if [ ! -f "$REVIEW_WORKFLOW_SKILL" ]; then log_error "Missing: Review SKILL.md at $REVIEW_WORKFLOW_SKILL" ((missing++)) else if type validate_workflow_content >/dev/null 2>&1; then if ! validate_workflow_content "$REVIEW_WORKFLOW_SKILL"; then ((invalid++)) fi fi fi if [ $missing -gt 0 ]; then log_error "Missing $missing required BMAD workflow files" log_error "Ensure you are running from the BMAD-METHOD repository" log_error "Skills expected at: $SKILLS_DIR" exit 1 fi if [ $invalid -gt 0 ]; then log_warn "$invalid workflow files have content issues (may still work)" fi log_success "All BMAD workflow files validated" if [ "$VERBOSE" = true ]; then echo " Dev skill: $DEV_WORKFLOW_DIR" echo " Review skill: $REVIEW_WORKFLOW_DIR" fi } validate_workflows # Initialize utility module (M1-M5 fixes) if type init_utils >/dev/null 2>&1; then init_utils fi # Check branch protection (M5) - prevent commits to main/master if [ "$NO_COMMIT" != true ] && type check_branch_protection >/dev/null 2>&1; then if ! check_branch_protection; then exit 1 fi fi # Contract harness preflight - validate readiness to run contract validation. # Opt-in by presence of a contract-harness.yaml. In a real run this is a # fail-fast gate (abort before story 1 if prerequisites are missing). In a dry # run it reports readiness and makes the run exit non-zero (a CI readiness gate). if [ "$SKIP_CONTRACT_VALIDATION" != true ] && type contract_preflight >/dev/null 2>&1; then CONTRACT_HARNESS_FILE=$(find_contract_harness) if [ -n "$CONTRACT_HARNESS_FILE" ]; then if ! contract_preflight "$CONTRACT_HARNESS_FILE"; then if [ "$DRY_RUN" = true ]; then log_warn "Preflight found missing prerequisites - dry run will exit non-zero" else log_error "Contract harness preflight failed - aborting before execution" exit 1 fi fi elif [ "$VERBOSE" = true ]; then log "No contract-harness.yaml found - contract validation not configured" fi fi # Ensure directories exist mkdir -p "$UAT_DIR" mkdir -p "$SPRINTS_DIR" # Observability: enforce jq when tracing is enabled (hard prerequisite), then # mint the epic-level trace id and create the span file. No-op unless BMAD_TRACE=1. if type require_observability_deps >/dev/null 2>&1; then require_observability_deps fi if type init_observability >/dev/null 2>&1; then init_observability fi # Initialize metrics collection EPIC_START_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") EPIC_START_SECONDS=$(date +%s) init_metrics # L1: Load checkpoint for resume capability RESUME_START_INDEX=0 if [ "$RESUME_FROM_CHECKPOINT" = true ] && type load_checkpoint >/dev/null 2>&1; then if load_checkpoint "$EPIC_ID" "$SPRINT_ARTIFACTS_DIR"; then RESUME_START_INDEX=$(get_resume_index) # Restore counters from checkpoint COMPLETED="${CHECKPOINT_COMPLETED:-0}" FAILED="${CHECKPOINT_FAILED:-0}" SKIPPED="${CHECKPOINT_SKIPPED:-0}" log "Will resume from story index: $RESUME_START_INDEX" else log "No checkpoint found - starting from beginning" fi elif [ -n "$START_FROM" ]; then # Manual --start-from takes precedence log "Using --start-from: $START_FROM" fi # Initialize decision log (if module loaded) if type init_decision_log >/dev/null 2>&1; then init_decision_log fi # Initialize regression baseline (if module loaded and not skipped) if [ "$SKIP_REGRESSION" = false ] && type init_regression_baseline >/dev/null 2>&1; then init_regression_baseline fi # Set legacy output mode if requested if [ "$LEGACY_OUTPUT" = true ] && type -v USE_LEGACY_OUTPUT >/dev/null 2>&1; then USE_LEGACY_OUTPUT=true log "Using legacy text-based output parsing" fi # Find epic file (supports both epic-39-*.md and epic-039-*.md formats) EPIC_FILE="" # Pad epic ID with leading zero for 3-digit format (e.g., 40 -> 040) EPIC_ID_PADDED=$(printf "%03d" "$EPIC_ID" 2>/dev/null || echo "$EPIC_ID") for pattern in "epic-${EPIC_ID}.md" "epic-${EPIC_ID}-"*.md "epic-${EPIC_ID_PADDED}-"*.md "epic-0${EPIC_ID}-"*.md "${EPIC_ID}.md"; do found=$(find "$EPICS_DIR" -name "$pattern" 2>/dev/null | head -1) if [ -n "$found" ]; then EPIC_FILE="$found" break fi done if [ -z "$EPIC_FILE" ] || [ ! -f "$EPIC_FILE" ]; then log_error "Epic file not found for: $EPIC_ID" log_error "Searched in: $EPICS_DIR" exit 1 fi log "Found epic file: $EPIC_FILE" # ============================================================================= # Discover Stories # ============================================================================= log "Discovering stories..." # Search multiple locations for story files STORY_LOCATIONS=("$STORIES_DIR" "$SPRINT_ARTIFACTS_DIR" "$SPRINTS_DIR") STORIES=() # Use associative array for O(1) deduplication (bash 4+) # Fallback to path comparison for bash 3.x if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then declare -A SEEN_STORIES fi # Helper function to check if story is already discovered is_story_duplicate() { local file="$1" local normalized # Normalize path for comparison (handles symlinks, relative paths) normalized=$(cd "$(dirname "$file")" 2>/dev/null && pwd)/$(basename "$file") 2>/dev/null || normalized="$file" if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then if [ -n "${SEEN_STORIES[$normalized]:-}" ]; then return 0 # Is duplicate fi SEEN_STORIES[$normalized]=1 return 1 # Not duplicate else # Bash 3.x fallback: iterate through array for existing in "${STORIES[@]}"; do local existing_norm existing_norm=$(cd "$(dirname "$existing")" 2>/dev/null && pwd)/$(basename "$existing") 2>/dev/null || existing_norm="$existing" if [ "$normalized" = "$existing_norm" ]; then return 0 # Is duplicate fi done return 1 # Not duplicate fi } for search_dir in "${STORY_LOCATIONS[@]}"; do if [ ! -d "$search_dir" ]; then continue fi # Method 1: Stories that reference this epic in content (with word boundary) # Use ([^0-9]|$) to ensure "Epic: 1" doesn't match "Epic: 10" or "Epic: 100" while IFS= read -r -d '' file; do if ! is_story_duplicate "$file"; then STORIES+=("$file") fi done < <(grep -l -Z -E "(Epic[[:space:]]*:[[:space:]]*${EPIC_ID}([^0-9]|$)|epic-${EPIC_ID}([^0-9]|$)|Epic[[:space:]]+${EPIC_ID}([^0-9]|$))" "$search_dir"/*.md 2>/dev/null || true) # Method 2: {EpicNumber}-{StoryNumber}-{description}.md (e.g., 1-1-user-registration.md) # Use more specific pattern: EPIC_ID followed by dash and digit while IFS= read -r -d '' file; do if ! is_story_duplicate "$file"; then STORIES+=("$file") fi done < <(find "$search_dir" -maxdepth 1 -name "${EPIC_ID}-[0-9]*-*.md" -print0 2>/dev/null || true) # Method 3: story-{epic}.{seq}-*.md (BMAD standard) while IFS= read -r -d '' file; do if ! is_story_duplicate "$file"; then STORIES+=("$file") fi done < <(find "$search_dir" -maxdepth 1 -name "story-${EPIC_ID}.[0-9]*-*.md" -print0 2>/dev/null || true) # Method 4: story-{epic}-{seq}-*.md (BMAD alternate) while IFS= read -r -d '' file; do if ! is_story_duplicate "$file"; then STORIES+=("$file") fi done < <(find "$search_dir" -maxdepth 1 -name "story-${EPIC_ID}-[0-9]*-*.md" -print0 2>/dev/null || true) done if [ ${#STORIES[@]} -eq 0 ]; then log_error "No stories found for epic: $EPIC_ID" log_error "Searched in: ${STORY_LOCATIONS[*]}" log_error "Looking for:" log_error " - Files containing 'Epic: $EPIC_ID'" log_error " - Files named: ${EPIC_ID}-*-*.md (e.g., ${EPIC_ID}-1-description.md)" log_error " - Files named: story-${EPIC_ID}.*.md or story-${EPIC_ID}-*.md" exit 1 fi log "Found ${#STORIES[@]} stories" # Sort stories for consistent execution order IFS=$'\n' STORIES=($(sort -V <<<"${STORIES[*]}")); unset IFS # Show which directories stories came from if [ "$VERBOSE" = true ]; then for story in "${STORIES[@]}"; do echo " - $story" done fi # ============================================================================= # Execution Functions # ============================================================================= execute_dev_phase() { local story_file="$1" local story_id=$(basename "$story_file" .md) log ">>> DEV PHASE: $story_id (using step template, lean prompt)" # Verify step template exists, fall back to workflow files if [ ! -f "$DEV_STORY_STEP" ]; then log_warn "Step template not found: $DEV_STORY_STEP" log_error "BMAD dev-story step template is required" return 1 fi # Get decision log context if available (with size limit) local decision_context="" if type get_decision_log_context >/dev/null 2>&1; then decision_context=$(get_decision_log_context) # Limit decision log to prevent context overflow local dec_size dec_size=$(get_byte_size "$decision_context") if [ "$dec_size" -gt 20000 ]; then decision_context=$(printf '%s' "$decision_context" | tail -c 20000) [ "$VERBOSE" = true ] && log_warn "Decision log truncated to last 20KB" fi fi # Get design context if available (from design phase) local design_context="" if type build_design_context_for_dev >/dev/null 2>&1; then design_context=$(build_design_context_for_dev "$story_id") fi # Get test spec context if available (from TDD test spec phase) local test_spec_context="" if type build_test_spec_context_for_dev >/dev/null 2>&1; then test_spec_context=$(build_test_spec_context_for_dev "$story_id") fi # Build extra context from in-memory sources (decision log, design, test spec) local extra_context="" if [ -n "$design_context" ]; then extra_context+=" $design_context " fi if [ -n "$test_spec_context" ]; then extra_context+=" $test_spec_context " fi if [ -n "$decision_context" ]; then extra_context+=" ## Previous Implementation Context $decision_context " fi # Completion signals for dev phase local completion_signals="## Completion Signals When the workflow completes successfully (all tasks done, tests pass, status set to 'review'): 1. Output a JSON result block: \`\`\`json { \"status\": \"COMPLETE\", \"story_id\": \"$story_id\", \"summary\": \"\", \"files_changed\": [\"\"], \"tests_added\": , \"decisions\": [{\"what\": \"\", \"why\": \"\"}] } \`\`\` 2. Then output exactly: IMPLEMENTATION COMPLETE: $story_id If a HALT condition is triggered or implementation is blocked: 1. Output a JSON result block with status \"BLOCKED\" and issues array describing blockers 2. Then output exactly: IMPLEMENTATION BLOCKED: $story_id - [specific reason] Stage your changes with explicit file paths: git add ... Do NOT use 'git add -A' or 'git add .' - only stage files you created or modified." # Build the lean prompt using step template + automation wrapper local dev_prompt dev_prompt=$(build_automated_prompt "$DEV_STORY_STEP" "$story_file" "dev-story" "$extra_context" "$completion_signals") # Log prompt size in verbose mode log_prompt_size "$dev_prompt" "dev-phase" if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would execute dev-story step template for $story_id" echo "[DRY RUN] Template: $DEV_STORY_STEP" return 0 fi # Execute in isolated context — pipe to file to avoid memory bloat set_span_context "dev" "$story_id" run_claude_to_file "$dev_prompt" local result result=$(read_phase_tail) # Check completion using JSON parsing with text fallback local completion_status if type check_phase_completion >/dev/null 2>&1; then check_phase_completion "$result" "dev" "$story_id" completion_status=$? else # Fallback to legacy detection if echo "$result" | grep -q "IMPLEMENTATION COMPLETE"; then completion_status=0 elif echo "$result" | grep -q "IMPLEMENTATION BLOCKED"; then completion_status=1 else completion_status=2 fi fi case $completion_status in 0) # Extract decisions for decision log if available if type get_result_decisions >/dev/null 2>&1 && type append_to_decision_log >/dev/null 2>&1; then local decisions=$(get_result_decisions) if [ "$decisions" != "[]" ] && [ -n "$decisions" ]; then append_to_decision_log "DEV" "$story_id" "Decisions: $decisions" fi fi log_success "Dev phase complete: $story_id" return 0 ;; 1) log_error "Dev phase blocked: $story_id" if type get_result_summary >/dev/null 2>&1; then local summary=$(get_result_summary) [ -n "$summary" ] && echo "Reason: $summary" fi echo "$result" | grep "IMPLEMENTATION BLOCKED" || true return 1 ;; *) log_error "Dev phase did not complete cleanly: $story_id" return 1 ;; esac } # Global variable to store review findings for fix loop LAST_REVIEW_FINDINGS="" execute_review_phase() { local story_file="$1" local story_id=$(basename "$story_file" .md) # Reset findings LAST_REVIEW_FINDINGS="" log ">>> REVIEW PHASE: $story_id (using step template, fresh context)" # Verify step template exists if [ ! -f "$CODE_REVIEW_STEP" ]; then log_warn "Step template not found: $CODE_REVIEW_STEP" log_error "BMAD code-review step template is required" return 1 fi # Review-specific extra context: adversarial mode + auto-fix policy local extra_context="## Adversarial Review Mode You ARE an adversarial reviewer - find 3-10 specific issues minimum. You are seeing this code for the FIRST TIME - review with fresh eyes. ## Automated Decision Policy When options are presented: - Always auto-fix HIGH and MEDIUM severity issues - LOW severity issues: document only, do not fix" # Completion signals for review phase local completion_signals="## Completion Signals When review passes (all HIGH/MEDIUM issues fixed, all ACs implemented, status set to 'done'): 1. Output a JSON result block: \`\`\`json { \"status\": \"PASSED\", \"story_id\": \"$story_id\", \"summary\": \"\", \"files_changed\": [\"\"], \"issues\": [] } \`\`\` 2. Then output exactly: REVIEW PASSED: $story_id Or if fixes were made: REVIEW PASSED WITH FIXES: $story_id - Fixed N issues If review fails (unfixable issues, missing acceptance criteria that YOU cannot fix): 1. Output a JSON result block with issues: \`\`\`json { \"status\": \"FAILED\", \"story_id\": \"$story_id\", \"summary\": \"\", \"issues\": [ {\"severity\": \"HIGH\", \"description\": \"\", \"location\": \"\"}, {\"severity\": \"MEDIUM\", \"description\": \"\", \"location\": \"\"} ] } \`\`\` 2. Then output the legacy findings block: \`\`\` REVIEW FINDINGS START - [HIGH] Description of issue 1 (file:line if applicable) - [MEDIUM] Description of issue 2 REVIEW FINDINGS END \`\`\` 3. Then output exactly: REVIEW FAILED: $story_id - [summary reason] Stage any fixes with explicit file paths: git add ..." # Build the lean prompt using step template + automation wrapper local review_prompt review_prompt=$(build_automated_prompt "$CODE_REVIEW_STEP" "$story_file" "code-review" "$extra_context" "$completion_signals") # Log prompt size in verbose mode log_prompt_size "$review_prompt" "review-phase" if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would execute code-review step template for $story_id" echo "[DRY RUN] Template: $CODE_REVIEW_STEP" return 0 fi # Execute in isolated context — pipe to file to avoid memory bloat set_span_context "review" "$story_id" run_claude_to_file "$review_prompt" local result result=$(read_phase_tail) # Check completion using JSON parsing with text fallback local completion_status if type check_phase_completion >/dev/null 2>&1; then check_phase_completion "$result" "review" "$story_id" completion_status=$? else # Fallback to legacy detection if echo "$result" | grep -q "REVIEW PASSED"; then completion_status=0 elif echo "$result" | grep -q "REVIEW FAILED"; then completion_status=1 else completion_status=2 fi fi case $completion_status in 0) log_success "Review passed: $story_id" return 0 ;; 1) log_error "Review failed: $story_id" echo "$result" | grep "REVIEW FAILED" || true # Extract findings for fix loop - try JSON first, then legacy if type get_result_issues >/dev/null 2>&1; then local json_issues=$(get_result_issues) if [ "$json_issues" != "[]" ] && [ -n "$json_issues" ]; then # Convert JSON issues to text format for fix phase LAST_REVIEW_FINDINGS=$(echo "$json_issues" | jq -r '.[] | "- [\(.severity)] \(.description) (\(.location // "unknown"))"' 2>/dev/null || echo "") fi fi # Fallback to legacy text extraction if JSON didn't work if [ -z "$LAST_REVIEW_FINDINGS" ]; then LAST_REVIEW_FINDINGS=$(echo "$result" | sed -n '/REVIEW FINDINGS START/,/REVIEW FINDINGS END/p' | grep -E '^\s*-\s*\[(HIGH|MEDIUM)\]' || true) fi if [ -n "$LAST_REVIEW_FINDINGS" ]; then log "Captured review findings for fix loop" fi return 1 ;; *) log_warn "Review did not complete cleanly: $story_id" return 1 ;; esac } execute_fix_phase() { local story_file="$1" local review_findings="$2" local attempt_num="$3" local static_analysis_context="${4:-}" # Optional: real tooling output local story_id=$(basename "$story_file" .md) log ">>> FIX PHASE: $story_id (attempt $attempt_num, lean focused prompt)" # Build real tooling output section if available local tooling_section="" if [ -n "$static_analysis_context" ]; then tooling_section=" ## Actual Tooling Output The following are REAL errors from running the project's tooling (not AI-generated). These must be fixed first as they represent actual compilation/test failures: $static_analysis_context " fi # Build a focused fix prompt - no workflow files needed, just findings + rules local fix_prompt="## Automated Execution Mode ### Rules - AUTOMATED execution - do NOT pause for user confirmation at any step - This is a TARGETED FIX session - only fix the issues listed below - Do NOT refactor unrelated code - Do NOT add new features - Stage changes with explicit file paths: git add ... - Do NOT use 'git add -A' or 'git add .' ## Fix Phase Context This is attempt $attempt_num of 3 to fix issues identified during code review. You MUST address ALL HIGH and MEDIUM severity issues listed below. ## Story Being Fixed **Read this file before proceeding:** $story_file **Story ID:** $story_id **Fix Attempt:** $attempt_num of 3 ## Review Findings to Address The following issues were identified during code review and MUST be fixed: $review_findings $tooling_section ## Fix Process 1. Read the story file at: $story_file 2. For each issue in the review findings: a. Locate the problematic code b. Implement the fix c. Run relevant tests to verify d. Move to next issue 3. After all issues are fixed: a. Run full test suite b. Update story file Dev Agent Record with fix notes c. Stage changed files: git add ... ## Completion Signals When ALL review issues are successfully fixed: 1. Output a JSON result block: \`\`\`json { \"status\": \"COMPLETE\", \"story_id\": \"$story_id\", \"summary\": \"Fixed N issues: \", \"files_changed\": [\"\"], \"issues\": [] } \`\`\` 2. Then output exactly: FIX COMPLETE: $story_id - Fixed [N] issues If unable to fix one or more issues: 1. Output a JSON result block with remaining issues: \`\`\`json { \"status\": \"FAILED\", \"story_id\": \"$story_id\", \"summary\": \"\", \"issues\": [{\"severity\": \"HIGH\", \"description\": \"\", \"location\": \"\"}] } \`\`\` 2. Then output exactly: FIX INCOMPLETE: $story_id - [reason and which issues remain] ## Begin Execution Address all review findings now. This is attempt $attempt_num of 3." # Check and enforce prompt size limits local prompt_size prompt_size=$(get_byte_size "$fix_prompt") # Log prompt size (always, not just verbose - critical for debugging) log "Prompt size (fix-phase): ${prompt_size}B / ${MAX_PROMPT_SIZE}B limit" # If prompt is too large, truncate the review findings if [ "$prompt_size" -gt "$MAX_PROMPT_SIZE" ]; then log_warn "Fix prompt exceeds size limit (${prompt_size}B > ${MAX_PROMPT_SIZE}B) - truncating" # Calculate how much we need to cut local excess=$((prompt_size - MAX_PROMPT_SIZE + 10000)) # Extra 10KB buffer # Truncate the review_findings (the variable content) local findings_size findings_size=$(get_byte_size "$review_findings") local new_findings_size=$((findings_size - excess)) if [ "$new_findings_size" -lt 5000 ]; then new_findings_size=5000 # Minimum 5KB of findings fi review_findings=$(truncate_content "$review_findings" "$new_findings_size" "Review findings") # Rebuild the prompt with truncated content fix_prompt="## Automated Execution Mode ### Rules - AUTOMATED execution - do NOT pause for user confirmation at any step - This is a TARGETED FIX session - only fix the issues listed below - Do NOT refactor unrelated code - Do NOT add new features - Stage changes with explicit file paths: git add ... - Do NOT use 'git add -A' or 'git add .' ## Fix Phase Context This is attempt $attempt_num of 3 to fix issues identified during code review. You MUST address ALL HIGH and MEDIUM severity issues listed below. **NOTE: Review findings were truncated due to size limits. Focus on the issues shown.** ## Story Being Fixed **Read this file before proceeding:** $story_file **Story ID:** $story_id **Fix Attempt:** $attempt_num of 3 ## Review Findings to Address $review_findings $tooling_section ## Fix Process 1. Read the story file at: $story_file 2. For each issue in the review findings: a. Locate the problematic code b. Implement the fix c. Run relevant tests to verify d. Move to next issue 3. After all issues are fixed: a. Run full test suite b. Update story file Dev Agent Record with fix notes c. Stage changed files: git add ... ## Begin Execution Address all review findings now. This is attempt $attempt_num of 3." prompt_size=$(get_byte_size "$fix_prompt") log "Truncated prompt size: ${prompt_size}B" fi # Final safety check - if still too large, write to temp file and use -f flag if [ "$prompt_size" -gt "$MAX_PROMPT_SIZE" ]; then log_warn "Prompt still too large after truncation - using file-based prompt" local temp_prompt_file temp_prompt_file=$(mktemp /tmp/bmad-fix-prompt-XXXXXX.txt) printf '%s' "$fix_prompt" > "$temp_prompt_file" if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would execute fix phase for $story_id (attempt $attempt_num) via file" rm -f "$temp_prompt_file" return 0 fi # Pipe to file to avoid memory bloat set_span_context "fix" "$story_id" run_claude_to_file "-f" "$temp_prompt_file" rm -f "$temp_prompt_file" else if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would execute fix phase for $story_id (attempt $attempt_num)" return 0 fi # Execute in isolated context — pipe to file to avoid memory bloat set_span_context "fix" "$story_id" run_claude_to_file "$fix_prompt" fi local result result=$(read_phase_tail) # Check completion using JSON parsing with text fallback local completion_status if type check_phase_completion >/dev/null 2>&1; then check_phase_completion "$result" "fix" "$story_id" completion_status=$? else # Fallback to legacy detection if echo "$result" | grep -q "FIX COMPLETE"; then completion_status=0 elif echo "$result" | grep -q "FIX INCOMPLETE"; then completion_status=1 else completion_status=2 fi fi case $completion_status in 0) log_success "Fix phase complete: $story_id (attempt $attempt_num)" record_fix_attempt "$story_id" "$attempt_num" "success" return 0 ;; 1) log_error "Fix phase incomplete: $story_id (attempt $attempt_num)" echo "$result" | grep "FIX INCOMPLETE" || true record_fix_attempt "$story_id" "$attempt_num" "failed" return 1 ;; *) log_warn "Fix phase did not complete cleanly: $story_id (attempt $attempt_num)" record_fix_attempt "$story_id" "$attempt_num" "failed" return 1 ;; esac } # ============================================================================= # Static Analysis Gate - Real Tooling Verification # ============================================================================= execute_static_analysis_gate() { local story_file="$1" local story_id=$(basename "$story_file" .md) local failures=0 local failure_details="" # Reset failures LAST_STATIC_ANALYSIS_FAILURES="" log ">>> STATIC ANALYSIS GATE: $story_id (running real tooling)" if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would run static analysis gate for $story_id" return 0 fi # Detect project type and run appropriate checks if [ -f "$PROJECT_ROOT/package.json" ]; then log "Detected Node.js/TypeScript project" # 1. Type checking (catches type errors AI might miss) if grep -q '"typecheck"\|"type-check"\|"tsc"' "$PROJECT_ROOT/package.json" 2>/dev/null; then log "Running type check..." local typecheck_output typecheck_output=$(cd "$PROJECT_ROOT" && npm run typecheck 2>&1) || { local exit_code=$? log_error "Type check failed (exit code: $exit_code)" failure_details+=" ### Type Check Failures \`\`\` $typecheck_output \`\`\` " ((failures++)) } echo "$typecheck_output" >> "$LOG_FILE" elif [ -f "$PROJECT_ROOT/tsconfig.json" ]; then # Fallback: run tsc directly if tsconfig exists log "Running tsc directly..." local tsc_output tsc_output=$(cd "$PROJECT_ROOT" && npx tsc --noEmit 2>&1) || { local exit_code=$? log_error "TypeScript compilation failed (exit code: $exit_code)" failure_details+=" ### TypeScript Compilation Failures \`\`\` $tsc_output \`\`\` " ((failures++)) } echo "$tsc_output" >> "$LOG_FILE" fi # 2. Linting (catches code style/quality issues) if grep -q '"lint"' "$PROJECT_ROOT/package.json" 2>/dev/null; then log "Running lint..." local lint_output lint_output=$(cd "$PROJECT_ROOT" && npm run lint 2>&1) || { local exit_code=$? log_error "Lint failed (exit code: $exit_code)" failure_details+=" ### Lint Failures \`\`\` $lint_output \`\`\` " ((failures++)) } echo "$lint_output" >> "$LOG_FILE" fi # 3. Build (catches compilation errors) if grep -q '"build"' "$PROJECT_ROOT/package.json" 2>/dev/null; then log "Running build..." local build_output build_output=$(cd "$PROJECT_ROOT" && npm run build 2>&1) || { local exit_code=$? log_error "Build failed (exit code: $exit_code)" failure_details+=" ### Build Failures \`\`\` $build_output \`\`\` " ((failures++)) } echo "$build_output" >> "$LOG_FILE" fi # 4. Tests (catches actual test failures) if grep -q '"test"' "$PROJECT_ROOT/package.json" 2>/dev/null; then log "Running tests..." local test_output test_output=$(cd "$PROJECT_ROOT" && npm test 2>&1) || { local exit_code=$? # Check if there are NEW failures (not just pre-existing baseline failures) local new_failure_count=0 if type count_new_test_failures >/dev/null 2>&1; then new_failure_count=$(count_new_test_failures "$test_output") new_failure_count=$(echo "$new_failure_count" | tr -d '[:space:]') fi if [ "$new_failure_count" -gt 0 ] 2>/dev/null; then log_error "Tests failed with $new_failure_count NEW failure(s) (exit code: $exit_code)" # Filter test output to only include failures (not all 1900+ passing tests) # This prevents prompt size explosion in fix phase local filtered_failures if type prepare_test_failures_for_fix >/dev/null 2>&1; then filtered_failures=$(prepare_test_failures_for_fix "$test_output" "$story_id") else # Fallback: basic extraction if module not loaded filtered_failures=$(echo "$test_output" | grep -E "FAIL|Error:|AssertionError|Test Files.*failed|Tests.*failed" | head -200) fi failure_details+=" ### Test Failures ($new_failure_count NEW, rest are pre-existing baseline) \`\`\` $filtered_failures \`\`\` " ((failures++)) else # All failures are pre-existing - don't count as a failure for this story log_warn "Tests exited non-zero but all failures are pre-existing (baseline). Passing gate." fi } echo "$test_output" >> "$LOG_FILE" fi elif [ -f "$PROJECT_ROOT/Cargo.toml" ]; then log "Detected Rust project" # Cargo check (type checking) log "Running cargo check..." local cargo_output cargo_output=$(cd "$PROJECT_ROOT" && cargo check 2>&1) || { log_error "Cargo check failed" failure_details+=" ### Cargo Check Failures \`\`\` $cargo_output \`\`\` " ((failures++)) } echo "$cargo_output" >> "$LOG_FILE" # Cargo test log "Running cargo test..." local test_output test_output=$(cd "$PROJECT_ROOT" && cargo test 2>&1) || { # Check if there are NEW failures local new_failure_count=0 if type count_new_test_failures >/dev/null 2>&1; then new_failure_count=$(count_new_test_failures "$test_output") new_failure_count=$(echo "$new_failure_count" | tr -d '[:space:]') fi if [ "$new_failure_count" -gt 0 ] 2>/dev/null; then log_error "Cargo tests failed with $new_failure_count NEW failure(s)" # Filter test output to only include failures local filtered_failures if type prepare_test_failures_for_fix >/dev/null 2>&1; then filtered_failures=$(prepare_test_failures_for_fix "$test_output" "$story_id") else filtered_failures=$(echo "$test_output" | grep -E "FAILED|error\[|panicked" | head -200) fi failure_details+=" ### Cargo Test Failures ($new_failure_count NEW) \`\`\` $filtered_failures \`\`\` " ((failures++)) else log_warn "Cargo tests exited non-zero but all failures are pre-existing. Passing gate." fi } echo "$test_output" >> "$LOG_FILE" elif [ -f "$PROJECT_ROOT/go.mod" ]; then log "Detected Go project" # Go build log "Running go build..." local build_output build_output=$(cd "$PROJECT_ROOT" && go build ./... 2>&1) || { log_error "Go build failed" failure_details+=" ### Go Build Failures \`\`\` $build_output \`\`\` " ((failures++)) } echo "$build_output" >> "$LOG_FILE" # Go test log "Running go test..." local test_output test_output=$(cd "$PROJECT_ROOT" && go test ./... 2>&1) || { # Check if there are NEW failures local new_failure_count=0 if type count_new_test_failures >/dev/null 2>&1; then new_failure_count=$(count_new_test_failures "$test_output") new_failure_count=$(echo "$new_failure_count" | tr -d '[:space:]') fi if [ "$new_failure_count" -gt 0 ] 2>/dev/null; then log_error "Go tests failed with $new_failure_count NEW failure(s)" # Filter test output to only include failures local filtered_failures if type prepare_test_failures_for_fix >/dev/null 2>&1; then filtered_failures=$(prepare_test_failures_for_fix "$test_output" "$story_id") else filtered_failures=$(echo "$test_output" | grep -E "FAIL|panic:|--- FAIL" | head -200) fi failure_details+=" ### Go Test Failures ($new_failure_count NEW) \`\`\` $filtered_failures \`\`\` " ((failures++)) else log_warn "Go tests exited non-zero but all failures are pre-existing. Passing gate." fi } echo "$test_output" >> "$LOG_FILE" elif [ -f "$PROJECT_ROOT/requirements.txt" ] || [ -f "$PROJECT_ROOT/pyproject.toml" ]; then log "Detected Python project" # pytest if command -v pytest >/dev/null 2>&1; then log "Running pytest..." local test_output test_output=$(cd "$PROJECT_ROOT" && pytest 2>&1) || { # Check if there are NEW failures local new_failure_count=0 if type count_new_test_failures >/dev/null 2>&1; then new_failure_count=$(count_new_test_failures "$test_output") new_failure_count=$(echo "$new_failure_count" | tr -d '[:space:]') fi if [ "$new_failure_count" -gt 0 ] 2>/dev/null; then log_error "Pytest failed with $new_failure_count NEW failure(s)" # Filter test output to only include failures local filtered_failures if type prepare_test_failures_for_fix >/dev/null 2>&1; then filtered_failures=$(prepare_test_failures_for_fix "$test_output" "$story_id") else filtered_failures=$(echo "$test_output" | grep -E "FAILED|ERROR|AssertionError|=+ FAILURES =+" | head -200) fi failure_details+=" ### Pytest Failures ($new_failure_count NEW) \`\`\` $filtered_failures \`\`\` " ((failures++)) else log_warn "Pytest exited non-zero but all failures are pre-existing. Passing gate." fi } echo "$test_output" >> "$LOG_FILE" fi # mypy (if available) if command -v mypy >/dev/null 2>&1 && [ -f "$PROJECT_ROOT/mypy.ini" ] || [ -f "$PROJECT_ROOT/setup.cfg" ]; then log "Running mypy..." local mypy_output mypy_output=$(cd "$PROJECT_ROOT" && mypy . 2>&1) || { log_error "Mypy type check failed" failure_details+=" ### Mypy Type Check Failures \`\`\` $mypy_output \`\`\` " ((failures++)) } echo "$mypy_output" >> "$LOG_FILE" fi else log_warn "No recognized project type found - skipping static analysis" return 0 fi # Check results if [ $failures -gt 0 ]; then log_error "Static analysis gate failed with $failures issue(s)" # Store failures for fix phase LAST_STATIC_ANALYSIS_FAILURES="## Static Analysis Failures for $story_id The following REAL tooling failures were detected. These are NOT AI-generated - they are actual errors from running the project's tooling. $failure_details ## Instructions Fix ALL the errors shown above. These are real compilation/test failures that must be resolved." add_metrics_issue "$story_id" "static_analysis_failed" "Static analysis gate failed with $failures issue(s)" return 1 fi log_success "Static analysis gate passed: $story_id" return 0 } # Maximum number of fix attempts before giving up MAX_FIX_ATTEMPTS=3 MAX_ARCH_FIX_ATTEMPTS=2 MAX_TEST_QUALITY_FIX_ATTEMPTS=2 MAX_TRACEABILITY_FIX_ATTEMPTS=3 MAX_STATIC_ANALYSIS_FIX_ATTEMPTS=3 # Global variable to store arch violations for fix loop LAST_ARCH_VIOLATIONS="" # Global variable to store test quality issues for fix loop LAST_TEST_QUALITY_ISSUES="" # Global variable to store traceability gaps for fix loop LAST_TRACEABILITY_GAPS="" # Global variable to store static analysis failures for fix loop LAST_STATIC_ANALYSIS_FAILURES="" execute_arch_compliance_phase() { local story_file="$1" local story_id=$(basename "$story_file" .md) # Reset violations LAST_ARCH_VIOLATIONS="" log ">>> ARCH COMPLIANCE: $story_id (using step template, fresh context)" # Verify step template exists if [ ! -f "$ARCH_COMPLIANCE_STEP" ]; then log_warn "Step template not found: $ARCH_COMPLIANCE_STEP" log_error "BMAD arch compliance step template is required" return 1 fi # Load architecture file path (pass as path, don't embed) local arch_file="" for search_path in "$PROJECT_ROOT/docs/architecture.md" "$PROJECT_ROOT/docs/architecture/architecture.md" "$PROJECT_ROOT/architecture.md"; do if [ -f "$search_path" ]; then arch_file="$search_path" break fi done if [ -z "$arch_file" ]; then log_warn "No architecture.md found - skipping compliance check" return 0 fi # Extra context: architecture file path for Claude to read on-demand local extra_context="## Architecture Reference **Read the architecture document at:** $arch_file Use this to validate compliance. Focus only on structural/architectural issues, not code quality." # Completion signals for arch compliance local completion_signals="## Completion Signals If compliant (no HIGH/MEDIUM violations or all fixed): Output: ARCH COMPLIANT: $story_id Or: ARCH COMPLIANT WITH FIXES: $story_id - Fixed N violations If HIGH violations cannot be fixed: First output: \`\`\` ARCH VIOLATIONS START - [HIGH] Description (file:line) - [MEDIUM] Description (file:line) ARCH VIOLATIONS END \`\`\` Then: ARCH VIOLATIONS: $story_id - [summary] Stage any fixes with: git add ..." # Build the lean prompt using step template + automation wrapper local arch_prompt arch_prompt=$(build_automated_prompt "$ARCH_COMPLIANCE_STEP" "$story_file" "arch-compliance" "$extra_context" "$completion_signals") # Log prompt size in verbose mode log_prompt_size "$arch_prompt" "arch-compliance" if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would execute arch compliance step template for $story_id" echo "[DRY RUN] Template: $ARCH_COMPLIANCE_STEP" return 0 fi # Pipe to file to avoid memory bloat set_span_context "arch" "$story_id" run_claude_to_file "$arch_prompt" local result result=$(read_phase_tail) if echo "$result" | grep -q "ARCH COMPLIANT"; then log_success "Architecture compliant: $story_id" return 0 elif echo "$result" | grep -q "ARCH VIOLATIONS"; then log_error "Architecture violations found: $story_id" echo "$result" | grep "ARCH VIOLATIONS" # Extract violations for fix loop LAST_ARCH_VIOLATIONS=$(echo "$result" | sed -n '/ARCH VIOLATIONS START/,/ARCH VIOLATIONS END/p' | grep -E '^\s*-\s*\[(HIGH|MEDIUM)\]' || true) if [ -n "$LAST_ARCH_VIOLATIONS" ]; then log "Captured architecture violations for fix loop" fi return 1 else log_warn "Architecture check did not complete cleanly: $story_id" return 0 # Don't block on unclear result fi } execute_test_quality_phase() { local story_file="$1" local story_id=$(basename "$story_file" .md) # Reset issues LAST_TEST_QUALITY_ISSUES="" log ">>> TEST QUALITY: $story_id (using step template, fresh context)" # Verify step template exists if [ ! -f "$TEST_QUALITY_STEP" ]; then log_warn "Step template not found: $TEST_QUALITY_STEP" log_error "BMAD test quality step template is required" return 1 fi # Completion signals for test quality phase local completion_signals="## Completion Signals If quality approved (score ≥70, no critical/high remaining): Output: TEST QUALITY APPROVED: $story_id - Score: N/100 Or: TEST QUALITY APPROVED WITH FIXES: $story_id - Score: N/100, Fixed M issues If quality concerns (score 60-69): Output: TEST QUALITY CONCERNS: $story_id - Score: N/100 If quality failed (score <60 or unfixable critical issues): First output: \`\`\` TEST QUALITY ISSUES START - [CRITICAL] Description (file:line) - [HIGH] Description (file:line) TEST QUALITY ISSUES END \`\`\` Then: TEST QUALITY FAILED: $story_id - Score: N/100 Stage any fixes with: git add ..." # Build the lean prompt using step template + automation wrapper local quality_prompt quality_prompt=$(build_automated_prompt "$TEST_QUALITY_STEP" "$story_file" "test-quality" "" "$completion_signals") # Log prompt size in verbose mode log_prompt_size "$quality_prompt" "test-quality" if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would execute test quality step template for $story_id" echo "[DRY RUN] Template: $TEST_QUALITY_STEP" return 0 fi # Pipe to file to avoid memory bloat set_span_context "test_quality" "$story_id" run_claude_to_file "$quality_prompt" local result result=$(read_phase_tail) if echo "$result" | grep -q "TEST QUALITY APPROVED"; then log_success "Test quality approved: $story_id" return 0 elif echo "$result" | grep -q "TEST QUALITY CONCERNS"; then log_warn "Test quality concerns: $story_id" return 0 # Concerns don't block elif echo "$result" | grep -q "TEST QUALITY FAILED"; then log_error "Test quality failed: $story_id" echo "$result" | grep "TEST QUALITY FAILED" # Extract issues for fix loop LAST_TEST_QUALITY_ISSUES=$(echo "$result" | sed -n '/TEST QUALITY ISSUES START/,/TEST QUALITY ISSUES END/p' | grep -E '^\s*-\s*\[(CRITICAL|HIGH)\]' || true) if [ -n "$LAST_TEST_QUALITY_ISSUES" ]; then log "Captured test quality issues for fix loop" fi return 1 else log_warn "Test quality check did not complete cleanly: $story_id" return 0 # Don't block on unclear result fi } execute_traceability_phase() { log ">>> TRACEABILITY CHECK: Epic $EPIC_ID (using step template, fresh context)" # Reset gaps LAST_TRACEABILITY_GAPS="" # Ensure output directory exists mkdir -p "$TRACEABILITY_DIR" # Verify step template exists if [ ! -f "$TRACEABILITY_STEP" ]; then log_warn "Step template not found: $TRACEABILITY_STEP" log_error "BMAD traceability step template is required" return 1 fi # Build story file paths list instead of embedding all contents local story_count=${#STORIES[@]} local story_paths_list="" for story_file in "${STORIES[@]}"; do local sid=$(basename "$story_file" .md) story_paths_list+="- $sid: $story_file " done # Load step template and perform substitutions local template template=$(cat "$TRACEABILITY_STEP") template="${template//\{epic_id\}/$EPIC_ID}" template="${template//\{date\}/$(date '+%Y-%m-%d')}" # Extra context: epic and story file paths for Claude to read on-demand local extra_context="## Epic Definition **Read the epic file at:** $EPIC_FILE **Epic ID:** $EPIC_ID ## Completed Stories ($story_count total) Read each story file as needed during analysis: $story_paths_list ## Output Location Save traceability matrix to: $TRACEABILITY_DIR/epic-${EPIC_ID}-traceability.md" # Completion signals for traceability phase local completion_signals="## Completion Signals If PASS (P0=100%, P1≥90%): Output: TRACEABILITY PASS: $EPIC_ID - P0: N%, P1: M%, Overall: O% If CONCERNS (P0=100%, P1 80-89%): Output: TRACEABILITY CONCERNS: $EPIC_ID - P1 at N% (below 90%) If FAIL (P0<100% or P1<80%): First output gaps for self-healing: \`\`\` TRACEABILITY GAPS START GAP: {story_id}|AC-{n}|{priority}|{description}|{recommended_test_id}|{test_level} SPEC: Given: {precondition} When: {action} Then: {expected result} GAP: ... TRACEABILITY GAPS END \`\`\` Then: TRACEABILITY FAIL: $EPIC_ID - P0: N%, P1: M%, X critical gaps" # Build the prompt - traceability doesn't use a story_file, so pass EPIC_FILE local trace_prompt="## Automated Execution Mode ### Rules - AUTOMATED execution - do NOT pause for user confirmation at any step - When instructions say 'ask user', make a reasonable autonomous decision - Complete the ENTIRE workflow in a single execution ### Execution Variables (Pre-resolved) - epic_id: $EPIC_ID - epic_file: $EPIC_FILE - story_count: $story_count - project_root: $PROJECT_ROOT - traceability_dir: $TRACEABILITY_DIR - date: $(date '+%Y-%m-%d') ## Step Template Instructions $template $extra_context $completion_signals ## Begin Execution Analyze traceability now. Read story files on-demand as needed." # Log prompt size in verbose mode log_prompt_size "$trace_prompt" "traceability" if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would execute traceability step template for Epic $EPIC_ID" echo "[DRY RUN] Template: $TRACEABILITY_STEP" return 0 fi # Pipe to file to avoid memory bloat set_span_context "trace" "epic-$EPIC_ID" run_claude_to_file "$trace_prompt" local result result=$(read_phase_tail) if echo "$result" | grep -q "TRACEABILITY PASS"; then log_success "Traceability passed: Epic $EPIC_ID" return 0 elif echo "$result" | grep -q "TRACEABILITY CONCERNS"; then log_warn "Traceability concerns: Epic $EPIC_ID" return 0 # Concerns don't block elif echo "$result" | grep -q "TRACEABILITY FAIL"; then log_error "Traceability failed: Epic $EPIC_ID" echo "$result" | grep "TRACEABILITY FAIL" # Extract gaps for self-healing LAST_TRACEABILITY_GAPS=$(echo "$result" | sed -n '/TRACEABILITY GAPS START/,/TRACEABILITY GAPS END/p' || true) if [ -n "$LAST_TRACEABILITY_GAPS" ]; then log "Captured traceability gaps for self-healing" fi return 1 else log_warn "Traceability check did not complete cleanly" return 0 # Don't block on unclear result fi } execute_traceability_fix_phase() { local gaps="$1" local attempt_num="$2" log ">>> TRACEABILITY FIX: Epic $EPIC_ID (attempt $attempt_num, generating missing tests)" local fix_prompt="You are a Test Architect generating tests to close coverage gaps. ## Your Task Generate missing tests for Epic: $EPIC_ID (attempt $attempt_num of $MAX_TRACEABILITY_FIX_ATTEMPTS) ### CRITICAL RULES - Generate ONLY the tests specified in the gaps - Follow existing test patterns in the codebase - Run each test to verify it passes - Stage changes with explicit paths: git add ... ## Gaps to Address $gaps ## Instructions For each GAP: 1. Parse the specification (Given/When/Then) 2. Create the test file if needed 3. Implement the test following the spec 4. Use existing patterns from codebase 5. Run the test 6. Stage changes ## Completion Signals If all tests generated: Output: TEST GENERATION COMPLETE: Generated N tests If partial success: Output: TEST GENERATION PARTIAL: Generated N of M tests - [reason] ## Begin Execution Generate missing tests now." if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would generate missing tests for Epic $EPIC_ID (attempt $attempt_num)" return 0 fi # Pipe to file to avoid memory bloat set_span_context "trace_fix" "epic-$EPIC_ID" run_claude_to_file "$fix_prompt" local result result=$(read_phase_tail) if echo "$result" | grep -q "TEST GENERATION COMPLETE"; then log_success "Test generation complete for Epic $EPIC_ID" return 0 elif echo "$result" | grep -q "TEST GENERATION PARTIAL"; then log_warn "Partial test generation for Epic $EPIC_ID" return 1 else log_error "Test generation did not complete cleanly" return 1 fi } execute_story_with_fix_loop() { local story_file="$1" local story_id=$(basename "$story_file" .md) local fix_attempt=0 local arch_fix_attempt=0 local test_quality_fix_attempt=0 local needs_fixes=false # DESIGN PHASE (Context 0) - Pre-implementation planning if [ "$SKIP_DESIGN" = false ] && type execute_design_phase >/dev/null 2>&1; then if ! execute_design_phase "$story_file"; then log_warn "Design phase did not complete cleanly for $story_id - proceeding" # Don't fail - design is advisory fi fi # TDD PHASES (Test-First Development) # Enabled by default, skip with --skip-tdd or individual --skip-test-spec/--skip-test-impl if [ "$SKIP_TDD" = false ]; then # TEST SPEC PHASE - Generate test specifications from acceptance criteria if [ "$SKIP_TEST_SPEC" = false ] && type execute_test_spec_phase >/dev/null 2>&1; then if ! execute_test_spec_phase "$story_file"; then log_warn "Test spec phase did not complete cleanly for $story_id - proceeding" # Don't fail - spec generation is advisory fi fi # TEST IMPL PHASE - Create failing tests from specifications if [ "$SKIP_TEST_IMPL" = false ] && type execute_test_impl_phase >/dev/null 2>&1; then # Only run if we have test specs (either just generated or loaded from file) if [ -n "$LAST_TEST_SPEC" ] || [ -f "$TEST_SPEC_DIR/${story_id}-test-spec.md" ] 2>/dev/null; then if ! execute_test_impl_phase "$story_file"; then log_warn "Test impl phase did not complete cleanly for $story_id - proceeding" # Don't fail - test impl is advisory in first iteration fi else log_warn "No test specifications available - skipping test implementation" fi fi # TEST VERIFICATION PHASE - Verify tests fail appropriately if [ "$SKIP_TEST_IMPL" = false ] && type execute_test_verification_phase >/dev/null 2>&1; then # Only verify if we just created tests if type get_last_test_spec >/dev/null 2>&1 && [ -n "$(get_last_test_spec)" ]; then if ! execute_test_verification_phase "$story_file"; then log_warn "Test verification had issues - proceeding to dev phase" # Don't fail - verification is informational fi fi fi fi # Capture test failure baseline before dev phase (for filtering in fix phase) # This allows us to distinguish new failures from pre-existing ones if type capture_failure_baseline >/dev/null 2>&1; then capture_failure_baseline "$story_id" fi # DEV PHASE (Context 1) - Now implements to make tests pass (if TDD enabled) if ! execute_dev_phase "$story_file"; then log_error "Dev phase failed for $story_id" return 1 fi # STATIC ANALYSIS GATE (Real Tooling) - Per Story local static_analysis_fix_attempt=0 if [ "$SKIP_STATIC_ANALYSIS" = false ]; then while true; do if execute_static_analysis_gate "$story_file"; then log_success "Static analysis passed: $story_id" break fi # Check if we have failures to fix if [ -z "$LAST_STATIC_ANALYSIS_FAILURES" ]; then log_warn "Static analysis unclear, proceeding anyway" break fi ((static_analysis_fix_attempt++)) if [ $static_analysis_fix_attempt -gt $MAX_STATIC_ANALYSIS_FIX_ATTEMPTS ]; then log_error "Max static analysis fix attempts ($MAX_STATIC_ANALYSIS_FIX_ATTEMPTS) reached for $story_id" add_metrics_issue "$story_id" "static_analysis_max_retries" "Static analysis failures after $MAX_STATIC_ANALYSIS_FIX_ATTEMPTS attempts" # Fail the story - real tooling errors must be fixed return 1 fi log_warn "Static analysis failed, attempting fix $static_analysis_fix_attempt of $MAX_STATIC_ANALYSIS_FIX_ATTEMPTS" # Use the regular fix phase with static analysis context if ! execute_fix_phase "$story_file" "$LAST_STATIC_ANALYSIS_FAILURES" "$static_analysis_fix_attempt"; then log_warn "Static analysis fix incomplete, re-running gate..." fi done fi # ARCHITECTURE COMPLIANCE CHECK (Context 2) - Per Story if [ "$SKIP_ARCH" = false ]; then while true; do if execute_arch_compliance_phase "$story_file"; then log_success "Architecture compliant: $story_id" break fi # Check if we have violations to fix if [ -z "$LAST_ARCH_VIOLATIONS" ]; then log_warn "Arch check unclear, proceeding anyway" break fi ((arch_fix_attempt++)) if [ $arch_fix_attempt -gt $MAX_ARCH_FIX_ATTEMPTS ]; then log_error "Max arch fix attempts ($MAX_ARCH_FIX_ATTEMPTS) reached for $story_id" add_metrics_issue "$story_id" "arch_violations" "Architecture violations after $MAX_ARCH_FIX_ATTEMPTS attempts" # Don't fail the story, proceed with violations documented break fi log_warn "Arch violations found, attempting fix $arch_fix_attempt of $MAX_ARCH_FIX_ATTEMPTS" # Use the regular fix phase with arch context if ! execute_fix_phase "$story_file" "$LAST_ARCH_VIOLATIONS" "$arch_fix_attempt"; then log_warn "Arch fix incomplete, continuing..." fi done fi # REVIEW + FIX LOOP while true; do # REVIEW PHASE (Fresh Context) if execute_review_phase "$story_file"; then # Review passed - proceed to test quality log_success "Story passed review: $story_id" break fi # Review failed - check if we have findings to fix if [ -z "$LAST_REVIEW_FINDINGS" ]; then log_error "Review failed but no findings captured for $story_id" return 1 fi # First failure - record that this story required fixes if [ "$needs_fixes" = false ]; then needs_fixes=true record_story_required_fixes "$story_id" fi # Check if we've exhausted fix attempts ((fix_attempt++)) if [ $fix_attempt -gt $MAX_FIX_ATTEMPTS ]; then log_error "Max fix attempts ($MAX_FIX_ATTEMPTS) reached for $story_id" record_fix_attempt "$story_id" "$fix_attempt" "max_retries" add_metrics_issue "$story_id" "max_retries_exhausted" "Failed after $MAX_FIX_ATTEMPTS fix attempts" return 1 fi log_warn "Review failed, attempting fix $fix_attempt of $MAX_FIX_ATTEMPTS for $story_id" # FIX PHASE (New Context) if ! execute_fix_phase "$story_file" "$LAST_REVIEW_FINDINGS" "$fix_attempt"; then log_error "Fix phase failed for $story_id (attempt $fix_attempt)" # Continue to next attempt - the review will catch remaining issues fi # Loop back to review phase to verify fixes log "Re-running review after fix attempt $fix_attempt..." done # TEST QUALITY REVIEW (Fresh Context) - Per Story if [ "$SKIP_TEST_QUALITY" = false ]; then while true; do if execute_test_quality_phase "$story_file"; then log_success "Test quality approved: $story_id" break fi # Check if we have issues to fix if [ -z "$LAST_TEST_QUALITY_ISSUES" ]; then log_warn "Test quality check unclear, proceeding anyway" break fi ((test_quality_fix_attempt++)) if [ $test_quality_fix_attempt -gt $MAX_TEST_QUALITY_FIX_ATTEMPTS ]; then log_warn "Max test quality fix attempts ($MAX_TEST_QUALITY_FIX_ATTEMPTS) reached for $story_id" add_metrics_issue "$story_id" "test_quality_concerns" "Test quality issues after $MAX_TEST_QUALITY_FIX_ATTEMPTS attempts" # Don't fail the story, proceed with concerns documented break fi log_warn "Test quality issues found, attempting fix $test_quality_fix_attempt of $MAX_TEST_QUALITY_FIX_ATTEMPTS" # Use the regular fix phase with test quality context if ! execute_fix_phase "$story_file" "$LAST_TEST_QUALITY_ISSUES" "$test_quality_fix_attempt"; then log_warn "Test quality fix incomplete, continuing..." fi done fi # REGRESSION GATE (if module loaded and not skipped) if [ "$SKIP_REGRESSION" = false ] && type execute_regression_gate >/dev/null 2>&1; then if ! execute_regression_gate "$story_id"; then log_error "Regression detected in $story_id" add_metrics_issue "$story_id" "regression_detected" "Test count decreased after implementation" # Don't fail the story - regression is a warning that should be investigated log_warn "Proceeding despite regression - investigate manually" fi fi return 0 } commit_story() { local story_id="$1" if [ "$NO_COMMIT" = true ]; then log "Skipping commit (--no-commit)" return 0 fi if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would commit: feat(epic-$EPIC_ID): complete $story_id" return 0 fi # Safety check for sensitive files before committing if ! check_sensitive_files; then log_error "Commit aborted due to sensitive files. Fix .gitignore or use --no-commit" return 1 fi # Use git add -u (tracked files only) instead of git add -A # This prevents accidentally staging untracked files like .env, credentials, etc. # Claude prompts instruct it to stage specific new files explicitly git add -u # Check if there's anything to commit local staged_count staged_count=$(git diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ') if [ "$staged_count" -eq 0 ]; then log_warn "Nothing to commit for $story_id" return 0 fi git commit -m "feat(epic-$EPIC_ID): complete $story_id" || { log_warn "Commit failed for $story_id" return 1 } log_success "Committed: $story_id" } generate_uat() { log ">>> GENERATING UAT DOCUMENT (using step template, fresh context)" # Verify step template exists if [ ! -f "$UAT_STEP_TEMPLATE" ]; then log_warn "Step template not found: $UAT_STEP_TEMPLATE" log_error "BMAD UAT step template is required" return 1 fi # Count stories and build paths list local story_count=${#STORIES[@]} local story_paths_list="" for story_file in "${STORIES[@]}"; do local sid=$(basename "$story_file" .md) story_paths_list+="- $sid: $story_file " done # Load step template and perform substitutions local template template=$(cat "$UAT_STEP_TEMPLATE") template="${template//\{epic_id\}/$EPIC_ID}" template="${template//\{date\}/$(date '+%Y-%m-%d')}" template="${template//\{story_count\}/$story_count}" # Extra context: file paths + doc template path + guidelines local extra_context="## Epic Definition **Read the epic file at:** $EPIC_FILE **Epic ID:** $EPIC_ID ## Completed Stories ($story_count total) Read each story file as needed: $story_paths_list ## UAT Document Template **Read the UAT document template at:** $UAT_DOC_TEMPLATE Follow this template structure for the generated document. ## Output 1. Generate the complete UAT document following the template structure 2. Save to: $UAT_DIR/epic-${EPIC_ID}-uat.md 3. Output exactly: UAT GENERATED: $UAT_DIR/epic-${EPIC_ID}-uat.md" # Build the prompt local uat_prompt="## Automated Execution Mode ### Rules - AUTOMATED execution - do NOT pause for user confirmation at any step - Write for NON-TECHNICAL users who can use software but don't know how it's built - Focus on user journeys, not implementation details - Generate clear, actionable test scenarios with binary pass/fail criteria - Complete the entire document in a single execution ### Execution Variables (Pre-resolved) - epic_id: $EPIC_ID - epic_file: $EPIC_FILE - story_count: $story_count - project_root: $PROJECT_ROOT - output_path: $UAT_DIR/epic-${EPIC_ID}-uat.md - date: $(date '+%Y-%m-%d') ## Step Template Instructions $template $extra_context ## Begin Execution Generate the UAT document now. Read story files on-demand as needed." # Log prompt size in verbose mode log_prompt_size "$uat_prompt" "uat-generation" if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would generate UAT document using step template" echo "[DRY RUN] Template: $UAT_STEP_TEMPLATE" return 0 fi # Pipe to file to avoid memory bloat set_span_context "uat" "epic-$EPIC_ID" run_claude_to_file "$uat_prompt" local result result=$(read_phase_tail) if echo "$result" | grep -q "UAT GENERATED"; then log_success "UAT document generated" else log_warn "UAT generation may not have completed cleanly" fi # Commit UAT document if [ "$NO_COMMIT" = false ]; then git add "$UAT_DIR/epic-${EPIC_ID}-uat.md" 2>/dev/null || true git commit -m "docs(epic-$EPIC_ID): add UAT document" 2>/dev/null || true fi } # ============================================================================= # Main Execution Loop # ============================================================================= log "==========================================" log "Starting execution of ${#STORIES[@]} stories" log "==========================================" # Initialize counters (may already be restored from metrics or checkpoint) : "${COMPLETED:=0}" : "${FAILED:=0}" : "${SKIPPED:=0}" START_TIME=$(date +%s) STARTED=false # Track current story index for checkpoint/resume STORY_INDEX=0 for story_file in "${STORIES[@]}"; do story_id=$(basename "$story_file" .md) # L1: Resume capability - skip stories before resume index if [ "$RESUME_START_INDEX" -gt 0 ] && [ "$STORY_INDEX" -lt "$RESUME_START_INDEX" ]; then log_warn "Skipping $story_id (resuming from index $RESUME_START_INDEX)" ((STORY_INDEX++)) CURRENT_STORY_INDEX=$STORY_INDEX continue fi # --start-from: Skip stories until we reach the specified one if [ -n "$START_FROM" ] && [ "$STARTED" = false ]; then if [[ "$story_id" == *"$START_FROM"* ]]; then STARTED=true else log_warn "Skipping $story_id (waiting for $START_FROM)" # Only count as skipped if this is a fresh run (no prior metrics) if [ "${METRICS_RESUMED:-false}" = false ]; then ((SKIPPED++)) update_story_metrics "skipped" fi ((STORY_INDEX++)) CURRENT_STORY_INDEX=$STORY_INDEX continue fi fi # --skip-done: Skip stories with Status: done (case-insensitive) if [ "$SKIP_DONE" = true ]; then if grep -qi "^Status:.*done" "$story_file" 2>/dev/null; then log_warn "Skipping $story_id (Status: Done)" # Only count as skipped if this is a fresh run (no prior metrics) if [ "${METRICS_RESUMED:-false}" = false ]; then ((SKIPPED++)) update_story_metrics "skipped" fi ((STORY_INDEX++)) CURRENT_STORY_INDEX=$STORY_INDEX continue fi fi echo "" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log "Story: $story_id" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # Execute story with fix loop (dev → review → fix loop if needed) if [ "$SKIP_REVIEW" = false ]; then # Full flow: dev → review (with fix loop if issues found) if ! execute_story_with_fix_loop "$story_file"; then log_error "Story execution failed for $story_id" ((FAILED++)) ((STORY_INDEX++)) CURRENT_STORY_INDEX=$STORY_INDEX update_story_metrics "failed" # Save checkpoint on failure too if type save_checkpoint >/dev/null 2>&1; then save_checkpoint "$STORY_INDEX" "$story_id" "$COMPLETED" "$FAILED" "$SKIPPED" fi # Flush log to repo after story failure flush_log_to_repo continue fi else # Skip review: just run dev phase if ! execute_dev_phase "$story_file"; then log_error "Dev phase failed for $story_id" ((FAILED++)) ((STORY_INDEX++)) CURRENT_STORY_INDEX=$STORY_INDEX update_story_metrics "failed" add_metrics_issue "$story_id" "dev_phase_failed" "Development phase did not complete" # Save checkpoint on failure too if type save_checkpoint >/dev/null 2>&1; then save_checkpoint "$STORY_INDEX" "$story_id" "$COMPLETED" "$FAILED" "$SKIPPED" fi # Flush log to repo after story failure flush_log_to_repo continue fi fi # MARK STORY AS DONE # Update both story file and sprint-status.yaml after successful review if [ "$DRY_RUN" = false ]; then mark_story_done "$story_file" else echo "[DRY RUN] Would mark story as done: $story_id" fi # COMMIT commit_story "$story_id" ((COMPLETED++)) update_story_metrics "completed" log_success "Story complete: $story_id ($COMPLETED/${#STORIES[@]})" # Kill orphaned node/test processes between stories kill_orphaned_test_processes # Truncate log file between stories to prevent unbounded growth. # Each Claude phase appends via tee -a, so across 6-7 phases per story # the log can grow to hundreds of MB. Keep only the last 64KB as context. if [ -f "$LOG_FILE" ]; then tail -c 65536 "$LOG_FILE" > "${LOG_FILE}.tmp" 2>/dev/null && mv "${LOG_FILE}.tmp" "$LOG_FILE" 2>/dev/null || true fi # Track progress for checkpoint/resume ((STORY_INDEX++)) CURRENT_STORY_INDEX=$STORY_INDEX # L1: Save checkpoint after each completed story if type save_checkpoint >/dev/null 2>&1; then save_checkpoint "$STORY_INDEX" "$story_id" "$COMPLETED" "$FAILED" "$SKIPPED" fi done # ============================================================================= # Contract Validation (Per-Epic: execute the harness against the live app) # ============================================================================= # v1 granularity: runs once after all stories are implemented (the app reflects # the full epic). Brings the sample env up, runs backend cases + UI flows, and # self-heals via a bounded fix loop. Non-blocking mid-run, but sets the epic # exit code if contracts never pass. if [ "$SKIP_CONTRACT_VALIDATION" != true ] && [ -n "${CONTRACT_HARNESS_FILE:-}" ] && type contract_validation_gate >/dev/null 2>&1; then echo "" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log "Contract Validation (API + UI)" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" contract_validation_gate "$CONTRACT_HARNESS_FILE" || true fi # ============================================================================= # Traceability Check (Per-Epic, with Self-Healing) # ============================================================================= if [ "$SKIP_TRACEABILITY" = false ]; then echo "" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log "Requirements Traceability Check" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" trace_fix_attempt=0 while true; do if execute_traceability_phase; then log_success "Traceability check passed for Epic $EPIC_ID" break fi # Check if we have gaps to fix if [ -z "$LAST_TRACEABILITY_GAPS" ]; then log_warn "Traceability check unclear, proceeding to UAT" break fi ((trace_fix_attempt++)) if [ $trace_fix_attempt -gt $MAX_TRACEABILITY_FIX_ATTEMPTS ]; then log_warn "Max traceability fix attempts ($MAX_TRACEABILITY_FIX_ATTEMPTS) reached" add_metrics_issue "epic-$EPIC_ID" "traceability_gaps" "Coverage gaps remain after $MAX_TRACEABILITY_FIX_ATTEMPTS attempts" # Don't fail the epic, proceed with gaps documented break fi log_warn "Traceability gaps found, generating missing tests (attempt $trace_fix_attempt of $MAX_TRACEABILITY_FIX_ATTEMPTS)" if ! execute_traceability_fix_phase "$LAST_TRACEABILITY_GAPS" "$trace_fix_attempt"; then log_warn "Test generation incomplete, continuing..." fi # Commit any generated tests if [ "$NO_COMMIT" = false ] && [ "$DRY_RUN" = false ]; then # Use git add -u for safety (tracked files only) git add -u git commit -m "test(epic-$EPIC_ID): generate missing tests for traceability (attempt $trace_fix_attempt)" 2>/dev/null || true fi log "Re-running traceability check..." done fi # ============================================================================= # UAT Generation (Fresh Context) # ============================================================================= echo "" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log "UAT Document Generation" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" generate_uat # ============================================================================= # Summary # ============================================================================= END_TIME=$(date +%s) DURATION=$((END_TIME - START_TIME)) # Finalize metrics with final counts finalize_metrics "${#STORIES[@]}" "$COMPLETED" "$FAILED" "$SKIPPED" "$DURATION" echo "" log "==========================================" log "EPIC EXECUTION COMPLETE" log "==========================================" echo "" echo " Epic: $EPIC_ID" echo " Duration: ${DURATION}s" echo " Stories: ${#STORIES[@]}" echo " Skipped: $SKIPPED" echo " Completed: $COMPLETED" echo " Failed: $FAILED" echo "" echo " Deliverables:" echo " - Stories: $STORIES_DIR/" echo " - UAT: $UAT_DIR/epic-${EPIC_ID}-uat.md" echo " - Traceability: $TRACEABILITY_DIR/epic-${EPIC_ID}-traceability.md" echo " - Metrics: $METRICS_FILE" echo " - Log: $LOGS_DIR/epic-${EPIC_ID}-.log (saved on exit)" echo "" # Contract preflight is an exit-code-honest gate: if a declared harness was # missing prerequisites, fail the run (this is what makes --dry-run usable as a # CI readiness check). if [ "${PREFLIGHT_FAILED:-false}" = true ]; then log_warn "Contract preflight reported missing prerequisites - see the readiness report above" exit 1 fi # Contract validation is an exit-code-honest gate: if API/UI contracts never # passed (after self-heal attempts), fail the epic. if [ "${CONTRACT_VALIDATION_FAILED:-false}" = true ]; then log_warn "Contract validation did not pass - see failures above" exit 1 fi if [ $FAILED -gt 0 ]; then log_warn "$FAILED stories failed - check log for details" log "Checkpoint preserved for resume capability" exit 1 fi # L1: Clear checkpoint on successful completion if type clear_checkpoint >/dev/null 2>&1; then clear_checkpoint fi log_success "All stories completed successfully" echo "" echo "Next step: Run UAT document with a human tester" echo ""