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.
850 lines
26 KiB
Bash
Executable File
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 "$@"
|