fix(epic-execute): implement high-priority reliability fixes (H1-H5)

H1: Git Add -A Safety
- Add check_sensitive_files() to detect untracked secrets
- Replace git add -A with git add -u (tracked files only)
- Update prompts to use explicit file staging

H2: Cleanup on Exit
- Add cleanup() function with trap handler for EXIT/INT/TERM
- Save checkpoint file for resume capability
- Finalize metrics and report uncommitted changes on exit
- Track CURRENT_STORY_INDEX throughout main loop

H3: Test Count Parsing (Multi-Framework)
- Add extract_test_count() supporting Jest, Mocha, Vitest, AVA, TAP, pytest, Go, Rust
- Try JSON output first, fall back to regex patterns
- Replace inline patterns in init_regression_baseline() and execute_regression_gate()

H4: Story Discovery (Word Boundaries)
- Fix grep pattern with word boundary: ${EPIC_ID}([^0-9]|$)
- Prevents "Epic: 1" from matching "Epic: 10" or "Epic: 100"
- Add associative array deduplication (bash 4+) with fallback

H5: Prompt Size Limits
- Add MAX_PROMPT_SIZE config (default 150KB)
- Add get_byte_size(), truncate_content(), build_sized_prompt()
- Truncate large workflow YAML and decision logs
- Add verbose logging of prompt sizes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Caleb 2026-01-26 15:03:48 -06:00
parent 5c63f31c0e
commit ce2f9fb3d7
2 changed files with 470 additions and 67 deletions

View File

@ -16,6 +16,91 @@ BASELINE_PASSING_TESTS=0
BASELINE_COVERAGE=0 BASELINE_COVERAGE=0
REGRESSION_INITIALIZED=false REGRESSION_INITIALIZED=false
# =============================================================================
# Test Count Extraction (Multi-Framework Support)
# =============================================================================
# Extract test count from test output using multiple patterns
# Supports: Jest, Mocha, Vitest, AVA, TAP, pytest, Go, Rust, and generic formats
# Arguments:
# $1 - test output string
# Returns: Number of passing tests (echoed)
extract_test_count() {
local test_output="$1"
local count=""
# Method 1: Try JSON output first (most reliable)
# Jest with --json, Vitest with --reporter=json
if command -v jq >/dev/null 2>&1; then
# Jest JSON format
count=$(echo "$test_output" | jq -r '.numPassedTests // empty' 2>/dev/null)
if [ -n "$count" ] && [ "$count" != "null" ] && [ "$count" -gt 0 ] 2>/dev/null; then
echo "$count"
return 0
fi
# Vitest JSON format (aggregate from testResults)
count=$(echo "$test_output" | jq -r '[.testResults[]?.assertionResults[]? | select(.status == "passed")] | length // empty' 2>/dev/null)
if [ -n "$count" ] && [ "$count" != "null" ] && [ "$count" -gt 0 ] 2>/dev/null; then
echo "$count"
return 0
fi
fi
# Method 2: Pattern matching fallbacks (ordered by specificity)
local patterns=(
# Jest standard output: "Tests: X passed, Y failed"
'Tests:[[:space:]]+[0-9]+ passed'
# Mocha: "X passing"
'[0-9]+ passing'
# Vitest/Jest verbose: "X passed"
'[0-9]+ passed'
# Generic: "X test(s) passed"
'[0-9]+ tests? passed'
# TAP format: "# pass X" or "# pass X"
'# pass[[:space:]]+[0-9]+'
# Rust cargo test: "test result: ok. X passed"
'test result: ok\. [0-9]+ passed'
# pytest summary: "X passed"
'[0-9]+ passed'
# AVA: "X tests passed" or "X test passed"
'[0-9]+ tests? passed'
)
for pattern in "${patterns[@]}"; do
count=$(echo "$test_output" | grep -oE "$pattern" | grep -oE '[0-9]+' | head -1 2>/dev/null || echo "")
if [ -n "$count" ] && [ "$count" != "0" ]; then
echo "$count"
return 0
fi
done
# Method 3: Count explicit PASS lines (Go test output)
local pass_count
pass_count=$(echo "$test_output" | grep -cE '^---[[:space:]]*PASS:' 2>/dev/null || echo "0")
if [ "$pass_count" -gt 0 ]; then
echo "$pass_count"
return 0
fi
# Method 4: Count checkmarks (some reporters use unicode checkmarks)
pass_count=$(echo "$test_output" | grep -cE '^[[:space:]]*[✓✔]' 2>/dev/null || echo "0")
if [ "$pass_count" -gt 0 ]; then
echo "$pass_count"
return 0
fi
# Method 5: Count "ok" lines (TAP format)
pass_count=$(echo "$test_output" | grep -cE '^ok[[:space:]]+[0-9]+' 2>/dev/null || echo "0")
if [ "$pass_count" -gt 0 ]; then
echo "$pass_count"
return 0
fi
# No tests found
echo "0"
}
# ============================================================================= # =============================================================================
# Regression Gate Functions # Regression Gate Functions
# ============================================================================= # =============================================================================
@ -30,28 +115,22 @@ init_regression_baseline() {
log "Initializing regression baseline..." log "Initializing regression baseline..."
# Detect project type and capture baseline local test_output=""
# Detect project type and run tests
if [ -f "$PROJECT_ROOT/package.json" ]; then if [ -f "$PROJECT_ROOT/package.json" ]; then
# Node.js/TypeScript project # Node.js/TypeScript project
if grep -q '"test"' "$PROJECT_ROOT/package.json" 2>/dev/null; then if grep -q '"test"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
log "Capturing baseline test count..." log "Capturing baseline test count (Node.js)..."
local test_output
test_output=$(cd "$PROJECT_ROOT" && npm test 2>&1) || true
# Try multiple patterns to extract passing test count # Check if there's a test:json script for better parsing
# Pattern 1: "X passing" (mocha/jest) if grep -q '"test:json"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
BASELINE_PASSING_TESTS=$(echo "$test_output" | grep -oE '[0-9]+ passing' | grep -oE '[0-9]+' | head -1 || echo "0") test_output=$(cd "$PROJECT_ROOT" && npm run test:json 2>&1) || true
else
# Pattern 2: "Tests: X passed" (jest) test_output=$(cd "$PROJECT_ROOT" && npm test 2>&1) || true
if [ "$BASELINE_PASSING_TESTS" = "0" ]; then
BASELINE_PASSING_TESTS=$(echo "$test_output" | grep -oE 'Tests:.*[0-9]+ passed' | grep -oE '[0-9]+' | head -1 || echo "0")
fi
# Pattern 3: "X tests passed" (generic)
if [ "$BASELINE_PASSING_TESTS" = "0" ]; then
BASELINE_PASSING_TESTS=$(echo "$test_output" | grep -oE '[0-9]+ tests? passed' | grep -oE '[0-9]+' | head -1 || echo "0")
fi fi
BASELINE_PASSING_TESTS=$(extract_test_count "$test_output")
log "Baseline passing tests: $BASELINE_PASSING_TESTS" log "Baseline passing tests: $BASELINE_PASSING_TESTS"
fi fi
@ -72,27 +151,24 @@ init_regression_baseline() {
elif [ -f "$PROJECT_ROOT/Cargo.toml" ]; then elif [ -f "$PROJECT_ROOT/Cargo.toml" ]; then
# Rust project # Rust project
log "Capturing baseline test count (Rust)..." log "Capturing baseline test count (Rust)..."
local test_output
test_output=$(cd "$PROJECT_ROOT" && cargo test 2>&1) || true test_output=$(cd "$PROJECT_ROOT" && cargo test 2>&1) || true
BASELINE_PASSING_TESTS=$(echo "$test_output" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1 || echo "0") BASELINE_PASSING_TESTS=$(extract_test_count "$test_output")
log "Baseline passing tests: $BASELINE_PASSING_TESTS" log "Baseline passing tests: $BASELINE_PASSING_TESTS"
elif [ -f "$PROJECT_ROOT/go.mod" ]; then elif [ -f "$PROJECT_ROOT/go.mod" ]; then
# Go project # Go project
log "Capturing baseline test count (Go)..." log "Capturing baseline test count (Go)..."
local test_output
test_output=$(cd "$PROJECT_ROOT" && go test ./... -v 2>&1) || true test_output=$(cd "$PROJECT_ROOT" && go test ./... -v 2>&1) || true
BASELINE_PASSING_TESTS=$(echo "$test_output" | grep -c "^--- PASS" || echo "0") BASELINE_PASSING_TESTS=$(extract_test_count "$test_output")
log "Baseline passing tests: $BASELINE_PASSING_TESTS" log "Baseline passing tests: $BASELINE_PASSING_TESTS"
elif [ -f "$PROJECT_ROOT/requirements.txt" ] || [ -f "$PROJECT_ROOT/pyproject.toml" ]; then elif [ -f "$PROJECT_ROOT/requirements.txt" ] || [ -f "$PROJECT_ROOT/pyproject.toml" ]; then
# Python project # Python project
if command -v pytest >/dev/null 2>&1; then if command -v pytest >/dev/null 2>&1; then
log "Capturing baseline test count (Python)..." log "Capturing baseline test count (Python)..."
local test_output test_output=$(cd "$PROJECT_ROOT" && pytest -v 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && pytest --co -q 2>&1) || true BASELINE_PASSING_TESTS=$(extract_test_count "$test_output")
BASELINE_PASSING_TESTS=$(echo "$test_output" | grep -oE '[0-9]+ tests? collected' | grep -oE '[0-9]+' | head -1 || echo "0") log "Baseline passing tests: $BASELINE_PASSING_TESTS"
log "Baseline test count: $BASELINE_PASSING_TESTS"
fi fi
fi fi
@ -116,35 +192,30 @@ execute_regression_gate() {
log ">>> REGRESSION GATE: $story_id" log ">>> REGRESSION GATE: $story_id"
local current_tests=0 local current_tests=0
local test_output=""
# Get current test count based on project type # Get current test count based on project type
if [ -f "$PROJECT_ROOT/package.json" ]; then if [ -f "$PROJECT_ROOT/package.json" ]; then
local test_output # Check if there's a test:json script for better parsing
test_output=$(cd "$PROJECT_ROOT" && npm test 2>&1) || true if grep -q '"test:json"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
test_output=$(cd "$PROJECT_ROOT" && npm run test:json 2>&1) || true
current_tests=$(echo "$test_output" | grep -oE '[0-9]+ passing' | grep -oE '[0-9]+' | head -1 || echo "0") else
if [ "$current_tests" = "0" ]; then test_output=$(cd "$PROJECT_ROOT" && npm test 2>&1) || true
current_tests=$(echo "$test_output" | grep -oE 'Tests:.*[0-9]+ passed' | grep -oE '[0-9]+' | head -1 || echo "0")
fi
if [ "$current_tests" = "0" ]; then
current_tests=$(echo "$test_output" | grep -oE '[0-9]+ tests? passed' | grep -oE '[0-9]+' | head -1 || echo "0")
fi fi
current_tests=$(extract_test_count "$test_output")
elif [ -f "$PROJECT_ROOT/Cargo.toml" ]; then elif [ -f "$PROJECT_ROOT/Cargo.toml" ]; then
local test_output
test_output=$(cd "$PROJECT_ROOT" && cargo test 2>&1) || true test_output=$(cd "$PROJECT_ROOT" && cargo test 2>&1) || true
current_tests=$(echo "$test_output" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1 || echo "0") current_tests=$(extract_test_count "$test_output")
elif [ -f "$PROJECT_ROOT/go.mod" ]; then elif [ -f "$PROJECT_ROOT/go.mod" ]; then
local test_output
test_output=$(cd "$PROJECT_ROOT" && go test ./... -v 2>&1) || true test_output=$(cd "$PROJECT_ROOT" && go test ./... -v 2>&1) || true
current_tests=$(echo "$test_output" | grep -c "^--- PASS" || echo "0") current_tests=$(extract_test_count "$test_output")
elif [ -f "$PROJECT_ROOT/requirements.txt" ] || [ -f "$PROJECT_ROOT/pyproject.toml" ]; then elif [ -f "$PROJECT_ROOT/requirements.txt" ] || [ -f "$PROJECT_ROOT/pyproject.toml" ]; then
if command -v pytest >/dev/null 2>&1; then if command -v pytest >/dev/null 2>&1; then
local test_output
test_output=$(cd "$PROJECT_ROOT" && pytest -v 2>&1) || true test_output=$(cd "$PROJECT_ROOT" && pytest -v 2>&1) || true
current_tests=$(echo "$test_output" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1 || echo "0") current_tests=$(extract_test_count "$test_output")
fi fi
fi fi

View File

@ -20,6 +20,83 @@
set -e set -e
# =============================================================================
# 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
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
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 <story-id>"
fi
fi
exit $exit_code
}
# Register trap for cleanup on exit, interrupt, or termination
trap cleanup EXIT INT TERM
# ============================================================================= # =============================================================================
# Configuration # Configuration
# ============================================================================= # =============================================================================
@ -114,6 +191,180 @@ log_warn() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $1" >> "$LOG_FILE" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $1" >> "$LOG_FILE"
} }
# =============================================================================
# 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
}
# ============================================================================= # =============================================================================
# Metrics Functions # Metrics Functions
# ============================================================================= # =============================================================================
@ -609,38 +860,72 @@ log "Discovering stories..."
STORY_LOCATIONS=("$STORIES_DIR" "$SPRINT_ARTIFACTS_DIR" "$SPRINTS_DIR") STORY_LOCATIONS=("$STORIES_DIR" "$SPRINT_ARTIFACTS_DIR" "$SPRINTS_DIR")
STORIES=() 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 for search_dir in "${STORY_LOCATIONS[@]}"; do
if [ ! -d "$search_dir" ]; then if [ ! -d "$search_dir" ]; then
continue continue
fi fi
# Method 1: Stories that reference this epic in content # 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 while IFS= read -r -d '' file; do
if [[ ! " ${STORIES[*]} " =~ " ${file} " ]]; then if ! is_story_duplicate "$file"; then
STORIES+=("$file") STORIES+=("$file")
fi fi
done < <(grep -l -Z "Epic.*:.*${EPIC_ID}\|epic-${EPIC_ID}\|Epic.*${EPIC_ID}" "$search_dir"/*.md 2>/dev/null || true) 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) # 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 while IFS= read -r -d '' file; do
if [[ ! " ${STORIES[*]} " =~ " ${file} " ]]; then if ! is_story_duplicate "$file"; then
STORIES+=("$file") STORIES+=("$file")
fi fi
done < <(find "$search_dir" -name "${EPIC_ID}-*-*.md" -print0 2>/dev/null || true) 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) # Method 3: story-{epic}.{seq}-*.md (BMAD standard)
while IFS= read -r -d '' file; do while IFS= read -r -d '' file; do
if [[ ! " ${STORIES[*]} " =~ " ${file} " ]]; then if ! is_story_duplicate "$file"; then
STORIES+=("$file") STORIES+=("$file")
fi fi
done < <(find "$search_dir" -name "story-${EPIC_ID}.*-*.md" -print0 2>/dev/null || true) done < <(find "$search_dir" -maxdepth 1 -name "story-${EPIC_ID}.[0-9]*-*.md" -print0 2>/dev/null || true)
# Method 4: story-{epic}-*.md (BMAD alternate) # Method 4: story-{epic}-{seq}-*.md (BMAD alternate)
while IFS= read -r -d '' file; do while IFS= read -r -d '' file; do
if [[ ! " ${STORIES[*]} " =~ " ${file} " ]]; then if ! is_story_duplicate "$file"; then
STORIES+=("$file") STORIES+=("$file")
fi fi
done < <(find "$search_dir" -name "story-${EPIC_ID}-*.md" -print0 2>/dev/null || true) done < <(find "$search_dir" -maxdepth 1 -name "story-${EPIC_ID}-[0-9]*-*.md" -print0 2>/dev/null || true)
done done
if [ ${#STORIES[@]} -eq 0 ]; then if [ ${#STORIES[@]} -eq 0 ]; then
@ -693,10 +978,17 @@ execute_dev_phase() {
local workflow_executor=$(cat "$WORKFLOW_EXECUTOR") local workflow_executor=$(cat "$WORKFLOW_EXECUTOR")
local story_contents=$(cat "$story_file") local story_contents=$(cat "$story_file")
# Get decision log context if available # Get decision log context if available (with size limit)
local decision_context="" local decision_context=""
if type get_decision_log_context >/dev/null 2>&1; then if type get_decision_log_context >/dev/null 2>&1; then
decision_context=$(get_decision_log_context) 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 fi
# Get design context if available (from design phase) # Get design context if available (from design phase)
@ -711,6 +1003,14 @@ execute_dev_phase() {
test_spec_context=$(build_test_spec_context_for_dev "$story_id") test_spec_context=$(build_test_spec_context_for_dev "$story_id")
fi fi
# Truncate large workflow files if needed to stay within context limits
local workflow_yaml_truncated="$workflow_yaml"
local yaml_size
yaml_size=$(get_byte_size "$workflow_yaml")
if [ "$yaml_size" -gt 10000 ]; then
workflow_yaml_truncated=$(truncate_content "$workflow_yaml" 10000 "Workflow YAML")
fi
# Build the dev prompt using BMAD workflow # Build the dev prompt using BMAD workflow
local dev_prompt="You are executing a BMAD dev-story workflow in automated mode. local dev_prompt="You are executing a BMAD dev-story workflow in automated mode.
@ -735,7 +1035,7 @@ $workflow_executor
## Dev-Story Workflow Configuration ## Dev-Story Workflow Configuration
<workflow-yaml> <workflow-yaml>
$workflow_yaml $workflow_yaml_truncated
</workflow-yaml> </workflow-yaml>
## Dev-Story Workflow Instructions ## Dev-Story Workflow Instructions
@ -805,7 +1105,11 @@ If a HALT condition is triggered or implementation is blocked:
## Begin Execution ## Begin Execution
Execute the dev-story workflow now. Follow all steps in exact order. Execute the dev-story workflow now. Follow all steps in exact order.
Stage all changes with: git add -A (after implementation is complete)" Stage your changes with explicit file paths: git add <file1> <file2> ...
Do NOT use 'git add -A' or 'git add .' - only stage files you created or modified."
# Log prompt size in verbose mode
log_prompt_size "$dev_prompt" "dev-phase"
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY RUN] Would execute BMAD dev-story workflow for $story_id" echo "[DRY RUN] Would execute BMAD dev-story workflow for $story_id"
@ -1011,7 +1315,7 @@ REVIEW FINDINGS END
Execute the code-review workflow now. Follow all steps in exact order. Execute the code-review workflow now. Follow all steps in exact order.
You are seeing this code for the FIRST TIME - review adversarially. You are seeing this code for the FIRST TIME - review adversarially.
Stage any fixes with: git add -A" Stage any fixes with explicit file paths: git add <file1> <file2> ..."
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY RUN] Would execute BMAD code-review workflow for $story_id" echo "[DRY RUN] Would execute BMAD code-review workflow for $story_id"
@ -1196,7 +1500,7 @@ $story_contents
2. After all issues are fixed: 2. After all issues are fixed:
a. Run full test suite a. Run full test suite
b. Update story file Dev Agent Record with fix notes b. Update story file Dev Agent Record with fix notes
c. Stage all changes: git add -A c. Stage changed files: git add <file1> <file2> ...
## Completion Signals ## Completion Signals
@ -1658,7 +1962,7 @@ Then: ARCH VIOLATIONS: $story_id - [summary]
## Begin Execution ## Begin Execution
Check architecture compliance now. Stage any fixes with: git add -A" Check architecture compliance now. Stage any fixes with: git add <file1> <file2> ..."
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY RUN] Would execute architecture compliance check for $story_id" echo "[DRY RUN] Would execute architecture compliance check for $story_id"
@ -1782,7 +2086,7 @@ Then: TEST QUALITY FAILED: $story_id - Score: N/100
## Begin Execution ## Begin Execution
Review test quality now. Stage any fixes with: git add -A" Review test quality now. Stage any fixes with: git add <file1> <file2> ..."
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY RUN] Would execute test quality review for $story_id" echo "[DRY RUN] Would execute test quality review for $story_id"
@ -1975,7 +2279,7 @@ Generate missing tests for Epic: $EPIC_ID (attempt $attempt_num of $MAX_TRACEABI
- Generate ONLY the tests specified in the gaps - Generate ONLY the tests specified in the gaps
- Follow existing test patterns in the codebase - Follow existing test patterns in the codebase
- Run each test to verify it passes - Run each test to verify it passes
- Stage changes: git add -A - Stage changes with explicit paths: git add <file1> <file2> ...
## Gaps to Address ## Gaps to Address
@ -2237,17 +2541,37 @@ commit_story() {
log "Skipping commit (--no-commit)" log "Skipping commit (--no-commit)"
return 0 return 0
fi fi
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY RUN] Would commit: feat(epic-$EPIC_ID): complete $story_id" echo "[DRY RUN] Would commit: feat(epic-$EPIC_ID): complete $story_id"
return 0 return 0
fi fi
git add -A # Safety check for sensitive files before committing
git commit -m "feat(epic-$EPIC_ID): complete $story_id" || { 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" 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" log_success "Committed: $story_id"
} }
@ -2399,6 +2723,7 @@ for story_file in "${STORIES[@]}"; do
else else
log_warn "Skipping $story_id (waiting for $START_FROM)" log_warn "Skipping $story_id (waiting for $START_FROM)"
((SKIPPED++)) ((SKIPPED++))
((CURRENT_STORY_INDEX++))
update_story_metrics "skipped" update_story_metrics "skipped"
continue continue
fi fi
@ -2409,6 +2734,7 @@ for story_file in "${STORIES[@]}"; do
if grep -qi "^Status:.*done" "$story_file" 2>/dev/null; then if grep -qi "^Status:.*done" "$story_file" 2>/dev/null; then
log_warn "Skipping $story_id (Status: Done)" log_warn "Skipping $story_id (Status: Done)"
((SKIPPED++)) ((SKIPPED++))
((CURRENT_STORY_INDEX++))
update_story_metrics "skipped" update_story_metrics "skipped"
continue continue
fi fi
@ -2425,6 +2751,7 @@ for story_file in "${STORIES[@]}"; do
if ! execute_story_with_fix_loop "$story_file"; then if ! execute_story_with_fix_loop "$story_file"; then
log_error "Story execution failed for $story_id" log_error "Story execution failed for $story_id"
((FAILED++)) ((FAILED++))
((CURRENT_STORY_INDEX++))
update_story_metrics "failed" update_story_metrics "failed"
continue continue
fi fi
@ -2433,6 +2760,7 @@ for story_file in "${STORIES[@]}"; do
if ! execute_dev_phase "$story_file"; then if ! execute_dev_phase "$story_file"; then
log_error "Dev phase failed for $story_id" log_error "Dev phase failed for $story_id"
((FAILED++)) ((FAILED++))
((CURRENT_STORY_INDEX++))
update_story_metrics "failed" update_story_metrics "failed"
add_metrics_issue "$story_id" "dev_phase_failed" "Development phase did not complete" add_metrics_issue "$story_id" "dev_phase_failed" "Development phase did not complete"
continue continue
@ -2453,6 +2781,9 @@ for story_file in "${STORIES[@]}"; do
((COMPLETED++)) ((COMPLETED++))
update_story_metrics "completed" update_story_metrics "completed"
log_success "Story complete: $story_id ($COMPLETED/${#STORIES[@]})" log_success "Story complete: $story_id ($COMPLETED/${#STORIES[@]})"
# Track progress for checkpoint/resume
((CURRENT_STORY_INDEX++))
done done
# ============================================================================= # =============================================================================
@ -2493,7 +2824,8 @@ if [ "$SKIP_TRACEABILITY" = false ]; then
# Commit any generated tests # Commit any generated tests
if [ "$NO_COMMIT" = false ] && [ "$DRY_RUN" = false ]; then if [ "$NO_COMMIT" = false ] && [ "$DRY_RUN" = false ]; then
git add -A # 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 git commit -m "test(epic-$EPIC_ID): generate missing tests for traceability (attempt $trace_fix_attempt)" 2>/dev/null || true
fi fi