#!/bin/bash # # BMAD UAT Validate - Automated UAT Scenario Execution with Self-Healing Fix Loop # # Usage: ./uat-validate.sh [options] # # Options: # --gate-mode=MODE Validation mode: quick|full|skip (default: quick) # --max-retries=N Max fix attempts before halt (default: 2) # --skip-manual Skip manual-only scenarios (default: skip) # --verbose Show detailed output # --dry-run Show what would be executed without running # --timeout=SECONDS Timeout per scenario (default: 30) # # Exit Codes: # 0 - UAT PASS (all automatable scenarios passed) # 1 - UAT FAIL (fixable, retries remain or self-heal succeeded) # 2 - UAT FAIL (max retries exceeded) # set -e # ============================================================================= # Section 1: Configuration # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" BMAD_DIR="$PROJECT_ROOT/.bmad" UAT_DIR="$PROJECT_ROOT/docs/uat" SPRINT_ARTIFACTS_DIR="$PROJECT_ROOT/docs/sprint-artifacts" METRICS_DIR="$SPRINT_ARTIFACTS_DIR/metrics" FIX_DIR="$SPRINT_ARTIFACTS_DIR/uat-fixes" STORIES_DIR="$PROJECT_ROOT/docs/stories" LOG_FILE="/tmp/bmad-uat-validate-$$.log" # Default configuration UAT_GATE_MODE="quick" MAX_RETRIES=2 SKIP_MANUAL=true VERBOSE=false DRY_RUN=false TIMEOUT_SECONDS=30 # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # No Color # ============================================================================= # Section 2: Helper Functions # ============================================================================= log() { echo -e "${BLUE}[UAT]${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" } log_success() { echo -e "${GREEN}[PASS]${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [PASS] $1" >> "$LOG_FILE" } log_error() { echo -e "${RED}[FAIL]${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [FAIL] $1" >> "$LOG_FILE" } log_warn() { echo -e "${YELLOW}[!]${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $1" >> "$LOG_FILE" } log_section() { echo "" echo -e "${BOLD}───────────────────────────────────────────────────────────${NC}" echo -e "${BOLD} $1${NC}" echo -e "${BOLD}───────────────────────────────────────────────────────────${NC}" } log_header() { echo "" echo -e "${CYAN}${BOLD}═══════════════════════════════════════════════════════════${NC}" echo -e "${CYAN}${BOLD} $1${NC}" echo -e "${CYAN}${BOLD}═══════════════════════════════════════════════════════════${NC}" echo "" } # ============================================================================= # Section 3: Argument Parsing # ============================================================================= EPIC_ID="" while [[ $# -gt 0 ]]; do case $1 in --gate-mode=*) UAT_GATE_MODE="${1#*=}" shift ;; --max-retries=*) MAX_RETRIES="${1#*=}" shift ;; --skip-manual) SKIP_MANUAL=true shift ;; --include-manual) SKIP_MANUAL=false shift ;; --verbose) VERBOSE=true shift ;; --dry-run) DRY_RUN=true shift ;; --timeout=*) TIMEOUT_SECONDS="${1#*=}" shift ;; -*) echo "Unknown option: $1" exit 1 ;; *) EPIC_ID="$1" shift ;; esac done if [ -z "$EPIC_ID" ]; then echo "Usage: $0 [options]" echo "" echo "Options:" echo " --gate-mode=MODE Validation mode: quick|full|skip (default: quick)" echo " --max-retries=N Max fix attempts before halt (default: 2)" echo " --skip-manual Skip manual-only scenarios (default)" echo " --include-manual Include manual scenarios in checklist" echo " --verbose Detailed output" echo " --dry-run Show what would be executed" echo " --timeout=SECONDS Timeout per scenario (default: 30)" echo "" echo "Exit Codes:" echo " 0 - UAT PASS" echo " 1 - UAT FAIL (fixable)" echo " 2 - UAT FAIL (max retries exceeded)" exit 1 fi # Validate gate mode if [[ ! "$UAT_GATE_MODE" =~ ^(quick|full|skip)$ ]]; then echo "Invalid gate mode: $UAT_GATE_MODE" echo "Valid modes: quick, full, skip" exit 1 fi # ============================================================================= # Section 4: UAT Document Loading # ============================================================================= load_uat_document() { local epic_id="$1" # Find UAT document (try multiple patterns) UAT_FILE="" for pattern in "epic-${epic_id}-uat.md" "epic-0${epic_id}-uat.md" "${epic_id}-uat.md"; do found=$(find "$UAT_DIR" -name "$pattern" 2>/dev/null | head -1) if [ -n "$found" ]; then UAT_FILE="$found" break fi done if [ -z "$UAT_FILE" ] || [ ! -f "$UAT_FILE" ]; then log_error "UAT document not found for Epic $epic_id" log_error "Searched in: $UAT_DIR" log_error "Expected: epic-${epic_id}-uat.md" return 1 fi log "Found UAT document: $UAT_FILE" # Validate structure - check for scenarios section if ! grep -qE "^##.*[Ss]cenario|^##.*[Tt]est|^##.*[Cc]riteria" "$UAT_FILE"; then log_warn "UAT document may not have standard scenario sections" fi # Count scenario blocks (lines starting with ### or numbered items under Test Scenarios) SCENARIO_COUNT=$(grep -cE "^###|^[0-9]+\." "$UAT_FILE" 2>/dev/null || echo "0") log "Found approximately $SCENARIO_COUNT scenario entries" return 0 } # ============================================================================= # Section 5: Scenario Classification # ============================================================================= # Arrays to store classified scenarios declare -a AUTOMATABLE_SCENARIOS declare -a SEMI_AUTO_SCENARIOS declare -a MANUAL_SCENARIOS classify_scenarios() { local uat_file="$1" # Reset arrays AUTOMATABLE_SCENARIOS=() SEMI_AUTO_SCENARIOS=() MANUAL_SCENARIOS=() # Read the UAT file and extract scenario blocks local current_scenario="" local current_name="" local in_scenario=false local scenario_num=0 while IFS= read -r line; do # Detect scenario headers (### or numbered items) if [[ "$line" =~ ^###[[:space:]]*(.*) ]] || [[ "$line" =~ ^([0-9]+)\.[[:space:]]+(.*) ]]; then # Save previous scenario if exists if [ -n "$current_scenario" ]; then classify_single_scenario "$scenario_num" "$current_name" "$current_scenario" fi # Start new scenario ((scenario_num++)) if [[ "$line" =~ ^###[[:space:]]*(.*) ]]; then current_name="${BASH_REMATCH[1]}" else current_name="${BASH_REMATCH[2]}" fi current_scenario="$line" in_scenario=true elif [ "$in_scenario" = true ]; then # Continue accumulating scenario content current_scenario+=$'\n'"$line" fi done < "$uat_file" # Handle last scenario if [ -n "$current_scenario" ]; then classify_single_scenario "$scenario_num" "$current_name" "$current_scenario" fi log "Classification complete:" log " Automatable: ${#AUTOMATABLE_SCENARIOS[@]}" log " Semi-auto: ${#SEMI_AUTO_SCENARIOS[@]}" log " Manual: ${#MANUAL_SCENARIOS[@]}" } classify_single_scenario() { local id="$1" local name="$2" local content="$3" # Check for automatable indicators if echo "$content" | grep -qiE 'npx|npm run|yarn|node |curl |wget |pytest|jest|vitest|--version|/health|/api/|exit code|returns [0-9]|\.sh |bash '; then # Extract command from code block if present local cmd="" cmd=$(echo "$content" | grep -oE '`[^`]+`' | head -1 | tr -d '`') if [ -z "$cmd" ]; then cmd=$(echo "$content" | grep -oE 'npx [a-zA-Z0-9_-]+.*|npm run [a-zA-Z0-9_:-]+.*|curl [^[:space:]]+.*' | head -1) fi AUTOMATABLE_SCENARIOS+=("$id|$name|$cmd") [ "$VERBOSE" = true ] && log " [AUTO] Scenario $id: $name" # Check for semi-automated indicators elif echo "$content" | grep -qiE 'test-send|email|inbox|check your|verify.*manually|setup.*first|start.*server'; then SEMI_AUTO_SCENARIOS+=("$id|$name|") [ "$VERBOSE" = true ] && log " [SEMI] Scenario $id: $name" # Everything else is manual else MANUAL_SCENARIOS+=("$id|$name|") [ "$VERBOSE" = true ] && log " [MANUAL] Scenario $id: $name" fi } # ============================================================================= # Section 6: Scenario Execution # ============================================================================= # Arrays to store results declare -a PASSED_SCENARIOS declare -a FAILED_SCENARIOS declare -a FAILED_DETAILS execute_scenarios() { local gate_mode="$1" # Reset results PASSED_SCENARIOS=() FAILED_SCENARIOS=() FAILED_DETAILS=() # Skip mode - pass automatically if [ "$gate_mode" = "skip" ]; then log "Gate mode: skip - bypassing scenario execution" echo "UAT_GATE_RESULT: PASS" echo "UAT_SCENARIOS_PASSED: 0/0 (skipped)" return 0 fi # Select scenarios based on gate mode local scenarios_to_run=() if [ "$gate_mode" = "quick" ]; then scenarios_to_run=("${AUTOMATABLE_SCENARIOS[@]}") elif [ "$gate_mode" = "full" ]; then scenarios_to_run=("${AUTOMATABLE_SCENARIOS[@]}" "${SEMI_AUTO_SCENARIOS[@]}") fi if [ ${#scenarios_to_run[@]} -eq 0 ]; then log_warn "No automatable scenarios found - gate passes by default" echo "UAT_GATE_RESULT: PASS" echo "UAT_SCENARIOS_PASSED: 0/0 (none automatable)" return 0 fi log_section "Executing ${#scenarios_to_run[@]} Scenarios" for scenario_entry in "${scenarios_to_run[@]}"; do IFS='|' read -r scenario_id scenario_name scenario_cmd <<< "$scenario_entry" execute_single_scenario "$scenario_id" "$scenario_name" "$scenario_cmd" done # Report results local total=${#scenarios_to_run[@]} local passed=${#PASSED_SCENARIOS[@]} local failed=${#FAILED_SCENARIOS[@]} echo "" log "Results: $passed/$total passed" if [ $failed -eq 0 ]; then return 0 else return 1 fi } execute_single_scenario() { local scenario_id="$1" local scenario_name="$2" local scenario_cmd="$3" echo "" log "Scenario $scenario_id: $scenario_name" # If no command extracted, try to infer from name if [ -z "$scenario_cmd" ]; then log_warn " No command detected - marking as manual verification needed" FAILED_SCENARIOS+=("$scenario_id") FAILED_DETAILS+=("$scenario_id|$scenario_name|No automatable command found|manual|1") return 1 fi if [ "$VERBOSE" = true ]; then log " Command: $scenario_cmd" fi if [ "$DRY_RUN" = true ]; then echo " [DRY RUN] Would execute: $scenario_cmd" PASSED_SCENARIOS+=("$scenario_id") return 0 fi # Execute with timeout local start_time=$(date +%s%N) local output="" local exit_code=0 local stderr_file="/tmp/uat-stderr-$$.txt" # Run command with timeout set +e if command -v timeout >/dev/null 2>&1; then output=$(timeout "$TIMEOUT_SECONDS" bash -c "$scenario_cmd" 2>"$stderr_file") exit_code=$? # timeout returns 124 on timeout if [ $exit_code -eq 124 ]; then exit_code=124 fi else # macOS fallback using perl output=$(perl -e 'alarm shift @ARGV; exec @ARGV' "$TIMEOUT_SECONDS" bash -c "$scenario_cmd" 2>"$stderr_file") exit_code=$? fi set -e local end_time=$(date +%s%N) local duration_ms=$(( (end_time - start_time) / 1000000 )) local stderr="" [ -f "$stderr_file" ] && stderr=$(cat "$stderr_file") rm -f "$stderr_file" # Evaluate result if [ $exit_code -eq 0 ]; then log_success " Scenario $scenario_id: PASS (${duration_ms}ms)" PASSED_SCENARIOS+=("$scenario_id") echo "[$(date '+%Y-%m-%d %H:%M:%S')] Scenario $scenario_id PASS: $scenario_cmd" >> "$LOG_FILE" elif [ $exit_code -eq 124 ]; then log_error " Scenario $scenario_id: FAIL (timeout after ${TIMEOUT_SECONDS}s)" FAILED_SCENARIOS+=("$scenario_id") FAILED_DETAILS+=("$scenario_id|$scenario_name|$scenario_cmd|timeout|$exit_code|$output|$stderr") else log_error " Scenario $scenario_id: FAIL (exit code $exit_code)" if [ -n "$stderr" ] && [ "$VERBOSE" = true ]; then echo " Error: $stderr" fi FAILED_SCENARIOS+=("$scenario_id") FAILED_DETAILS+=("$scenario_id|$scenario_name|$scenario_cmd|error|$exit_code|$output|$stderr") fi return $exit_code } # ============================================================================= # Section 7: Gate Evaluation # ============================================================================= evaluate_gate() { local total=${#AUTOMATABLE_SCENARIOS[@]} local passed=${#PASSED_SCENARIOS[@]} local failed=${#FAILED_SCENARIOS[@]} log_section "Gate Evaluation" if [ $failed -eq 0 ]; then log_success "All automatable scenarios passed" return 0 else log_error "$failed scenario(s) failed" return 1 fi } # ============================================================================= # Section 8: Self-Healing Loop # ============================================================================= generate_fix_context() { local epic_id="$1" local attempt="$2" mkdir -p "$FIX_DIR" local fix_file="$FIX_DIR/epic-${epic_id}-fix-context-${attempt}.md" local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # Find template local template="$PROJECT_ROOT/src/modules/bmm/workflows/5-validation/uat-validate/uat-fix-context-template.md" if [ -f "$template" ]; then # Render template with basic variable substitution sed -e "s/{epic_id}/$epic_id/g" \ -e "s/{attempt}/$attempt/g" \ -e "s/{timestamp}/$timestamp/g" \ -e "s/{max_retries}/$MAX_RETRIES/g" \ -e "s/{next_attempt}/$((attempt + 1))/g" \ -e "s/{failure_count}/${#FAILED_SCENARIOS[@]}/g" \ -e "s|{uat_doc_path}|$UAT_FILE|g" \ "$template" > "$fix_file" else # Create minimal fix context without template cat > "$fix_file" << EOF # UAT Fix Context - Epic $epic_id (Attempt $attempt) **Generated:** $timestamp **Epic:** $epic_id **Gate Result:** FAIL (${#PASSED_SCENARIOS[@]}/${#AUTOMATABLE_SCENARIOS[@]} scenarios passed) --- ## Summary This document contains the context needed to fix UAT failures for Epic $epic_id. **Failures to fix:** ${#FAILED_SCENARIOS[@]} **Fix attempt:** $attempt of $MAX_RETRIES --- EOF fi # Append failed scenarios details echo "" >> "$fix_file" echo "## Failed Scenarios" >> "$fix_file" echo "" >> "$fix_file" for detail in "${FAILED_DETAILS[@]}"; do IFS='|' read -r scenario_id scenario_name cmd error_type exit_code output stderr <<< "$detail" cat >> "$fix_file" << EOF ### Scenario $scenario_id: $scenario_name **Command Executed:** \`\`\`bash $cmd \`\`\` **Error Type:** $error_type **Exit Code:** $exit_code **Output:** \`\`\` $output \`\`\` **Error Output:** \`\`\` $stderr \`\`\` --- EOF done # Add context references section cat >> "$fix_file" << EOF ## Context References The following files provide additional context for fixing these failures: | File | Purpose | |------|---------| | \`$UAT_FILE\` | Full UAT document with all scenarios | | \`$STORIES_DIR/${epic_id}-*\` | Story files with acceptance criteria | | \`$METRICS_DIR/epic-${epic_id}-metrics.yaml\` | Execution metrics | ## Fix Instructions Address the failures above in priority order. For each fix: 1. **Analyze** - Understand why the scenario failed 2. **Locate** - Find the relevant code files 3. **Fix** - Implement the minimum change to resolve the failure 4. **Verify** - Run the scenario command locally to confirm fix 5. **Commit** - Use message format: \`fix(epic-$epic_id): {description}\` ### Constraints - Only fix the identified failures - do not refactor unrelated code - Run the specific failing commands to verify each fix - Run project tests after all fixes: \`npm test\` - If a fix requires changes that would break other scenarios, document the tradeoff ## After Fixing Once all fixes are committed, the UAT validation will automatically re-run. - **If all pass:** Epic continues to next phase - **If failures remain:** Another fix context will be generated (attempt $((attempt + 1))) - **If max retries exceeded:** Chain halts for human intervention --- *Generated by UAT Validate Workflow* *BMAD Method - Epic Chain Self-Healing* *Fix Context: epic-${epic_id}-fix-context-${attempt}.md* EOF log "Fix context generated: $fix_file" echo "$fix_file" } run_quick_dev_fix() { local fix_context_file="$1" local epic_id="$2" local attempt="$3" log "Spawning quick-dev fix session (attempt $attempt/$MAX_RETRIES)" local fix_prompt="You are Barry, the Quick Flow Solo Dev. Load and process this fix context document: $fix_context_file Your task: 1. Read the failed scenarios and error details from the fix context 2. Analyze root cause for each failure 3. Implement targeted fixes 4. Run the failing commands to verify fixes 5. Stage changes: git add -A 6. Commit with message: fix(epic-${epic_id}): UAT fix #${attempt} Constraints: - Only fix the identified failures - Do not refactor unrelated code - Run tests after fixes When done, output exactly: FIX_COMPLETE: {number_fixed}/${#FAILED_SCENARIOS[@]}" if [ "$DRY_RUN" = true ]; then echo "[DRY RUN] Would spawn Claude for fixes with prompt:" echo " Fix context: $fix_context_file" return 0 fi # Execute in isolated context local result result=$(claude --dangerously-skip-permissions -p "$fix_prompt" 2>&1) || true echo "$result" >> "$LOG_FILE" if echo "$result" | grep -q "FIX_COMPLETE"; then log_success "Quick-dev fix session completed" return 0 else log_warn "Quick-dev fix session may not have completed cleanly" return 1 fi } self_healing_loop() { local epic_id="$1" local attempt=0 while [ $attempt -lt $MAX_RETRIES ]; do ((attempt++)) log_section "Self-Healing Fix Loop (Attempt $attempt/$MAX_RETRIES)" # Generate fix context local fix_file fix_file=$(generate_fix_context "$epic_id" "$attempt") # Run quick-dev fix if ! run_quick_dev_fix "$fix_file" "$epic_id" "$attempt"; then log_warn "Fix attempt $attempt may have issues" fi # Re-run validation log "Re-validating after fix attempt $attempt..." # Reset and re-execute PASSED_SCENARIOS=() FAILED_SCENARIOS=() FAILED_DETAILS=() if execute_scenarios "$UAT_GATE_MODE"; then log_success "UAT passed after fix attempt $attempt" return 0 fi log_warn "UAT still failing after attempt $attempt" done log_error "Max retries ($MAX_RETRIES) exceeded" return 2 } # ============================================================================= # Section 9: Output Signals and Metrics # ============================================================================= update_metrics() { local epic_id="$1" local gate_status="$2" local fix_attempts="$3" mkdir -p "$METRICS_DIR" local metrics_file="$METRICS_DIR/epic-${epic_id}-metrics.yaml" local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # Check if yq is available for YAML manipulation if command -v yq >/dev/null 2>&1; then if [ -f "$metrics_file" ]; then yq -i ".validation.gate_executed = true" "$metrics_file" yq -i ".validation.gate_status = \"$gate_status\"" "$metrics_file" yq -i ".validation.fix_attempts = $fix_attempts" "$metrics_file" yq -i ".validation.scenarios_passed = ${#PASSED_SCENARIOS[@]}" "$metrics_file" yq -i ".validation.scenarios_failed = ${#FAILED_SCENARIOS[@]}" "$metrics_file" yq -i ".validation.timestamp = \"$timestamp\"" "$metrics_file" else # Create new metrics file cat > "$metrics_file" << EOF epic_id: "$epic_id" validation: gate_executed: true gate_status: "$gate_status" fix_attempts: $fix_attempts scenarios_passed: ${#PASSED_SCENARIOS[@]} scenarios_failed: ${#FAILED_SCENARIOS[@]} timestamp: "$timestamp" EOF fi else # Fallback: append to file or create new if [ ! -f "$metrics_file" ]; then cat > "$metrics_file" << EOF epic_id: "$epic_id" validation: gate_executed: true gate_status: "$gate_status" fix_attempts: $fix_attempts scenarios_passed: ${#PASSED_SCENARIOS[@]} scenarios_failed: ${#FAILED_SCENARIOS[@]} timestamp: "$timestamp" EOF else # Simple append for validation section log_warn "yq not found - metrics update may be incomplete" fi fi log "Metrics updated: $metrics_file" } output_signals() { local gate_status="$1" local fix_attempts="$2" local total=${#AUTOMATABLE_SCENARIOS[@]} local passed=${#PASSED_SCENARIOS[@]} echo "" echo "UAT_GATE_RESULT: $gate_status" echo "UAT_FIX_ATTEMPTS: $fix_attempts" echo "UAT_SCENARIOS_PASSED: $passed/$total" } print_summary() { local gate_status="$1" local fix_attempts="$2" log_header "UAT VALIDATION COMPLETE" echo " Epic: $EPIC_ID" echo " Gate Mode: $UAT_GATE_MODE" echo " Gate Result: $gate_status" echo "" echo " Scenarios:" echo " Automatable: ${#AUTOMATABLE_SCENARIOS[@]}" echo " Semi-automated: ${#SEMI_AUTO_SCENARIOS[@]}" echo " Manual: ${#MANUAL_SCENARIOS[@]}" echo "" echo " Results:" echo " Passed: ${#PASSED_SCENARIOS[@]}" echo " Failed: ${#FAILED_SCENARIOS[@]}" echo " Fix Attempts: $fix_attempts" echo "" echo " Artifacts:" echo " Log: $LOG_FILE" echo " UAT Document: $UAT_FILE" if [ ${#FAILED_SCENARIOS[@]} -gt 0 ] && [ -d "$FIX_DIR" ]; then echo " Fix Contexts: $FIX_DIR/" fi echo "" } # ============================================================================= # Main Execution # ============================================================================= log_header "UAT VALIDATION: Epic $EPIC_ID" log "Gate mode: $UAT_GATE_MODE" log "Max retries: $MAX_RETRIES" log "Timeout: ${TIMEOUT_SECONDS}s" # Ensure directories exist mkdir -p "$METRICS_DIR" mkdir -p "$FIX_DIR" # Step 1: Load UAT document log_section "Loading UAT Document" if ! load_uat_document "$EPIC_ID"; then echo "UAT_GATE_RESULT: FAIL" echo "UAT_FIX_ATTEMPTS: 0" echo "UAT_SCENARIOS_PASSED: 0/0" exit 1 fi # Step 2: Classify scenarios log_section "Classifying Scenarios" classify_scenarios "$UAT_FILE" # Step 3: Execute scenarios if ! execute_scenarios "$UAT_GATE_MODE"; then # Gate failed - check if we should try self-healing if [ "$DRY_RUN" = false ] && [ $MAX_RETRIES -gt 0 ]; then if ! self_healing_loop "$EPIC_ID"; then # Max retries exceeded update_metrics "$EPIC_ID" "FAIL" "$MAX_RETRIES" output_signals "FAIL" "$MAX_RETRIES" print_summary "FAIL" "$MAX_RETRIES" exit 2 fi else # No self-healing or dry-run update_metrics "$EPIC_ID" "FAIL" "0" output_signals "FAIL" "0" print_summary "FAIL" "0" exit 1 fi fi # Step 4: Gate passed FINAL_ATTEMPTS=0 if [ ${#FAILED_SCENARIOS[@]} -gt 0 ]; then # Passed after retries FINAL_ATTEMPTS=$((MAX_RETRIES - $(ls -1 "$FIX_DIR"/epic-${EPIC_ID}-fix-context-*.md 2>/dev/null | wc -l) + 1)) fi update_metrics "$EPIC_ID" "PASS" "$FINAL_ATTEMPTS" output_signals "PASS" "$FINAL_ATTEMPTS" print_summary "PASS" "$FINAL_ATTEMPTS" log_success "UAT validation passed for Epic $EPIC_ID" exit 0