Files
calctext/run-pipeline.sh
C. Cassel 6a8fecd03e feat(engine): establish calcpad-engine workspace with Epic 1 modules
Cherry-picked and integrated the best code from 105 parallel epic
branches into a clean workspace structure:

- calcpad-engine/: Core Rust crate with lexer, parser, AST,
  interpreter, types, FFI (C ABI), pipeline, error handling,
  span tracking, eval context (from epic/1-5 base)
- calcpad-engine/src/number.rs: Arbitrary precision arithmetic
  via dashu crate, WASM-compatible, exact decimals (from epic/1-4)
- calcpad-engine/src/sheet_context.rs: SheetContext with dependency
  graph, dirty tracking, circular detection, cheap clone (from epic/1-8)
- calcpad-wasm/: Thin WASM wrapper crate via wasm-bindgen,
  delegates to calcpad-engine (from epic/1-6)
- Updated .gitignore for target/, node_modules/, build artifacts
- Fixed run-pipeline.sh for macOS compat and CalcPad phases

79 tests passing across workspace.
2026-03-17 07:54:17 -04:00

850 lines
26 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================
# run-pipeline.sh — CalcPad Epic Development Pipeline
# ==============================================================================
# Runs dev → review → commit for each story. Auto-discovers stories and skips
# already-committed ones via git history. Supports parallel execution via
# git worktrees.
#
# Usage:
# ./run-pipeline.sh # All pending stories
# ./run-pipeline.sh --phase1 # Phase 1 stories (Epics 1-5, 10)
# ./run-pipeline.sh --phase1 --epic 1 # Only Epic 1 stories
# ./run-pipeline.sh --parallel 3 # 3 stories in parallel
# ./run-pipeline.sh --from 3-1 # Start from story 3-1
# ./run-pipeline.sh --dry-run # Show plan only
#
# Options:
# --phase1 Phase 1: Engine + MVP (Epics 1-5, 10)
# --phase2 Phase 2: Power features (Epics 6, 7.1-7.6, 8.1-8.6, 9.1-9.3)
# --phase3 Phase 3: System integration (Epics 7.7-7.14, 8.7-8.10, 9.4-9.8, 11)
# --phase4 Phase 4: Ecosystem (Epics 12, 13, 14, 15, 16)
# --parallel <N> Run N stories in parallel via git worktrees (default: 1)
# --epic <N> Filter to epic N (e.g. --epic 1)
# --from <prefix> Start from story matching prefix (e.g. --from 3-1)
# --stories <paths...> Explicit story paths (skip auto-discovery)
# --no-review Skip code-review phase
# --force Ignore git-based skip detection
# --cooldown <seconds> Pause between stories (default: 10, ignored in parallel)
# --dry-run Show execution plan without running
# --log-dir <path> Directory for logs (default: .logs/epic-pipeline/)
# --help Show this help message
# ==============================================================================
set -euo pipefail
# ── Portable relpath (macOS lacks realpath --relative-to) ────────────────────
relpath() {
python3 -c "import os; print(os.path.relpath('$1', '${2:-$PWD}'))"
}
# ── Configuration ─────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR"
STORY_DIR="${PROJECT_ROOT}/_bmad-output/implementation-artifacts"
LOG_DIR="${PROJECT_ROOT}/.logs/epic-pipeline"
WORKTREE_BASE="${PROJECT_ROOT}/.worktrees"
COOLDOWN=10
PARALLEL=1
DRY_RUN=false
NO_REVIEW=false
FORCE=false
MERGE_ONLY=false
PHASE_FILTER=""
EPIC_FILTER=""
FROM_PREFIX=""
STORIES=()
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
# Phase 1: Engine + MVP (Epics 1-5, 10)
PHASE1_STORIES=(
1-1-lexer-and-tokenizer
1-2-parser-and-ast
1-3-interpreter-evaluator
1-4-arbitrary-precision-arithmetic
1-5-c-ffi-layer-for-swift
1-6-wasm-bindings-for-web
1-7-error-handling-and-graceful-degradation
1-8-sheet-context
2-1-unit-registry-and-base-conversion
2-2-si-prefix-support
2-3-css-and-screen-units
2-4-data-units-binary-vs-decimal
2-5-natural-language-unit-expressions
2-6-custom-user-defined-units
3-1-fiat-currency-provider
3-2-cryptocurrency-provider
3-3-historical-rates
3-4-currency-symbol-and-code-recognition
3-5-multi-currency-arithmetic
4-1-date-math
4-2-time-math
4-3-time-zone-conversions
4-4-business-day-calculations
4-5-unix-timestamp-conversions
4-6-relative-time-expressions
5-1-variable-declaration-and-usage
5-2-line-references
5-3-previous-line-reference
5-4-section-aggregators
5-5-subtotals-and-grand-total
5-6-autocomplete
10-1-headers-comments-and-labels
10-2-click-to-copy-answer
10-3-drag-to-resize-columns
10-4-answer-column-formatting
10-5-line-numbers
10-6-find-and-replace
)
# Phase 2: Power features (Epics 6, 7.1-7.6, 8.1-8.6, 9.1-9.3)
PHASE2_STORIES=()
# Phase 3: System integration (Epics 7.7-7.14, 8.7-8.10, 9.4-9.8, 11)
PHASE3_STORIES=()
# Phase 4: Ecosystem (Epics 12, 13, 14, 15, 16)
PHASE4_STORIES=()
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
# ── Logging ───────────────────────────────────────────────────────────────────
log() {
local level="$1"; shift
local ts; ts=$(date +"%H:%M:%S")
case "$level" in
INFO) echo -e "${BLUE}[$ts]${NC} ${BOLD}INFO${NC} $*" ;;
OK) echo -e "${GREEN}[$ts]${NC} ${GREEN}${BOLD}OK${NC} $*" ;;
WARN) echo -e "${YELLOW}[$ts]${NC} ${YELLOW}${BOLD}WARN${NC} $*" ;;
ERROR) echo -e "${RED}[$ts]${NC} ${RED}${BOLD}ERROR${NC} $*" ;;
STEP) echo -e "${CYAN}[$ts]${NC} ${CYAN}${BOLD}STEP${NC} $*" ;;
esac
echo "[$ts] $level $*" >> "${LOG_DIR}/run-${TIMESTAMP}.log" 2>/dev/null || true
}
separator() {
echo ""
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD} $1${NC}"
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
echo ""
}
usage() {
head -n 32 "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//'
exit 0
}
# ── Prerequisites ─────────────────────────────────────────────────────────────
check_prerequisites() {
if ! command -v claude &> /dev/null; then
log ERROR "claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code"
exit 1
fi
if [ ! -d "${PROJECT_ROOT}/_bmad" ]; then
log ERROR "Not in a valid BMAD project root (_bmad/ not found)"
exit 1
fi
if [ ! -d "$STORY_DIR" ]; then
log ERROR "Story directory not found: $STORY_DIR"
exit 1
fi
# Parallel mode requires at least one commit on main (for branching)
if [ "$PARALLEL" -gt 1 ]; then
if ! git -C "$PROJECT_ROOT" rev-parse HEAD &>/dev/null; then
log ERROR "No commits on main. Parallel mode needs an initial commit."
log ERROR "Run: git add -A && git commit -m 'initial commit'"
exit 1
fi
fi
}
# ── Git-based completion detection ────────────────────────────────────────────
is_story_committed() {
local story_path="$1"
if git log --all --grep="Story: $story_path" --oneline -1 2>/dev/null | grep -q .; then
return 0
fi
return 1
}
# ── Auto-discover stories ────────────────────────────────────────────────────
discover_stories() {
local all_stories=()
while IFS= read -r story; do
local rel_path
rel_path=$(relpath "$story" "$PROJECT_ROOT")
all_stories+=("$rel_path")
done < <(find "$STORY_DIR" -maxdepth 1 -name "[0-9]*-[0-9]*-*.md" -type f | sort -V)
if [ ${#all_stories[@]} -eq 0 ]; then
log ERROR "No story files found in $STORY_DIR"
exit 1
fi
# Apply phase filter
if [ -n "$PHASE_FILTER" ]; then
local phase_stories_ref="PHASE${PHASE_FILTER}_STORIES[@]"
local phase_stories=("${!phase_stories_ref}")
if [ ${#phase_stories[@]} -eq 0 ]; then
log ERROR "No stories defined for phase $PHASE_FILTER"
exit 1
fi
local filtered=()
for s in "${all_stories[@]}"; do
local base; base=$(basename "$s" .md)
for ps in "${phase_stories[@]}"; do
if [ "$base" = "$ps" ]; then
filtered+=("$s")
break
fi
done
done
all_stories=("${filtered[@]}")
if [ ${#all_stories[@]} -eq 0 ]; then
log ERROR "No phase $PHASE_FILTER stories found on disk"
exit 1
fi
fi
# Apply --epic filter
if [ -n "$EPIC_FILTER" ]; then
local filtered=()
for s in "${all_stories[@]}"; do
local base; base=$(basename "$s")
if [[ "$base" =~ ^${EPIC_FILTER}- ]]; then
filtered+=("$s")
fi
done
all_stories=("${filtered[@]}")
if [ ${#all_stories[@]} -eq 0 ]; then
log ERROR "No stories found for epic $EPIC_FILTER"
exit 1
fi
fi
# Apply --from filter
if [ -n "$FROM_PREFIX" ]; then
local filtered=()
local found=false
for s in "${all_stories[@]}"; do
local base; base=$(basename "$s" .md)
if [ "$found" = true ] || [[ "$base" =~ ^${FROM_PREFIX} ]]; then
found=true
filtered+=("$s")
fi
done
if [ "$found" = false ]; then
log ERROR "No story matching prefix '$FROM_PREFIX' found"
exit 1
fi
all_stories=("${filtered[@]}")
fi
STORIES=("${all_stories[@]}")
}
# ── Filter out already-committed stories ──────────────────────────────────────
filter_committed() {
if [ "$FORCE" = true ]; then
log INFO "Force mode: skipping git-based filtering"
return
fi
local pending=()
local skipped=0
for story in "${STORIES[@]}"; do
if is_story_committed "$story"; then
((skipped++)) || true
else
pending+=("$story")
fi
done
if [ $skipped -gt 0 ]; then
log INFO "Skipped $skipped already-committed stories"
fi
if [ ${#pending[@]} -eq 0 ]; then
log OK "All ${#STORIES[@]} stories already committed. Nothing to do."
exit 0
fi
STORIES=("${pending[@]}")
}
# ── Extract story title from file ─────────────────────────────────────────────
story_title() {
local story_path="$1"
local root="${2:-$PROJECT_ROOT}"
grep -m1 '^# ' "${root}/${story_path}" 2>/dev/null \
| sed 's/^# Story [A-Z0-9.]*: //' \
| sed 's/^# //' \
|| basename "$story_path" .md
}
# ==============================================================================
# Sequential mode (--parallel 1, default)
# ==============================================================================
# ── Run Claude with clean context ─────────────────────────────────────────────
run_claude() {
local description="$1"
local prompt="$2"
local log_file="$3"
local work_dir="${4:-$PROJECT_ROOT}"
if [ "$DRY_RUN" = true ]; then
log INFO "[DRY RUN] Would execute: claude -p \"${prompt:0:80}...\""
return 0
fi
log STEP "$description"
log INFO "Log: $log_file"
if (cd "$work_dir" && env -u CLAUDECODE claude --dangerously-skip-permissions -p "$prompt" < /dev/null > "$log_file" 2>&1); then
if grep -q "Unknown skill" "$log_file"; then
log ERROR "$description — skill not found (check skill name)"
log ERROR "Log contains: $(grep 'Unknown skill' "$log_file" | head -1)"
return 1
fi
log OK "$description — completed"
return 0
else
local exit_code=$?
log ERROR "$description — failed (exit code: $exit_code)"
log ERROR "Check log: $log_file"
return $exit_code
fi
}
phase_dev() {
local story_path="$1"
local work_dir="${2:-$PROJECT_ROOT}"
local story_name; story_name=$(basename "$story_path" .md)
local dev_log="${LOG_DIR}/${TIMESTAMP}-dev-${story_name}.log"
run_claude \
"Developing: $story_name" \
"You are running in a fully autonomous pipeline — do NOT ask questions, do NOT wait for input. If the story file is missing task breakdowns, create them yourself and proceed immediately. Always choose option 1 (do it yourself). /bmad-dev-story $story_path" \
"$dev_log" \
"$work_dir" || return 1
if [ "$DRY_RUN" = false ]; then
if (cd "$work_dir" && git diff --quiet && git diff --staged --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]); then
log ERROR "Dev produced no file changes for: $story_name"
if grep -q "Unknown skill" "$dev_log"; then
log ERROR "Cause: Unknown skill in log"
fi
return 1
fi
log OK "Dev produced changes for: $story_name"
fi
}
phase_review() {
local story_path="$1"
local work_dir="${2:-$PROJECT_ROOT}"
local story_name; story_name=$(basename "$story_path" .md)
local review_log="${LOG_DIR}/${TIMESTAMP}-review-${story_name}.log"
run_claude \
"Code review: $story_name" \
"/bmad-code-review $story_path" \
"$review_log" \
"$work_dir" || return 1
log OK "Code review complete: $story_name"
}
phase_commit() {
local story_path="$1"
local work_dir="${2:-$PROJECT_ROOT}"
local story_name; story_name=$(basename "$story_path" .md)
if [ "$DRY_RUN" = true ]; then
log INFO "[DRY RUN] Would commit changes for: $story_name"
return 0
fi
if (cd "$work_dir" && git diff --quiet && git diff --staged --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]); then
log WARN "No changes to commit for: $story_name"
return 0
fi
local title
title=$(story_title "$story_path" "$work_dir")
(cd "$work_dir" && git add -A && git commit -m "$(cat <<EOF
feat(epic): implement $title
Story: $story_path
EOF
)")
log OK "Committed: $story_name"
}
run_sequential() {
local succeeded=0 failed=0
local total=${#STORIES[@]}
for i in "${!STORIES[@]}"; do
local story="${STORIES[$i]}"
local num=$((i + 1))
local story_name; story_name=$(basename "$story" .md)
separator "[$num/$total] $story_name"
if ! phase_dev "$story"; then
log ERROR "Dev failed: $story_name"
((failed++)) || true
continue
fi
if [ "$NO_REVIEW" = false ]; then
phase_review "$story" || log WARN "Review had issues: $story_name (continuing)"
fi
phase_commit "$story" || log WARN "Commit failed: $story_name"
((succeeded++)) || true
if [ $num -lt $total ] && [ "$DRY_RUN" = false ]; then
log INFO "Cooldown: ${COOLDOWN}s..."
sleep "$COOLDOWN"
fi
done
print_summary $succeeded $failed $total
}
# ==============================================================================
# Parallel mode (--parallel N, N > 1) — uses git worktrees
# ==============================================================================
# Track results per story: .worktrees/<name>.result = 0 (ok) or 1 (fail)
RESULTS_DIR=""
# Cleanup worktrees on exit (keeps unmerged branches safe)
cleanup_worktrees() {
if [ -d "$WORKTREE_BASE" ]; then
log INFO "Cleaning up worktrees..."
for wt in "$WORKTREE_BASE"/*/; do
[ -d "$wt" ] || continue
local name; name=$(basename "$wt")
# Remove worktree directory only (branch stays for manual merge)
git -C "$PROJECT_ROOT" worktree remove --force "$wt" 2>/dev/null || true
done
rmdir "$WORKTREE_BASE" 2>/dev/null || true
fi
# Show unmerged branches if any remain
local unmerged
unmerged=$(git -C "$PROJECT_ROOT" branch --list 'epic/*' --no-merged main 2>/dev/null || true)
if [ -n "$unmerged" ]; then
log WARN "Unmerged branches (work preserved):"
echo "$unmerged" | while read -r b; do echo " $b"; done
log INFO "Re-run to merge, or: git merge <branch>"
fi
if [ -n "$RESULTS_DIR" ] && [ -d "$RESULTS_DIR" ]; then
rm -rf "$RESULTS_DIR"
fi
}
# Process one story in an isolated worktree
process_story_worktree() {
local story="$1"
local story_name; story_name=$(basename "$story" .md)
local branch="epic/${story_name}"
local wt_path="${WORKTREE_BASE}/${story_name}"
# Create branch + worktree
if [ "$DRY_RUN" = true ]; then
log INFO "[DRY RUN] [parallel] Would create worktree for: $story_name"
log INFO "[DRY RUN] [parallel] Would develop: $story_name"
[ "$NO_REVIEW" = false ] && log INFO "[DRY RUN] [parallel] Would review: $story_name"
log INFO "[DRY RUN] [parallel] Would commit: $story_name"
echo "0" > "${RESULTS_DIR}/${story_name}.result"
return 0
fi
log STEP "Creating worktree: $story_name$branch"
# Reset branch to main if it already exists, or create fresh
if git -C "$PROJECT_ROOT" rev-parse --verify "$branch" &>/dev/null; then
git -C "$PROJECT_ROOT" branch -f "$branch" main 2>/dev/null || true
log INFO "Reset existing branch: $branch"
else
if ! git -C "$PROJECT_ROOT" branch "$branch" main 2>&1; then
log ERROR "[parallel] Failed to create branch: $branch"
echo "1" > "${RESULTS_DIR}/${story_name}.result"
return 1
fi
fi
# Remove stale worktree if path exists
if [ -d "$wt_path" ]; then
git -C "$PROJECT_ROOT" worktree remove --force "$wt_path" 2>/dev/null || rm -rf "$wt_path"
fi
if ! git -C "$PROJECT_ROOT" worktree add "$wt_path" "$branch" 2>&1; then
log ERROR "[parallel] Failed to create worktree: $wt_path"
echo "1" > "${RESULTS_DIR}/${story_name}.result"
return 1
fi
# Symlink .claude/ so skills are available in worktree
if [ -d "${PROJECT_ROOT}/.claude" ] && [ ! -e "${wt_path}/.claude" ]; then
ln -s "${PROJECT_ROOT}/.claude" "${wt_path}/.claude"
# Exclude symlink from git so it doesn't get committed
echo ".claude" >> "${wt_path}/.git/info/exclude"
fi
# Dev
if ! phase_dev "$story" "$wt_path"; then
log ERROR "[parallel] Dev failed: $story_name"
echo "1" > "${RESULTS_DIR}/${story_name}.result"
return 1
fi
# Review
if [ "$NO_REVIEW" = false ]; then
phase_review "$story" "$wt_path" || log WARN "[parallel] Review had issues: $story_name"
fi
# Commit in worktree branch
phase_commit "$story" "$wt_path" || log WARN "[parallel] Commit failed: $story_name"
echo "0" > "${RESULTS_DIR}/${story_name}.result"
log OK "[parallel] Story complete: $story_name (branch: $branch)"
}
# Merge completed branches back to main in order
merge_branches() {
local merged=0 merge_failed=0
separator "Merging branches into main"
cd "$PROJECT_ROOT"
for story in "${STORIES[@]}"; do
local story_name; story_name=$(basename "$story" .md)
local branch="epic/${story_name}"
local result_file="${RESULTS_DIR}/${story_name}.result"
# Skip failed stories
if [ -f "$result_file" ] && [ "$(cat "$result_file")" != "0" ]; then
log WARN "Skipping merge (dev failed): $story_name"
continue
fi
# Skip if branch has no new commits
if ! git rev-parse --verify "$branch" &>/dev/null; then
continue
fi
local title
title=$(story_title "$story")
log STEP "Merging: $branch → main"
if git merge --no-ff "$branch" -m "$(cat <<EOF
feat(epic): implement $title
Story: $story
EOF
)"; then
# Remove .claude symlink if it sneaked in
if [ -L "${PROJECT_ROOT}/.claude" ] || git ls-files --error-unmatch .claude &>/dev/null; then
git rm -f .claude &>/dev/null && git commit --amend --no-edit &>/dev/null || true
fi
log OK "Merged: $story_name"
((merged++)) || true
else
log ERROR "Merge conflict: $story_name — aborting merge"
git merge --abort 2>/dev/null || true
((merge_failed++)) || true
log WARN "Remaining stories not merged. Resolve conflict and re-run."
break
fi
# Cleanup branch + worktree
local wt_path="${WORKTREE_BASE}/${story_name}"
git worktree remove --force "$wt_path" 2>/dev/null || true
git branch -d "$branch" 2>/dev/null || true
done
log INFO "Merged: $merged, Failed: $merge_failed"
return $merge_failed
}
run_parallel() {
local total=${#STORIES[@]}
RESULTS_DIR=$(mktemp -d)
mkdir -p "$WORKTREE_BASE"
# Register cleanup trap
trap cleanup_worktrees EXIT
log INFO "Parallel mode: $PARALLEL workers, $total stories"
if [ "$DRY_RUN" = true ]; then
for story in "${STORIES[@]}"; do
process_story_worktree "$story"
done
print_summary "$total" 0 "$total"
return 0
fi
# Launch workers with semaphore using a slot-based approach
local pids=()
local slots=()
# Initialize slots
for ((s=0; s<PARALLEL; s++)); do slots[$s]=0; done
for i in "${!STORIES[@]}"; do
local story="${STORIES[$i]}"
local num=$((i + 1))
local story_name; story_name=$(basename "$story" .md)
# Find a free slot (wait for one if all busy)
local found_slot=false
while [ "$found_slot" = false ]; do
for ((s=0; s<PARALLEL; s++)); do
if [ "${slots[$s]}" -eq 0 ]; then
found_slot=true
break
fi
# Check if the process in this slot has finished
if ! kill -0 "${slots[$s]}" 2>/dev/null; then
wait "${slots[$s]}" 2>/dev/null || true
slots[$s]=0
found_slot=true
break
fi
done
if [ "$found_slot" = false ]; then
sleep 1
fi
done
log INFO "Launching [$num/$total]: $story_name (slot $s)"
process_story_worktree "$story" &
slots[$s]=$!
pids+=($!)
done
# Wait for all remaining
local remaining=0
for ((s=0; s<PARALLEL; s++)); do
[ "${slots[$s]}" -ne 0 ] && ((remaining++)) || true
done
log INFO "Waiting for $remaining remaining workers..."
for pid in "${pids[@]}"; do
wait "$pid" 2>/dev/null || true
done
# Count results
local succeeded=0 failed=0
for story in "${STORIES[@]}"; do
local story_name; story_name=$(basename "$story" .md)
local result_file="${RESULTS_DIR}/${story_name}.result"
if [ -f "$result_file" ] && [ "$(cat "$result_file")" = "0" ]; then
((succeeded++)) || true
else
((failed++)) || true
fi
done
log OK "All workers done. Succeeded: $succeeded, Failed: $failed"
# Merge branches sequentially into main
if [ $succeeded -gt 0 ]; then
merge_branches
fi
print_summary $succeeded $failed $total
}
# ==============================================================================
# Common
# ==============================================================================
print_summary() {
local succeeded=$1 failed=$2 total=$3
separator "Pipeline Complete"
echo -e " ${GREEN}Succeeded:${NC} $succeeded"
echo -e " ${RED}Failed:${NC} $failed"
echo -e " ${BOLD}Total:${NC} $total"
[ "$PARALLEL" -gt 1 ] && echo -e " ${CYAN}Workers:${NC} $PARALLEL"
echo ""
log INFO "Logs: $LOG_DIR/"
[ "$failed" -gt 0 ] && return 1
return 0
}
# ── Parse Arguments ───────────────────────────────────────────────────────────
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--phase1)
PHASE_FILTER="1"
shift
;;
--phase2)
PHASE_FILTER="2"
shift
;;
--phase3)
PHASE_FILTER="3"
shift
;;
--phase4)
PHASE_FILTER="4"
shift
;;
--parallel)
PARALLEL="$2"
if ! [[ "$PARALLEL" =~ ^[0-9]+$ ]] || [ "$PARALLEL" -lt 1 ]; then
log ERROR "--parallel must be a positive integer"
exit 1
fi
shift 2
;;
--epic)
EPIC_FILTER="$2"
shift 2
;;
--from)
FROM_PREFIX="$2"
shift 2
;;
--stories)
shift
while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do
STORIES+=("$1")
shift
done
;;
--no-review)
NO_REVIEW=true
shift
;;
--force)
FORCE=true
shift
;;
--merge-only)
MERGE_ONLY=true
shift
;;
--cooldown)
COOLDOWN="$2"
shift 2
;;
--dry-run)
DRY_RUN=true
shift
;;
--log-dir)
LOG_DIR="$2"
shift 2
;;
--help|-h)
usage
;;
*)
log ERROR "Unknown option: $1"
usage
;;
esac
done
}
# ── Main ──────────────────────────────────────────────────────────────────────
main() {
parse_args "$@"
mkdir -p "$LOG_DIR"
check_prerequisites
cd "$PROJECT_ROOT"
# Merge-only mode: merge existing epic/* branches into main
if [ "$MERGE_ONLY" = true ]; then
separator "Merge-only mode"
# Discover stories from existing epic/* branches
local branches
branches=$(git branch --list 'epic/*' 2>/dev/null | sed 's/^[* ]*//')
if [ -z "$branches" ]; then
log OK "No epic/* branches to merge."
exit 0
fi
# Build STORIES array from branches
STORIES=()
while IFS= read -r branch; do
local story_name="${branch#epic/}"
local story_path="_bmad-output/implementation-artifacts/${story_name}.md"
STORIES+=("$story_path")
echo -e " ${BOLD}-${NC} $story_name"
done <<< "$branches"
echo ""
RESULTS_DIR=$(mktemp -d)
# Mark all as succeeded (they were already developed)
for story in "${STORIES[@]}"; do
local sn; sn=$(basename "$story" .md)
echo "0" > "${RESULTS_DIR}/${sn}.result"
done
merge_branches
rm -rf "$RESULTS_DIR"
# Cleanup merged branches
local remaining
remaining=$(git branch --list 'epic/*' 2>/dev/null | wc -l)
log INFO "Branches remaining: $remaining"
exit 0
fi
# Discover stories if not provided explicitly
if [ ${#STORIES[@]} -eq 0 ]; then
discover_stories
fi
# Filter out already-committed
local total_before=${#STORIES[@]}
filter_committed
local total_pending=${#STORIES[@]}
# Show execution plan
local mode_label="sequential"
[ "$PARALLEL" -gt 1 ] && mode_label="parallel ($PARALLEL workers)"
separator "Pipeline: $total_pending pending / $total_before total [$mode_label]"
log INFO "Timestamp: $TIMESTAMP"
log INFO "Mode: $mode_label"
log INFO "Review: $([ "$NO_REVIEW" = true ] && echo 'off' || echo 'on')"
[ "$PARALLEL" -eq 1 ] && log INFO "Cooldown: ${COOLDOWN}s"
log INFO "Dry Run: $DRY_RUN"
[ -n "$EPIC_FILTER" ] && log INFO "Epic: $EPIC_FILTER"
[ -n "$FROM_PREFIX" ] && log INFO "From: $FROM_PREFIX"
echo ""
for i in "${!STORIES[@]}"; do
echo -e " ${BOLD}$((i + 1)).${NC} $(basename "${STORIES[$i]}" .md)"
done
echo ""
# Run
if [ "$PARALLEL" -gt 1 ]; then
run_parallel
else
run_sequential
fi
local exit_code=$?
exit $exit_code
}
main "$@"