#!/usr/bin/env bash # ============================================ # Ralphy - Autonomous AI Coding Loop # Supports Claude Code, OpenCode, Codex, Cursor, Qwen-Code and Factory Droid # Runs until PRD is complete # ============================================ set -euo pipefail # ============================================ # CONFIGURATION & DEFAULTS # ============================================ VERSION="4.0.0" # Ralphy config directory RALPHY_DIR=".ralphy" PROGRESS_FILE="$RALPHY_DIR/progress.txt" CONFIG_FILE="$RALPHY_DIR/config.yaml" SINGLE_TASK="" INIT_MODE=false SHOW_CONFIG=false ADD_RULE="" AUTO_COMMIT=true # Runtime options SKIP_TESTS=false SKIP_LINT=false AI_ENGINE="claude" # claude, opencode, cursor, codex, qwen, or droid DRY_RUN=false MAX_ITERATIONS=0 # 0 = unlimited MAX_RETRIES=3 RETRY_DELAY=5 VERBOSE=false # Git branch options BRANCH_PER_TASK=false CREATE_PR=false BASE_BRANCH="" PR_DRAFT=false # Parallel execution PARALLEL=false MAX_PARALLEL=3 # PRD source options PRD_SOURCE="markdown" # markdown, yaml, github PRD_FILE="PRD.md" GITHUB_REPO="" GITHUB_LABEL="" # Colors (detect if terminal supports colors) if [[ -t 1 ]] && command -v tput &>/dev/null && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then RED=$(tput setaf 1) GREEN=$(tput setaf 2) YELLOW=$(tput setaf 3) BLUE=$(tput setaf 4) MAGENTA=$(tput setaf 5) CYAN=$(tput setaf 6) BOLD=$(tput bold) DIM=$(tput dim) RESET=$(tput sgr0) else RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" BOLD="" DIM="" RESET="" fi # Global state ai_pid="" monitor_pid="" tmpfile="" CODEX_LAST_MESSAGE_FILE="" current_step="Thinking" total_input_tokens=0 total_output_tokens=0 total_actual_cost="0" # OpenCode provides actual cost total_duration_ms=0 # Cursor provides duration iteration=0 retry_count=0 declare -a parallel_pids=() declare -a task_branches=() declare -a integration_branches=() # Track integration branches for cleanup on interrupt WORKTREE_BASE="" # Base directory for parallel agent worktrees ORIGINAL_DIR="" # Original working directory (for worktree operations) ORIGINAL_BASE_BRANCH="" # Original base branch before integration branches # ============================================ # UTILITY FUNCTIONS # ============================================ log_info() { echo "${BLUE}[INFO]${RESET} $*" } log_success() { echo "${GREEN}[OK]${RESET} $*" } log_warn() { echo "${YELLOW}[WARN]${RESET} $*" } log_error() { echo "${RED}[ERROR]${RESET} $*" >&2 } log_debug() { if [[ "$VERBOSE" == true ]]; then echo "${DIM}[DEBUG] $*${RESET}" fi } # Slugify text for branch names slugify() { echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g' | sed -E 's/^-|-$//g' | cut -c1-50 } # ============================================ # BROWNFIELD MODE (.ralphy/ configuration) # ============================================ # Initialize .ralphy/ directory with config files init_ralphy_config() { if [[ -d "$RALPHY_DIR" ]]; then log_warn "$RALPHY_DIR already exists" REPLY='N' # Default if read times out or fails read -p "Overwrite config? [y/N] " -n 1 -r -t 30 2>/dev/null || true echo [[ ! $REPLY =~ ^[Yy]$ ]] && exit 0 fi mkdir -p "$RALPHY_DIR" # Smart detection local project_name="" local lang="" local framework="" local test_cmd="" local lint_cmd="" local build_cmd="" # Get project name from directory or package.json project_name=$(basename "$PWD") if [[ -f "package.json" ]]; then # Get name from package.json if available local pkg_name pkg_name=$(jq -r '.name // ""' package.json 2>/dev/null) [[ -n "$pkg_name" ]] && project_name="$pkg_name" # Detect language if [[ -f "tsconfig.json" ]]; then lang="TypeScript" else lang="JavaScript" fi # Detect frameworks from dependencies (collect all matches) local deps frameworks=() deps=$(jq -r '(.dependencies // {}) + (.devDependencies // {}) | keys[]' package.json 2>/dev/null || true) # Use grep for reliable exact matching echo "$deps" | grep -qx "next" && frameworks+=("Next.js") echo "$deps" | grep -qx "nuxt" && frameworks+=("Nuxt") echo "$deps" | grep -qx "@remix-run/react" && frameworks+=("Remix") echo "$deps" | grep -qx "svelte" && frameworks+=("Svelte") echo "$deps" | grep -qE "@nestjs/" && frameworks+=("NestJS") echo "$deps" | grep -qx "hono" && frameworks+=("Hono") echo "$deps" | grep -qx "fastify" && frameworks+=("Fastify") echo "$deps" | grep -qx "express" && frameworks+=("Express") # Only add React/Vue if no meta-framework detected if [[ ${#frameworks[@]} -eq 0 ]]; then echo "$deps" | grep -qx "react" && frameworks+=("React") echo "$deps" | grep -qx "vue" && frameworks+=("Vue") fi # Join frameworks with comma framework=$(IFS=', '; echo "${frameworks[*]}") # Detect commands from package.json scripts local scripts scripts=$(jq -r '.scripts // {}' package.json 2>/dev/null) # Test command (prefer bun if lockfile exists) if echo "$scripts" | jq -e '.test' >/dev/null 2>&1; then test_cmd="npm test" [[ -f "bun.lockb" ]] && test_cmd="bun test" fi # Lint command if echo "$scripts" | jq -e '.lint' >/dev/null 2>&1; then lint_cmd="npm run lint" fi # Build command if echo "$scripts" | jq -e '.build' >/dev/null 2>&1; then build_cmd="npm run build" fi elif [[ -f "pyproject.toml" ]] || [[ -f "requirements.txt" ]] || [[ -f "setup.py" ]]; then lang="Python" local py_frameworks=() local py_deps="" [[ -f "pyproject.toml" ]] && py_deps=$(cat pyproject.toml 2>/dev/null) [[ -f "requirements.txt" ]] && py_deps+=$(cat requirements.txt 2>/dev/null) echo "$py_deps" | grep -qi "fastapi" && py_frameworks+=("FastAPI") echo "$py_deps" | grep -qi "django" && py_frameworks+=("Django") echo "$py_deps" | grep -qi "flask" && py_frameworks+=("Flask") framework=$(IFS=', '; echo "${py_frameworks[*]}") test_cmd="pytest" lint_cmd="ruff check ." elif [[ -f "go.mod" ]]; then lang="Go" test_cmd="go test ./..." lint_cmd="golangci-lint run" elif [[ -f "Cargo.toml" ]]; then lang="Rust" test_cmd="cargo test" lint_cmd="cargo clippy" build_cmd="cargo build" fi # Show what we detected echo "" echo "${BOLD}Detected:${RESET}" echo " Project: ${CYAN}$project_name${RESET}" [[ -n "$lang" ]] && echo " Language: ${CYAN}$lang${RESET}" [[ -n "$framework" ]] && echo " Framework: ${CYAN}$framework${RESET}" [[ -n "$test_cmd" ]] && echo " Test: ${CYAN}$test_cmd${RESET}" [[ -n "$lint_cmd" ]] && echo " Lint: ${CYAN}$lint_cmd${RESET}" [[ -n "$build_cmd" ]] && echo " Build: ${CYAN}$build_cmd${RESET}" echo "" # Escape values for safe YAML (double quotes inside strings) yaml_escape() { printf '%s' "$1" | sed 's/"/\\"/g'; } # Create config.yaml with detected values cat > "$CONFIG_FILE" << EOF # Ralphy Configuration # https://github.com/michaelshimeles/ralphy # Project info (auto-detected, edit if needed) project: name: "$(yaml_escape "$project_name")" language: "$(yaml_escape "${lang:-Unknown}")" framework: "$(yaml_escape "${framework:-}")" description: "" # Add a brief description # Commands (auto-detected from package.json/pyproject.toml) commands: test: "$(yaml_escape "${test_cmd:-}")" lint: "$(yaml_escape "${lint_cmd:-}")" build: "$(yaml_escape "${build_cmd:-}")" # Rules - instructions the AI MUST follow # These are injected into every prompt rules: [] # Examples: # - "Always use TypeScript strict mode" # - "Follow the error handling pattern in src/utils/errors.ts" # - "All API endpoints must have input validation with Zod" # - "Use server actions instead of API routes in Next.js" # Boundaries - files/folders the AI should not modify boundaries: never_touch: [] # Examples: # - "src/legacy/**" # - "migrations/**" # - "*.lock" EOF # Create progress.txt echo "# Ralphy Progress Log" > "$PROGRESS_FILE" echo "" >> "$PROGRESS_FILE" log_success "Created $RALPHY_DIR/" echo "" echo " ${CYAN}$CONFIG_FILE${RESET} - Your rules and preferences" echo " ${CYAN}$PROGRESS_FILE${RESET} - Progress log (auto-updated)" echo "" echo "${BOLD}Next steps:${RESET}" echo " 1. Add rules: ${CYAN}ralphy --add-rule \"your rule here\"${RESET}" echo " 2. Or edit: ${CYAN}$CONFIG_FILE${RESET}" echo " 3. Run: ${CYAN}ralphy \"your task\"${RESET} or ${CYAN}ralphy${RESET} (with PRD.md)" } # Load rules from config.yaml load_ralphy_rules() { [[ ! -f "$CONFIG_FILE" ]] && return if command -v yq &>/dev/null; then yq -r '.rules // [] | .[]' "$CONFIG_FILE" 2>/dev/null || true fi } # Load boundaries from config.yaml load_ralphy_boundaries() { local boundary_type="$1" # never_touch or always_test [[ ! -f "$CONFIG_FILE" ]] && return if command -v yq &>/dev/null; then yq -r ".boundaries.$boundary_type // [] | .[]" "$CONFIG_FILE" 2>/dev/null || true fi } # Show current config show_ralphy_config() { if [[ ! -f "$CONFIG_FILE" ]]; then log_warn "No config found. Run 'ralphy --init' first." exit 1 fi echo "" echo "${BOLD}Ralphy Configuration${RESET} ($CONFIG_FILE)" echo "" if command -v yq &>/dev/null; then # Project info local name lang framework desc name=$(yq -r '.project.name // "Unknown"' "$CONFIG_FILE" 2>/dev/null) lang=$(yq -r '.project.language // "Unknown"' "$CONFIG_FILE" 2>/dev/null) framework=$(yq -r '.project.framework // ""' "$CONFIG_FILE" 2>/dev/null) desc=$(yq -r '.project.description // ""' "$CONFIG_FILE" 2>/dev/null) echo "${BOLD}Project:${RESET}" echo " Name: $name" echo " Language: $lang" [[ -n "$framework" ]] && echo " Framework: $framework" [[ -n "$desc" ]] && echo " About: $desc" echo "" # Commands local test_cmd lint_cmd build_cmd test_cmd=$(yq -r '.commands.test // ""' "$CONFIG_FILE" 2>/dev/null) lint_cmd=$(yq -r '.commands.lint // ""' "$CONFIG_FILE" 2>/dev/null) build_cmd=$(yq -r '.commands.build // ""' "$CONFIG_FILE" 2>/dev/null) echo "${BOLD}Commands:${RESET}" [[ -n "$test_cmd" ]] && echo " Test: $test_cmd" || echo " Test: ${DIM}(not set)${RESET}" [[ -n "$lint_cmd" ]] && echo " Lint: $lint_cmd" || echo " Lint: ${DIM}(not set)${RESET}" [[ -n "$build_cmd" ]] && echo " Build: $build_cmd" || echo " Build: ${DIM}(not set)${RESET}" echo "" # Rules echo "${BOLD}Rules:${RESET}" local rules rules=$(yq -r '.rules // [] | .[]' "$CONFIG_FILE" 2>/dev/null) if [[ -n "$rules" ]]; then echo "$rules" | while read -r rule; do echo " • $rule" done else echo " ${DIM}(none - add with: ralphy --add-rule \"...\")${RESET}" fi echo "" # Boundaries local never_touch never_touch=$(yq -r '.boundaries.never_touch // [] | .[]' "$CONFIG_FILE" 2>/dev/null) if [[ -n "$never_touch" ]]; then echo "${BOLD}Never Touch:${RESET}" echo "$never_touch" | while read -r path; do echo " • $path" done echo "" fi else # Fallback: just show the file cat "$CONFIG_FILE" fi } # Add a rule to config.yaml add_ralphy_rule() { local rule="$1" if [[ ! -f "$CONFIG_FILE" ]]; then log_error "No config found. Run 'ralphy --init' first." exit 1 fi if ! command -v yq &>/dev/null; then log_error "yq is required to add rules. Install from https://github.com/mikefarah/yq" log_info "Or manually edit $CONFIG_FILE" exit 1 fi # Add rule to the rules array (use env var to avoid YAML injection) RULE="$rule" yq -i '.rules += [env(RULE)]' "$CONFIG_FILE" log_success "Added rule: $rule" } # Load test command from config load_test_command() { [[ ! -f "$CONFIG_FILE" ]] && echo "" && return if command -v yq &>/dev/null; then yq -r '.commands.test // ""' "$CONFIG_FILE" 2>/dev/null || echo "" else echo "" fi } # Load project context from config.yaml load_project_context() { [[ ! -f "$CONFIG_FILE" ]] && return if command -v yq &>/dev/null; then local name lang framework desc name=$(yq -r '.project.name // ""' "$CONFIG_FILE" 2>/dev/null) lang=$(yq -r '.project.language // ""' "$CONFIG_FILE" 2>/dev/null) framework=$(yq -r '.project.framework // ""' "$CONFIG_FILE" 2>/dev/null) desc=$(yq -r '.project.description // ""' "$CONFIG_FILE" 2>/dev/null) local context="" [[ -n "$name" ]] && context+="Project: $name\n" [[ -n "$lang" ]] && context+="Language: $lang\n" [[ -n "$framework" ]] && context+="Framework: $framework\n" [[ -n "$desc" ]] && context+="Description: $desc\n" echo -e "$context" fi } # Log task to progress file log_task_history() { local task="$1" local status="$2" # completed, failed [[ ! -f "$PROGRESS_FILE" ]] && return local timestamp timestamp=$(date '+%Y-%m-%d %H:%M') local icon="✓" [[ "$status" == "failed" ]] && icon="✗" echo "- [$icon] $timestamp - $task" >> "$PROGRESS_FILE" } # Build prompt with brownfield context build_brownfield_prompt() { local task="$1" local prompt="" # Add project context if available local context context=$(load_project_context) if [[ -n "$context" ]]; then prompt+="## Project Context $context " fi # Add rules if available local rules rules=$(load_ralphy_rules) if [[ -n "$rules" ]]; then prompt+="## Rules (you MUST follow these) $rules " fi # Add boundaries local never_touch never_touch=$(load_ralphy_boundaries "never_touch") if [[ -n "$never_touch" ]]; then prompt+="## Boundaries Do NOT modify these files/directories: $never_touch " fi # Add the task prompt+="## Task $task ## Instructions 1. Implement the task described above 2. Write tests if appropriate 3. Ensure the code works correctly" # Add commit instruction only if auto-commit is enabled if [[ "$AUTO_COMMIT" == "true" ]]; then prompt+=" 4. Commit your changes with a descriptive message" fi prompt+=" Keep changes focused and minimal. Do not refactor unrelated code." echo "$prompt" } # Run a single brownfield task run_brownfield_task() { local task="$1" echo "" echo "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo "${BOLD}Task:${RESET} $task" echo "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo "" local prompt prompt=$(build_brownfield_prompt "$task") # Create temp file for output local output_file output_file=$(mktemp) log_info "Running with $AI_ENGINE..." # Run the AI engine (tee to show output while saving for parsing) case "$AI_ENGINE" in claude) claude --dangerously-skip-permissions \ -p "$prompt" 2>&1 | tee "$output_file" ;; opencode) opencode --output-format stream-json \ --approval-mode full-auto \ "$prompt" 2>&1 | tee "$output_file" ;; cursor) agent --dangerously-skip-permissions \ -p "$prompt" 2>&1 | tee "$output_file" ;; qwen) qwen --output-format stream-json \ --approval-mode yolo \ -p "$prompt" 2>&1 | tee "$output_file" ;; droid) droid exec --output-format stream-json \ --auto medium \ "$prompt" 2>&1 | tee "$output_file" ;; codex) codex exec --full-auto \ --json \ "$prompt" 2>&1 | tee "$output_file" ;; esac local exit_code=$? # Log to history if [[ $exit_code -eq 0 ]]; then log_task_history "$task" "completed" log_success "Task completed" else log_task_history "$task" "failed" log_error "Task failed" fi rm -f "$output_file" return $exit_code } # ============================================ # HELP & VERSION # ============================================ show_help() { cat << EOF ${BOLD}Ralphy${RESET} - Autonomous AI Coding Loop (v${VERSION}) ${BOLD}USAGE:${RESET} ./ralphy.sh [options] # PRD mode (requires PRD.md) ./ralphy.sh "task description" # Single task mode (brownfield) ./ralphy.sh --init # Initialize .ralphy/ config ${BOLD}CONFIG & SETUP:${RESET} --init Initialize .ralphy/ with smart defaults --config Show current configuration --add-rule "..." Add a rule to config (e.g., "Always use Zod") ${BOLD}SINGLE TASK MODE:${RESET} "task description" Run a single task without PRD (quotes required) --no-commit Don't auto-commit after task completion ${BOLD}AI ENGINE OPTIONS:${RESET} --claude Use Claude Code (default) --opencode Use OpenCode --cursor Use Cursor agent --codex Use Codex CLI --qwen Use Qwen-Code --droid Use Factory Droid ${BOLD}WORKFLOW OPTIONS:${RESET} --no-tests Skip writing and running tests --no-lint Skip linting --fast Skip both tests and linting ${BOLD}EXECUTION OPTIONS:${RESET} --max-iterations N Stop after N iterations (0 = unlimited) --max-retries N Max retries per task on failure (default: 3) --retry-delay N Seconds between retries (default: 5) --dry-run Show what would be done without executing ${BOLD}PARALLEL EXECUTION:${RESET} --parallel Run independent tasks in parallel --max-parallel N Max concurrent tasks (default: 3) ${BOLD}GIT BRANCH OPTIONS:${RESET} --branch-per-task Create a new git branch for each task --base-branch NAME Base branch to create task branches from (default: current) --create-pr Create a pull request after each task (requires gh CLI) --draft-pr Create PRs as drafts ${BOLD}PRD SOURCE OPTIONS:${RESET} --prd FILE PRD file path (default: PRD.md) --yaml FILE Use YAML task file instead of markdown --github REPO Fetch tasks from GitHub issues (e.g., owner/repo) --github-label TAG Filter GitHub issues by label ${BOLD}OTHER OPTIONS:${RESET} -v, --verbose Show debug output -h, --help Show this help --version Show version number ${BOLD}EXAMPLES:${RESET} # Brownfield mode (single tasks in existing projects) ./ralphy.sh --init # Initialize config ./ralphy.sh "add dark mode toggle" # Run single task ./ralphy.sh "fix the login bug" --cursor # Single task with Cursor # PRD mode (task lists) ./ralphy.sh # Run with Claude Code ./ralphy.sh --codex # Run with Codex CLI ./ralphy.sh --branch-per-task --create-pr # Feature branch workflow ./ralphy.sh --parallel --max-parallel 4 # Run 4 tasks concurrently ./ralphy.sh --yaml tasks.yaml # Use YAML task file ./ralphy.sh --github owner/repo # Fetch from GitHub issues ${BOLD}PRD FORMATS:${RESET} Markdown (PRD.md): - [ ] Task description YAML (tasks.yaml): tasks: - title: Task description completed: false parallel_group: 1 # Optional: tasks with same group run in parallel GitHub Issues: Uses open issues from the specified repository EOF } show_version() { echo "Ralphy v${VERSION}" } # ============================================ # ARGUMENT PARSING # ============================================ parse_args() { while [[ $# -gt 0 ]]; do case $1 in --no-tests|--skip-tests) SKIP_TESTS=true shift ;; --no-lint|--skip-lint) SKIP_LINT=true shift ;; --fast) SKIP_TESTS=true SKIP_LINT=true shift ;; --opencode) AI_ENGINE="opencode" shift ;; --claude) AI_ENGINE="claude" shift ;; --cursor|--agent) AI_ENGINE="cursor" shift ;; --codex) AI_ENGINE="codex" shift ;; --qwen) AI_ENGINE="qwen" shift ;; --droid) AI_ENGINE="droid" shift ;; --init) INIT_MODE=true shift ;; --config) SHOW_CONFIG=true shift ;; --add-rule) ADD_RULE="$2" shift 2 ;; --no-commit) AUTO_COMMIT=false shift ;; --max-iterations) MAX_ITERATIONS="$2" shift 2 ;; --max-retries) MAX_RETRIES="$2" shift 2 ;; --retry-delay) RETRY_DELAY="$2" shift 2 ;; --dry-run) DRY_RUN=true shift ;; --parallel) PARALLEL=true shift ;; --max-parallel) MAX_PARALLEL="$2" shift 2 ;; --branch-per-task) BRANCH_PER_TASK=true shift ;; --base-branch) BASE_BRANCH="$2" shift 2 ;; --create-pr) CREATE_PR=true shift ;; --draft-pr) PR_DRAFT=true shift ;; --prd) PRD_FILE="$2" shift 2 ;; --yaml) PRD_SOURCE="yaml" PRD_FILE="$2" shift 2 ;; --github) PRD_SOURCE="github" GITHUB_REPO="$2" shift 2 ;; --github-label) GITHUB_LABEL="$2" shift 2 ;; -v|--verbose) VERBOSE=true shift ;; -h|--help) show_help exit 0 ;; --version) show_version exit 0 ;; *) SINGLE_TASK="$1" shift ;; esac done # Validate arguments if [[ "$INIT_MODE" == true ]]; then init_ralphy_config exit 0 fi if [[ "$SHOW_CONFIG" == true ]]; then show_ralphy_config exit 0 fi if [[ -n "$ADD_RULE" ]]; then add_ralphy_rule "$ADD_RULE" exit 0 fi # If no single task and no PRD file, show help if [[ -z "$SINGLE_TASK" ]] && [[ ! -f "$PRD_FILE" ]]; then if [[ "$PRD_SOURCE" == "github" ]]; then # GitHub mode doesn't need local file : else echo "No PRD file found. Run './ralphy.sh --init' to create config, or create PRD.md, or specify a task." exit 1 fi fi # ============================================ # PRE-FLIGHT CHECKS # ============================================ check_requirements() { local missing=() # Check for PRD source case "$PRD_SOURCE" in markdown) if [[ ! -f "$PRD_FILE" ]]; then log_error "$PRD_FILE not found in current directory" log_info "Create a PRD.md file with tasks marked as '- [ ] Task description'" log_info "Or use: --yaml tasks.yaml for YAML task files" exit 1 fi ;; yaml) if [[ ! -f "$PRD_FILE" ]]; then log_error "$PRD_FILE not found in current directory" log_info "Create a tasks.yaml file with tasks in YAML format" log_info "Or use: --prd PRD.md for Markdown task files" exit 1 fi if ! command -v yq &>/dev/null; then log_error "yq is required for YAML parsing. Install from https://github.com/mikefarah/yq" exit 1 fi ;; github) if [[ -z "$GITHUB_REPO" ]]; then log_error "GitHub repository not specified. Use --github owner/repo" exit 1 fi if ! command -v gh &>/dev/null; then log_error "GitHub CLI (gh) is required. Install from https://cli.github.com/" exit 1 fi ;; esac # Check for AI CLI case "$AI_ENGINE" in opencode) if ! command -v opencode &>/dev/null; then log_error "OpenCode CLI not found." log_info "Install from: https://opencode.ai/docs/" exit 1 fi ;; codex) if ! command -v codex &>/dev/null; then log_error "Codex CLI not found." log_info "Make sure 'codex' is in your PATH." exit 1 fi ;; cursor) if ! command -v agent &>/dev/null; then log_error "Cursor agent CLI not found." log_info "Make sure Cursor is installed and 'agent' is in your PATH." exit 1 fi ;; qwen) if ! command -v qwen &>/dev/null; then log_error "Qwen-Code CLI not found." log_info "Make sure 'qwen' is in your PATH." exit 1 fi ;; droid) if ! command -v droid &>/dev/null; then log_error "Factory Droid CLI not found. Install from https://docs.factory.ai/cli/getting-started/quickstart" exit 1 fi ;; *) if ! command -v claude &>/dev/null; then log_error "Claude Code CLI not found." log_info "Install from: https://github.com/anthropics/claude-code" log_info "Or use another engine: --cursor, --opencode, --codex, --qwen" exit 1 fi ;; esac # Check for jq (required for JSON parsing) if ! command -v jq &>/dev/null; then log_error "jq is required but not installed. On Linux, install with: apt-get install jq (Debian/Ubuntu) or yum install jq (RHEL/CentOS)" exit 1 fi # Check for gh if PR creation is requested if [[ "$CREATE_PR" == true ]] && ! command -v gh &>/dev/null; then log_error "GitHub CLI (gh) is required for --create-pr. Install from https://cli.github.com/" exit 1 fi if [[ ${#missing[@]} -gt 0 ]]; then log_warn "Missing optional dependencies: ${missing[*]}" log_warn "Some features may not work properly" fi # Check for git if ! command -v git &>/dev/null; then log_error "git is required but not installed. Ralphy requires a git repository to track changes." exit 1 fi # Check if we're in a git repository if ! git rev-parse --git-dir >/dev/null 2>&1; then log_error "Not a git repository. Ralphy requires a git repository to track changes." exit 1 fi # Ensure .ralphy/ directory exists and create progress.txt if missing mkdir -p "$RALPHY_DIR" if [[ ! -f "$PROGRESS_FILE" ]]; then log_info "Creating $PROGRESS_FILE..." echo "# Ralphy Progress Log" > "$PROGRESS_FILE" echo "" >> "$PROGRESS_FILE" fi # Set base branch if not specified if [[ "$BRANCH_PER_TASK" == true ]] && [[ -z "$BASE_BRANCH" ]]; then BASE_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main") log_debug "Using base branch: $BASE_BRANCH" fi } # ============================================ # ARGUMENT PARSING END # ============================================ } # ============================================ # CLEANUP HANDLER # ============================================ cleanup() { local exit_code=$? # Kill background processes [[ -n "$monitor_pid" ]] && kill "$monitor_pid" 2>/dev/null || true [[ -n "$ai_pid" ]] && kill "$ai_pid" 2>/dev/null || true # Kill parallel processes for pid in "${parallel_pids[@]+"${parallel_pids[@]}"}"; do kill "$pid" 2>/dev/null || true done # Kill any remaining child processes pkill -P $$ 2>/dev/null || true # Remove temp file [[ -n "$tmpfile" ]] && rm -f "$tmpfile" [[ -n "$CODEX_LAST_MESSAGE_FILE" ]] && rm -f "$CODEX_LAST_MESSAGE_FILE" # Cleanup parallel worktrees if [[ -n "$WORKTREE_BASE" ]] && [[ -d "$WORKTREE_BASE" ]]; then # Remove all worktrees we created for dir in "$WORKTREE_BASE"/agent-*; do if [[ -d "$dir" ]]; then if git -C "$dir" status --porcelain 2>/dev/null | grep -q .; then log_warn "Preserving dirty worktree: $dir" continue fi git worktree remove "$dir" 2>/dev/null || true fi done if ! find "$WORKTREE_BASE" -maxdepth 1 -type d -name 'agent-*' -print -quit 2>/dev/null | grep -q .; then rm -rf "$WORKTREE_BASE" 2>/dev/null || true else log_warn "Preserving worktree base with dirty agents: $WORKTREE_BASE" fi fi # Show message on interrupt if [[ $exit_code -eq 130 ]]; then printf "\n" log_warn "Interrupted! Cleaned up." # Show branches created if any if [[ -n "${task_branches[*]+"${task_branches[*]}"}" ]]; then log_info "Branches created: ${task_branches[*]}" fi # Show integration branches if any (for parallel group workflows) if [[ -n "${integration_branches[*]+"${integration_branches[*]}"}" ]]; then log_info "Integration branches: ${integration_branches[*]}" if [[ -n "$ORIGINAL_BASE_BRANCH" ]]; then log_info "To resume: merge integration branches into $ORIGINAL_BASE_BRANCH" fi fi fi } # ============================================ # TASK SOURCES - MARKDOWN # ============================================ get_tasks_markdown() { grep '^\- \[ \]' "$PRD_FILE" 2>/dev/null | sed 's/^- \[ \] //' || true } get_next_task_markdown() { grep -m1 '^\- \[ \]' "$PRD_FILE" 2>/dev/null | sed 's/^- \[ \] //' | cut -c1-50 || echo "" } count_remaining_markdown() { grep -c '^\- \[ \]' "$PRD_FILE" 2>/dev/null || echo "0" } count_completed_markdown() { grep -c '^\- \[x\]' "$PRD_FILE" 2>/dev/null || echo "0" } mark_task_complete_markdown() { local task=$1 # For macOS sed (BRE), we need to: # - Escape: [ ] \ . * ^ $ / # - NOT escape: { } ( ) + ? | (these are literal in BRE) local escaped_task escaped_task=$(printf '%s\n' "$task" | sed 's/[[\.*^$/]/\\&/g') sed -i.bak "s/^- \[ \] ${escaped_task}/- [x] ${escaped_task}/" "$PRD_FILE" rm -f "${PRD_FILE}.bak" } # ============================================ # TASK SOURCES - YAML # ============================================ get_tasks_yaml() { yq -r '.tasks[] | select(.completed != true) | .title' "$PRD_FILE" 2>/dev/null || true } get_next_task_yaml() { yq -r '.tasks[] | select(.completed != true) | .title' "$PRD_FILE" 2>/dev/null | head -1 | cut -c1-50 || echo "" } count_remaining_yaml() { yq -r '[.tasks[] | select(.completed != true)] | length' "$PRD_FILE" 2>/dev/null || echo "0" } count_completed_yaml() { yq -r '[.tasks[] | select(.completed == true)] | length' "$PRD_FILE" 2>/dev/null || echo "0" } mark_task_complete_yaml() { local task=$1 yq -i "(.tasks[] | select(.title == \"$task\")).completed = true" "$PRD_FILE" } get_parallel_group_yaml() { local task=$1 yq -r ".tasks[] | select(.title == \"$task\") | .parallel_group // 0" "$PRD_FILE" 2>/dev/null || echo "0" } get_tasks_in_group_yaml() { local group=$1 yq -r ".tasks[] | select(.completed != true and (.parallel_group // 0) == $group) | .title" "$PRD_FILE" 2>/dev/null || true } # ============================================ # TASK SOURCES - GITHUB ISSUES # ============================================ get_tasks_github() { local args=(--repo "$GITHUB_REPO" --state open --json number,title) [[ -n "$GITHUB_LABEL" ]] && args+=(--label "$GITHUB_LABEL") gh issue list "${args[@]}" \ --jq '.[] | "\(.number):\(.title)"' 2>/dev/null || true } get_next_task_github() { local args=(--repo "$GITHUB_REPO" --state open --limit 1 --json number,title) [[ -n "$GITHUB_LABEL" ]] && args+=(--label "$GITHUB_LABEL") gh issue list "${args[@]}" \ --jq '.[0] | "\(.number):\(.title)"' 2>/dev/null | cut -c1-50 || echo "" } count_remaining_github() { local args=(--repo "$GITHUB_REPO" --state open --json number) [[ -n "$GITHUB_LABEL" ]] && args+=(--label "$GITHUB_LABEL") gh issue list "${args[@]}" \ --jq 'length' 2>/dev/null || echo "0" } count_completed_github() { local args=(--repo "$GITHUB_REPO" --state closed --json number) [[ -n "$GITHUB_LABEL" ]] && args+=(--label "$GITHUB_LABEL") gh issue list "${args[@]}" \ --jq 'length' 2>/dev/null || echo "0" } mark_task_complete_github() { local task=$1 # Extract issue number from "number:title" format local issue_num="${task%%:*}" gh issue close "$issue_num" --repo "$GITHUB_REPO" 2>/dev/null || true } get_github_issue_body() { local task=$1 local issue_num="${task%%:*}" gh issue view "$issue_num" --repo "$GITHUB_REPO" --json body --jq '.body' 2>/dev/null || echo "" } # ============================================ # UNIFIED TASK INTERFACE # ============================================ get_next_task() { case "$PRD_SOURCE" in markdown) get_next_task_markdown ;; yaml) get_next_task_yaml ;; github) get_next_task_github ;; esac } get_all_tasks() { case "$PRD_SOURCE" in markdown) get_tasks_markdown ;; yaml) get_tasks_yaml ;; github) get_tasks_github ;; esac } count_remaining_tasks() { case "$PRD_SOURCE" in markdown) count_remaining_markdown ;; yaml) count_remaining_yaml ;; github) count_remaining_github ;; esac } count_completed_tasks() { case "$PRD_SOURCE" in markdown) count_completed_markdown ;; yaml) count_completed_yaml ;; github) count_completed_github ;; esac } mark_task_complete() { local task=$1 case "$PRD_SOURCE" in markdown) mark_task_complete_markdown "$task" ;; yaml) mark_task_complete_yaml "$task" ;; github) mark_task_complete_github "$task" ;; esac } # ============================================ # GIT BRANCH MANAGEMENT # ============================================ create_task_branch() { local task=$1 local branch_name="ralphy/$(slugify "$task")" log_debug "Creating branch: $branch_name from $BASE_BRANCH" # Stash any changes (only pop if a new stash was created) local stash_before stash_after stashed=false stash_before=$(git stash list -1 --format='%gd %s' 2>/dev/null || true) git stash push -m "ralphy-autostash" >/dev/null 2>&1 || true stash_after=$(git stash list -1 --format='%gd %s' 2>/dev/null || true) if [[ -n "$stash_after" ]] && [[ "$stash_after" != "$stash_before" ]] && [[ "$stash_after" == *"ralphy-autostash"* ]]; then stashed=true fi # Create and checkout new branch git checkout "$BASE_BRANCH" 2>/dev/null || true git pull origin "$BASE_BRANCH" 2>/dev/null || true git checkout -b "$branch_name" 2>/dev/null || { # Branch might already exist git checkout "$branch_name" 2>/dev/null || true } # Pop stash if we stashed if [[ "$stashed" == true ]]; then git stash pop >/dev/null 2>&1 || true fi task_branches+=("$branch_name") echo "$branch_name" } create_pull_request() { local branch=$1 local task=$2 local body="${3:-Automated PR created by Ralphy}" local draft_flag="" [[ "$PR_DRAFT" == true ]] && draft_flag="--draft" log_info "Creating pull request for $branch..." # Push branch first git push -u origin "$branch" 2>/dev/null || { log_warn "Failed to push branch $branch" return 1 } # Create PR local pr_url pr_url=$(gh pr create \ --base "$BASE_BRANCH" \ --head "$branch" \ --title "$task" \ --body "$body" \ $draft_flag 2>/dev/null) || { log_warn "Failed to create PR for $branch" return 1 } log_success "PR created: $pr_url" echo "$pr_url" } return_to_base_branch() { if [[ "$BRANCH_PER_TASK" == true ]]; then git checkout "$BASE_BRANCH" 2>/dev/null || true fi } # ============================================ # PROGRESS MONITOR # ============================================ monitor_progress() { local file=$1 local task=$2 local start_time start_time=$(date +%s) local spinstr='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' local spin_idx=0 task="${task:0:40}" while true; do local elapsed=$(($(date +%s) - start_time)) local mins=$((elapsed / 60)) local secs=$((elapsed % 60)) # Check latest output for step indicators if [[ -f "$file" ]] && [[ -s "$file" ]]; then local content content=$(tail -c 5000 "$file" 2>/dev/null || true) if echo "$content" | grep -qE 'git commit|"command":"git commit'; then current_step="Committing" elif echo "$content" | grep -qE 'git add|"command":"git add'; then current_step="Staging" elif echo "$content" | grep -qE 'progress\.txt'; then current_step="Logging" elif echo "$content" | grep -qE 'PRD\.md|tasks\.yaml'; then current_step="Updating PRD" elif echo "$content" | grep -qE 'lint|eslint|biome|prettier'; then current_step="Linting" elif echo "$content" | grep -qE 'vitest|jest|bun test|npm test|pytest|go test'; then current_step="Testing" elif echo "$content" | grep -qE '\.test\.|\.spec\.|__tests__|_test\.go'; then current_step="Writing tests" elif echo "$content" | grep -qE '"tool":"[Ww]rite"|"tool":"[Ee]dit"|"name":"write"|"name":"edit"'; then current_step="Implementing" elif echo "$content" | grep -qE '"tool":"[Rr]ead"|"tool":"[Gg]lob"|"tool":"[Gg]rep"|"name":"read"|"name":"glob"|"name":"grep"'; then current_step="Reading code" fi fi local spinner_char="${spinstr:$spin_idx:1}" local step_color="" # Color-code steps case "$current_step" in "Thinking"|"Reading code") step_color="$CYAN" ;; "Implementing"|"Writing tests") step_color="$MAGENTA" ;; "Testing"|"Linting") step_color="$YELLOW" ;; "Staging"|"Committing") step_color="$GREEN" ;; *) step_color="$BLUE" ;; esac # Use tput for cleaner line clearing tput cr 2>/dev/null || printf "\r" tput el 2>/dev/null || true printf " %s ${step_color}%-16s${RESET} │ %s ${DIM}[%02d:%02d]${RESET}" "$spinner_char" "$current_step" "$task" "$mins" "$secs" spin_idx=$(( (spin_idx + 1) % ${#spinstr} )) sleep 0.12 done } # ============================================ # NOTIFICATION (Cross-platform) # ============================================ notify_done() { local message="${1:-Ralphy has completed all tasks!}" # macOS if command -v afplay &>/dev/null; then afplay /System/Library/Sounds/Glass.aiff 2>/dev/null & fi # macOS notification if command -v osascript &>/dev/null; then osascript -e "display notification \"$message\" with title \"Ralphy\"" 2>/dev/null || true fi # Linux (notify-send) if command -v notify-send &>/dev/null; then notify-send "Ralphy" "$message" 2>/dev/null || true fi # Linux (paplay for sound) if command -v paplay &>/dev/null; then paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null & fi # Windows (powershell) if command -v powershell.exe &>/dev/null; then powershell.exe -Command "[System.Media.SystemSounds]::Asterisk.Play()" 2>/dev/null || true fi } notify_error() { local message="${1:-Ralphy encountered an error}" # macOS if command -v osascript &>/dev/null; then osascript -e "display notification \"$message\" with title \"Ralphy - Error\"" 2>/dev/null || true fi # Linux if command -v notify-send &>/dev/null; then notify-send -u critical "Ralphy - Error" "$message" 2>/dev/null || true fi } # ============================================ # PROMPT BUILDER # ============================================ build_prompt() { local task_override="${1:-}" local prompt="" # Add .ralphy/ config if available (works with PRD mode too) if [[ -d "$RALPHY_DIR" ]]; then # Add project context local context context=$(load_project_context) if [[ -n "$context" ]]; then prompt+="## Project Context $context " fi # Add rules local rules rules=$(load_ralphy_rules) if [[ -n "$rules" ]]; then prompt+="## Rules (you MUST follow these) $rules " fi # Add boundaries local never_touch never_touch=$(load_ralphy_boundaries "never_touch") if [[ -n "$never_touch" ]]; then prompt+="## Boundaries - Do NOT modify these files: $never_touch " fi fi # Add context based on PRD source case "$PRD_SOURCE" in markdown) prompt="@${PRD_FILE} @$PROGRESS_FILE" ;; yaml) prompt="@${PRD_FILE} @$PROGRESS_FILE" ;; github) # For GitHub issues, we include the issue body local issue_body="" if [[ -n "$task_override" ]]; then issue_body=$(get_github_issue_body "$task_override") fi prompt="Task from GitHub Issue: $task_override Issue Description: $issue_body @$PROGRESS_FILE" ;; esac prompt="$prompt 1. Find the highest-priority incomplete task and implement it." local step=2 if [[ "$SKIP_TESTS" == false ]]; then prompt="$prompt $step. Write tests for the feature. $((step+1)). Run tests and ensure they pass before proceeding." step=$((step+2)) fi if [[ "$SKIP_LINT" == false ]]; then prompt="$prompt $step. Run linting and ensure it passes before proceeding." step=$((step+1)) fi # Adjust completion step based on PRD source case "$PRD_SOURCE" in markdown) prompt="$prompt $step. Update the PRD to mark the task as complete (change '- [ ]' to '- [x]')." ;; yaml) prompt="$prompt $step. Update ${PRD_FILE} to mark the task as completed (set completed: true)." ;; github) prompt="$prompt $step. The task will be marked complete automatically. Just note the completion in $PROGRESS_FILE." ;; esac step=$((step+1)) prompt="$prompt $step. Append your progress to $PROGRESS_FILE. $((step+1)). Commit your changes with a descriptive message. ONLY WORK ON A SINGLE TASK." if [[ "$SKIP_TESTS" == false ]]; then prompt="$prompt Do not proceed if tests fail." fi if [[ "$SKIP_LINT" == false ]]; then prompt="$prompt Do not proceed if linting fails." fi prompt="$prompt If ALL tasks in the PRD are complete, output COMPLETE." echo "$prompt" } # ============================================ # AI ENGINE ABSTRACTION # ============================================ run_ai_command() { local prompt=$1 local output_file=$2 case "$AI_ENGINE" in opencode) # OpenCode: use 'run' command with JSON format and permissive settings OPENCODE_PERMISSION='{"*":"allow"}' opencode run \ --format json \ "$prompt" > "$output_file" 2>&1 & ;; cursor) # Cursor agent: use --print for non-interactive, --force to allow all commands agent --print --force \ --output-format stream-json \ "$prompt" > "$output_file" 2>&1 & ;; qwen) # Qwen-Code: use CLI with JSON format and auto-approve tools qwen --output-format stream-json \ --approval-mode yolo \ -p "$prompt" > "$output_file" 2>&1 & ;; droid) # Droid: use exec with stream-json output and medium autonomy for development droid exec --output-format stream-json \ --auto medium \ "$prompt" > "$output_file" 2>&1 & ;; codex) CODEX_LAST_MESSAGE_FILE="${output_file}.last" rm -f "$CODEX_LAST_MESSAGE_FILE" codex exec --full-auto \ --json \ --output-last-message "$CODEX_LAST_MESSAGE_FILE" \ "$prompt" > "$output_file" 2>&1 & ;; *) # Claude Code: use existing approach claude --dangerously-skip-permissions \ --verbose \ --output-format stream-json \ -p "$prompt" > "$output_file" 2>&1 & ;; esac ai_pid=$! } parse_ai_result() { local result=$1 local response="" local input_tokens=0 local output_tokens=0 local actual_cost="0" case "$AI_ENGINE" in opencode) # OpenCode JSON format: uses step_finish for tokens and text events for response local step_finish step_finish=$(echo "$result" | grep '"type":"step_finish"' | tail -1 || echo "") if [[ -n "$step_finish" ]]; then input_tokens=$(echo "$step_finish" | jq -r '.part.tokens.input // 0' 2>/dev/null || echo "0") output_tokens=$(echo "$step_finish" | jq -r '.part.tokens.output // 0' 2>/dev/null || echo "0") # OpenCode provides actual cost directly actual_cost=$(echo "$step_finish" | jq -r '.part.cost // 0' 2>/dev/null || echo "0") fi # Get text response from text events response=$(echo "$result" | grep '"type":"text"' | jq -rs 'map(.part.text // "") | join("")' 2>/dev/null || echo "") # If no text found, indicate task completed if [[ -z "$response" ]]; then response="Task completed" fi ;; cursor) # Cursor agent: parse stream-json output # Cursor doesn't provide token counts, but does provide duration_ms local result_line result_line=$(echo "$result" | grep '"type":"result"' | tail -1) if [[ -n "$result_line" ]]; then response=$(echo "$result_line" | jq -r '.result // "Task completed"' 2>/dev/null || echo "Task completed") # Cursor provides duration instead of tokens local duration_ms duration_ms=$(echo "$result_line" | jq -r '.duration_ms // 0' 2>/dev/null || echo "0") # Store duration in output_tokens field for now (we'll handle it specially) # Use negative value as marker that this is duration, not tokens if [[ "$duration_ms" =~ ^[0-9]+$ ]] && [[ "$duration_ms" -gt 0 ]]; then # Encode duration: store as-is, we track separately actual_cost="duration:$duration_ms" fi fi # Get response from assistant message if result is empty if [[ -z "$response" ]] || [[ "$response" == "Task completed" ]]; then local assistant_msg assistant_msg=$(echo "$result" | grep '"type":"assistant"' | tail -1) if [[ -n "$assistant_msg" ]]; then response=$(echo "$assistant_msg" | jq -r '.message.content[0].text // .message.content // "Task completed"' 2>/dev/null || echo "Task completed") fi fi # Tokens remain 0 for Cursor (not available) input_tokens=0 output_tokens=0 ;; qwen) # Qwen-Code stream-json parsing (similar to Claude Code) local result_line result_line=$(echo "$result" | grep '"type":"result"' | tail -1) if [[ -n "$result_line" ]]; then response=$(echo "$result_line" | jq -r '.result // "No result text"' 2>/dev/null || echo "Could not parse result") input_tokens=$(echo "$result_line" | jq -r '.usage.input_tokens // 0' 2>/dev/null || echo "0") output_tokens=$(echo "$result_line" | jq -r '.usage.output_tokens // 0' 2>/dev/null || echo "0") fi # Fallback when no response text was parsed, similar to OpenCode behavior if [[ -z "$response" ]]; then response="Task completed" fi ;; droid) # Droid stream-json parsing # Look for completion event which has the final result local completion_line completion_line=$(echo "$result" | grep '"type":"completion"' | tail -1) if [[ -n "$completion_line" ]]; then response=$(echo "$completion_line" | jq -r '.finalText // "Task completed"' 2>/dev/null || echo "Task completed") # Droid provides duration_ms in completion event local dur_ms dur_ms=$(echo "$completion_line" | jq -r '.durationMs // 0' 2>/dev/null || echo "0") if [[ "$dur_ms" =~ ^[0-9]+$ ]] && [[ "$dur_ms" -gt 0 ]]; then # Store duration for tracking actual_cost="duration:$dur_ms" fi fi # Tokens remain 0 for Droid (not exposed in exec mode) input_tokens=0 output_tokens=0 ;; codex) if [[ -n "$CODEX_LAST_MESSAGE_FILE" ]] && [[ -f "$CODEX_LAST_MESSAGE_FILE" ]]; then response=$(cat "$CODEX_LAST_MESSAGE_FILE" 2>/dev/null || echo "") # Codex sometimes prefixes a generic completion line; drop it for readability. response=$(printf '%s' "$response" | sed '1{/^Task completed successfully\.[[:space:]]*$/d;}') fi input_tokens=0 output_tokens=0 ;; *) # Claude Code stream-json parsing local result_line result_line=$(echo "$result" | grep '"type":"result"' | tail -1) if [[ -n "$result_line" ]]; then response=$(echo "$result_line" | jq -r '.result // "No result text"' 2>/dev/null || echo "Could not parse result") input_tokens=$(echo "$result_line" | jq -r '.usage.input_tokens // 0' 2>/dev/null || echo "0") output_tokens=$(echo "$result_line" | jq -r '.usage.output_tokens // 0' 2>/dev/null || echo "0") fi ;; esac # Sanitize token counts [[ "$input_tokens" =~ ^[0-9]+$ ]] || input_tokens=0 [[ "$output_tokens" =~ ^[0-9]+$ ]] || output_tokens=0 echo "$response" echo "---TOKENS---" echo "$input_tokens" echo "$output_tokens" echo "$actual_cost" } check_for_errors() { local result=$1 if echo "$result" | grep -q '"type":"error"'; then local error_msg error_msg=$(echo "$result" | grep '"type":"error"' | head -1 | jq -r '.error.message // .message // .' 2>/dev/null || echo "Unknown error") echo "$error_msg" return 1 fi return 0 } # ============================================ # COST CALCULATION # ============================================ calculate_cost() { local input=$1 local output=$2 if command -v bc &>/dev/null; then echo "scale=4; ($input * 0.000003) + ($output * 0.000015)" | bc else echo "N/A" fi } # ============================================ # SINGLE TASK EXECUTION # ============================================ run_single_task() { local task_name="${1:-}" local task_num="${2:-$iteration}" retry_count=0 echo "" echo "${BOLD}>>> Task $task_num${RESET}" local remaining completed remaining=$(count_remaining_tasks | tr -d '[:space:]') completed=$(count_completed_tasks | tr -d '[:space:]') remaining=${remaining:-0} completed=${completed:-0} echo "${DIM} Completed: $completed | Remaining: $remaining${RESET}" echo "--------------------------------------------" # Get current task for display local current_task if [[ -n "$task_name" ]]; then current_task="$task_name" else current_task=$(get_next_task) fi if [[ -z "$current_task" ]]; then log_info "No more tasks found" return 2 fi current_step="Thinking" # Create branch if needed local branch_name="" if [[ "$BRANCH_PER_TASK" == true ]]; then branch_name=$(create_task_branch "$current_task") log_info "Working on branch: $branch_name" fi # Temp file for AI output tmpfile=$(mktemp) # Build the prompt local prompt prompt=$(build_prompt "$current_task") if [[ "$DRY_RUN" == true ]]; then log_info "DRY RUN - Would execute:" echo "${DIM}$prompt${RESET}" rm -f "$tmpfile" tmpfile="" return_to_base_branch return 0 fi # Run with retry logic while [[ $retry_count -lt $MAX_RETRIES ]]; do # Start AI command run_ai_command "$prompt" "$tmpfile" # Start progress monitor in background monitor_progress "$tmpfile" "${current_task:0:40}" & monitor_pid=$! # Wait for AI to finish wait "$ai_pid" 2>/dev/null || true # Stop the monitor kill "$monitor_pid" 2>/dev/null || true wait "$monitor_pid" 2>/dev/null || true monitor_pid="" # Show completion tput cr 2>/dev/null || printf "\r" tput el 2>/dev/null || true # Read result local result result=$(cat "$tmpfile" 2>/dev/null || echo "") # Check for empty response if [[ -z "$result" ]]; then ((retry_count++)) || true if [[ $retry_count -lt $MAX_RETRIES ]]; then log_warn "Empty response, retrying... ($retry_count/$MAX_RETRIES)" sleep "$RETRY_DELAY" fi continue fi # Check for errors local error_msg error_msg=$(check_for_errors "$result") if [[ -n "$error_msg" ]]; then ((retry_count++)) || true if [[ $retry_count -lt $MAX_RETRIES ]]; then log_warn "AI error: $error_msg" log_warn "Retrying... ($retry_count/$MAX_RETRIES)" sleep "$RETRY_DELAY" fi continue fi # Parse result local parsed parsed=$(parse_ai_result "$result") local response response=$(echo "$parsed" | sed '/^---TOKENS---$/,$d') local token_data token_data=$(echo "$parsed" | sed -n '/^---TOKENS---$/,$p' | tail -3) local input_tokens input_tokens=$(echo "$token_data" | sed -n '1p') local output_tokens output_tokens=$(echo "$token_data" | sed -n '2p') local actual_cost actual_cost=$(echo "$token_data" | sed -n '3p') printf " ${GREEN}✓${RESET} %-16s │ %s\n" "Done" "${current_task:0:40}" if [[ -n "$response" ]]; then echo "" echo "$response" fi # Sanitize values [[ "$input_tokens" =~ ^[0-9]+$ ]] || input_tokens=0 [[ "$output_tokens" =~ ^[0-9]+$ ]] || output_tokens=0 # Update totals total_input_tokens=$((total_input_tokens + input_tokens)) total_output_tokens=$((total_output_tokens + output_tokens)) # Track actual cost for OpenCode, or duration for Cursor if [[ -n "$actual_cost" ]]; then if [[ "$actual_cost" == duration:* ]]; then # Cursor duration tracking local dur_ms="${actual_cost#duration:}" [[ "$dur_ms" =~ ^[0-9]+$ ]] && total_duration_ms=$((total_duration_ms + dur_ms)) elif [[ "$actual_cost" != "0" ]] && command -v bc &>/dev/null; then # OpenCode cost tracking total_actual_cost=$(echo "scale=6; $total_actual_cost + $actual_cost" | bc 2>/dev/null || echo "$total_actual_cost") fi fi rm -f "$tmpfile" tmpfile="" if [[ "$AI_ENGINE" == "codex" ]] && [[ -n "$CODEX_LAST_MESSAGE_FILE" ]]; then rm -f "$CODEX_LAST_MESSAGE_FILE" CODEX_LAST_MESSAGE_FILE="" fi # Mark task complete for GitHub issues (since AI can't do it) if [[ "$PRD_SOURCE" == "github" ]]; then mark_task_complete "$current_task" fi # Create PR if requested if [[ "$CREATE_PR" == true ]] && [[ -n "$branch_name" ]]; then create_pull_request "$branch_name" "$current_task" "Automated implementation by Ralphy" fi # Return to base branch return_to_base_branch # Check for completion - verify by actually counting remaining tasks local remaining_count remaining_count=$(count_remaining_tasks | tr -d '[:space:]' | head -1) remaining_count=${remaining_count:-0} [[ "$remaining_count" =~ ^[0-9]+$ ]] || remaining_count=0 if [[ "$remaining_count" -eq 0 ]]; then return 2 # All tasks actually complete fi # AI might claim completion but tasks remain - continue anyway if [[ "$result" == *"COMPLETE"* ]]; then log_debug "AI claimed completion but $remaining_count tasks remain, continuing..." fi return 0 done return_to_base_branch return 1 } # ============================================ # PARALLEL TASK EXECUTION # ============================================ # Create an isolated worktree for a parallel agent create_agent_worktree() { local task_name="$1" local agent_num="$2" local branch_name="ralphy/agent-${agent_num}-$(slugify "$task_name")" local worktree_dir="${WORKTREE_BASE}/agent-${agent_num}" # Run git commands from original directory # All git output goes to stderr so it doesn't interfere with our return value ( cd "$ORIGINAL_DIR" || { echo "Failed to cd to $ORIGINAL_DIR" >&2; exit 1; } # Prune any stale worktrees first git worktree prune >&2 # Delete branch if it exists (force) git branch -D "$branch_name" >&2 2>/dev/null || true # Create worktree git worktree add "$worktree_dir" "$ORIGINAL_BASE_BRANCH" >&2 || { echo "Failed to create worktree $worktree_dir" >&2 exit 1 } # Create and checkout branch in the worktree ( cd "$worktree_dir" || exit 1 git checkout -b "$branch_name" >&2 || { echo "Failed to create branch $branch_name" >&2 exit 1 } ) || exit 1 ) || return 1 echo "$worktree_dir" } # Run a single agent in parallel run_parallel_agent() { local agent_num="$1" local task_name="$2" local worktree_dir="$3" # Change to worktree directory cd "$worktree_dir" || return 1 # Set up agent-specific state local agent_iteration=0 local agent_tmpfile="" local agent_monitor_pid="" local agent_ai_pid="" # Create agent-specific progress file local agent_progress_file="$RALPHY_DIR/progress-agent-${agent_num}.txt" echo "# Ralphy Agent $agent_num Progress Log" > "$agent_progress_file" echo "" >> "$agent_progress_file" log_info "Agent $agent_num: Starting task '$task_name'" # Run single task with agent-specific settings while true; do ((agent_iteration++)) || true # Create temp file for this agent's AI output agent_tmpfile=$(mktemp) # Build prompt for this specific task local prompt prompt=$(build_prompt "$task_name") # Replace progress file reference with agent-specific one prompt=$(echo "$prompt" | sed "s/@$PROGRESS_FILE/@$agent_progress_file/") # Run AI command run_ai_command "$prompt" "$agent_tmpfile" # Start progress monitor monitor_progress "$agent_tmpfile" "${task_name:0:30} (A$agent_num)" & agent_monitor_pid=$! # Wait for AI wait "$agent_ai_pid" 2>/dev/null || true # Stop monitor kill "$agent_monitor_pid" 2>/dev/null || true wait "$agent_monitor_pid" 2>/dev/null || true agent_monitor_pid="" # Show completion for this agent tput cr 2>/dev/null || printf "\r" tput el 2>/dev/null || true printf " ${GREEN}✓${RESET} %-16s │ %s ${DIM}(Agent %d)${RESET}\n" "Done" "${task_name:0:30}" "$agent_num" # Read result local result result=$(cat "$agent_tmpfile" 2>/dev/null || echo "") # Parse result (similar to run_single_task) if [[ -n "$result" ]]; then local parsed parsed=$(parse_ai_result "$result") local response response=$(echo "$parsed" | sed '/^---TOKENS---$/,$d') # Update global token counts (simplified - just add to totals) local token_data token_data=$(echo "$parsed" | sed -n '/^---TOKENS---$/,$p' | tail -3) local input_tokens input_tokens=$(echo "$token_data" | sed -n '1p') local output_tokens output_tokens=$(echo "$token_data" | sed -n '2p') local actual_cost actual_cost=$(echo "$token_data" | sed -n '3p') [[ "$input_tokens" =~ ^[0-9]+$ ]] && total_input_tokens=$((total_input_tokens + input_tokens)) [[ "$output_tokens" =~ ^[0-9]+$ ]] && total_output_tokens=$((total_output_tokens + output_tokens)) if [[ -n "$actual_cost" ]]; then if [[ "$actual_cost" == duration:* ]]; then local dur_ms="${actual_cost#duration:}" [[ "$dur_ms" =~ ^[0-9]+$ ]] && total_duration_ms=$((total_duration_ms + dur_ms)) elif [[ "$actual_cost" != "0" ]] && command -v bc &>/dev/null; then total_actual_cost=$(echo "scale=6; $total_actual_cost + $actual_cost" | bc 2>/dev/null || echo "$total_actual_cost") fi fi fi rm -f "$agent_tmpfile" agent_tmpfile="" # Check if task is complete (AI should mark it in the PRD file) # For parallel execution, we assume the AI marks tasks complete appropriately # We break after one successful run per agent break done # Merge changes back to integration branch merge_to_integration "$worktree_dir" "$task_name" "$agent_num" log_info "Agent $agent_num: Completed task '$task_name'" } # Merge agent worktree back to integration branch merge_to_integration() { local worktree_dir="$1" local task_name="$2" local agent_num="$3" local branch_name="ralphy/agent-${agent_num}-$(slugify "$task_name")" local integration_branch="ralphy/integration-$(slugify "$task_name")" # Run git commands from original directory ( cd "$ORIGINAL_DIR" || exit 1 # Create integration branch if it doesn't exist if ! git show-ref --verify --quiet "refs/heads/$integration_branch"; then git checkout -b "$integration_branch" "$ORIGINAL_BASE_BRANCH" >&2 || { echo "Failed to create integration branch $integration_branch" >&2 exit 1 } integration_branches+=("$integration_branch") else git checkout "$integration_branch" >&2 || exit 1 fi # Merge agent branch (allow unrelated histories) git merge "$branch_name" --allow-unrelated-histories --no-edit >&2 || { echo "Failed to merge $branch_name into $integration_branch" >&2 # Try to resolve conflicts automatically git merge --abort >&2 2>/dev/null || true exit 1 } # Push integration branch git push -u origin "$integration_branch" >&2 2>/dev/null || true ) || log_warn "Agent $agent_num: Failed to merge changes for '$task_name'" } # Run parallel execution run_parallel_tasks() { # Set up worktree base WORKTREE_BASE=$(mktemp -d) ORIGINAL_DIR="$PWD" ORIGINAL_BASE_BRANCH="$BASE_BRANCH" log_info "Using worktree base: $WORKTREE_BASE" # Get all tasks local all_tasks all_tasks=$(get_all_tasks) if [[ -z "$all_tasks" ]]; then log_warn "No tasks found" return fi # Convert to array local task_array=() while IFS= read -r task; do task_array+=("$task") done <<< "$all_tasks" local total_tasks=${#task_array[@]} log_info "Running $total_tasks tasks in parallel (max $MAX_PARALLEL concurrent)" local agent_num=0 local active_pids=() local completed_count=0 for task in "${task_array[@]}"; do ((agent_num++)) || true # Create worktree for this agent local worktree_dir worktree_dir=$(create_agent_worktree "$task" "$agent_num") if [[ -z "$worktree_dir" ]]; then log_error "Failed to create worktree for agent $agent_num" continue fi # Start agent in background run_parallel_agent "$agent_num" "$task" "$worktree_dir" & local pid=$! parallel_pids+=("$pid") active_pids+=("$pid") log_debug "Started agent $agent_num (PID $pid) for task: $task" # Wait if we've reached max parallel while [[ ${#active_pids[@]} -ge $MAX_PARALLEL ]]; do # Check which PIDs are still running local still_active=() for pid in "${active_pids[@]}"; do if kill -0 "$pid" 2>/dev/null; then still_active+=("$pid") else wait "$pid" 2>/dev/null || true ((completed_count++)) || true fi done active_pids=("${still_active[@]}") # Brief sleep to avoid busy waiting sleep 0.5 done done # Wait for remaining agents for pid in "${active_pids[@]}"; do wait "$pid" 2>/dev/null || true ((completed_count++)) || true done log_success "All $completed_count parallel agents completed" # Create PRs for integration branches if requested if [[ "$CREATE_PR" == true ]] && [[ -n "${integration_branches[*]+"${integration_branches[*]}"}" ]]; then for branch in "${integration_branches[@]}"; do create_pull_request "$branch" "Integration: ${branch#ralphy/integration-}" "Automated integration by Ralphy parallel execution" done fi # Cleanup worktrees if [[ -d "$WORKTREE_BASE" ]]; then for dir in "$WORKTREE_BASE"/agent-*; do if [[ -d "$dir" ]]; then git worktree remove "$dir" 2>/dev/null || true fi done rm -rf "$WORKTREE_BASE" 2>/dev/null || true fi } # ============================================ # SUMMARY & REPORTING # ============================================ show_summary() { local completed completed=$(count_completed_tasks | tr -d '[:space:]') completed=${completed:-0} echo "" echo "${BOLD}============================================${RESET}" echo "${BOLD}SUMMARY${RESET}" echo "${BOLD}============================================${RESET}" echo "Tasks completed: $completed" # Show token usage if [[ $total_input_tokens -gt 0 ]] || [[ $total_output_tokens -gt 0 ]]; then echo "Input tokens: $total_input_tokens" echo "Output tokens: $total_output_tokens" if [[ -n "$total_actual_cost" ]] && [[ "$total_actual_cost" != "0" ]]; then echo "Actual cost: $$total_actual_cost" else local estimated_cost estimated_cost=$(calculate_cost "$total_input_tokens" "$total_output_tokens") echo "Est. cost: $$estimated_cost" fi fi # Show duration if available if [[ $total_duration_ms -gt 0 ]]; then local total_seconds=$((total_duration_ms / 1000)) local mins=$((total_seconds / 60)) local secs=$((total_seconds % 60)) echo "Duration: ${mins}m ${secs}s" fi echo "" echo "${GREEN}All tasks completed!${RESET}" echo "" } # ============================================ # MAIN # ============================================ main() { parse_args "$@" # Handle --init mode if [[ "$INIT_MODE" == true ]]; then init_ralphy_config exit 0 fi # Handle --config mode if [[ "$SHOW_CONFIG" == true ]]; then show_ralphy_config exit 0 fi # Handle --add-rule if [[ -n "$ADD_RULE" ]]; then add_ralphy_rule "$ADD_RULE" exit 0 fi # Handle single-task (brownfield) mode if [[ -n "$SINGLE_TASK" ]]; then # Set up cleanup trap trap cleanup EXIT trap 'exit 130' INT TERM HUP # Check basic requirements (AI engine, git) case "$AI_ENGINE" in claude) command -v claude &>/dev/null || { log_error "Claude Code CLI not found"; exit 1; } ;; opencode) command -v opencode &>/dev/null || { log_error "OpenCode CLI not found"; exit 1; } ;; cursor) command -v agent &>/dev/null || { log_error "Cursor agent CLI not found"; exit 1; } ;; codex) command -v codex &>/dev/null || { log_error "Codex CLI not found"; exit 1; } ;; qwen) command -v qwen &>/dev/null || { log_error "Qwen-Code CLI not found"; exit 1; } ;; droid) command -v droid &>/dev/null || { log_error "Factory Droid CLI not found"; exit 1; } ;; esac if ! git rev-parse --git-dir >/dev/null 2>&1; then log_error "Not a git repository" exit 1 fi # Show brownfield banner echo "${BOLD}============================================${RESET}" echo "${BOLD}Ralphy${RESET} - Single Task Mode" local engine_display case "$AI_ENGINE" in opencode) engine_display="${CYAN}OpenCode${RESET}" ;; cursor) engine_display="${YELLOW}Cursor Agent${RESET}" ;; codex) engine_display="${BLUE}Codex${RESET}" ;; qwen) engine_display="${GREEN}Qwen-Code${RESET}" ;; droid) engine_display="${MAGENTA}Factory Droid${RESET}" ;; *) engine_display="${MAGENTA}Claude Code${RESET}" ;; esac echo "Engine: $engine_display" if [[ -d "$RALPHY_DIR" ]]; then echo "Config: ${GREEN}$RALPHY_DIR/${RESET}" else echo "Config: ${DIM}none (run --init to configure)${RESET}" fi echo "${BOLD}============================================${RESET}" run_brownfield_task "$SINGLE_TASK" exit $? fi if [[ "$DRY_RUN" == true ]] && [[ "$MAX_ITERATIONS" -eq 0 ]]; then MAX_ITERATIONS=1 fi # Set up cleanup trap trap cleanup EXIT trap 'exit 130' INT TERM HUP # Check requirements check_requirements # Show banner echo "${BOLD}============================================${RESET}" echo "${BOLD}Ralphy${RESET} - Running until PRD is complete" local engine_display case "$AI_ENGINE" in opencode) engine_display="${CYAN}OpenCode${RESET}" ;; cursor) engine_display="${YELLOW}Cursor Agent${RESET}" ;; codex) engine_display="${BLUE}Codex${RESET}" ;; qwen) engine_display="${GREEN}Qwen-Code${RESET}" ;; droid) engine_display="${MAGENTA}Factory Droid${RESET}" ;; *) engine_display="${MAGENTA}Claude Code${RESET}" ;; esac echo "Engine: $engine_display" echo "Source: ${CYAN}$PRD_SOURCE${RESET} (${PRD_FILE:-$GITHUB_REPO})" if [[ -d "$RALPHY_DIR" ]]; then echo "Config: ${GREEN}$RALPHY_DIR/${RESET} (rules loaded)" fi local mode_parts=() [[ "$SKIP_TESTS" == true ]] && mode_parts+=("no-tests") [[ "$SKIP_LINT" == true ]] && mode_parts+=("no-lint") [[ "$DRY_RUN" == true ]] && mode_parts+=("dry-run") [[ "$PARALLEL" == true ]] && mode_parts+=("parallel:$MAX_PARALLEL") [[ "$BRANCH_PER_TASK" == true ]] && mode_parts+=("branch-per-task") [[ "$CREATE_PR" == true ]] && mode_parts+=("create-pr") [[ $MAX_ITERATIONS -gt 0 ]] && mode_parts+=("max:$MAX_ITERATIONS") if [[ ${#mode_parts[@]} -gt 0 ]]; then echo "Mode: ${YELLOW}${mode_parts[*]}${RESET}" fi echo "${BOLD}============================================${RESET}" # Run in parallel or sequential mode if [[ "$PARALLEL" == true ]]; then run_parallel_tasks show_summary notify_done exit 0 fi # Sequential main loop while true; do ((iteration++)) || true local result_code=0 run_single_task "" "$iteration" || result_code=$? case $result_code in 0) # Success, continue ;; 1) # Error, but continue to next task log_warn "Task failed after $MAX_RETRIES attempts, continuing..." ;; 2) # All tasks complete show_summary notify_done exit 0 ;; esac # Check max iterations if [[ $MAX_ITERATIONS -gt 0 ]] && [[ $iteration -ge $MAX_ITERATIONS ]]; then log_warn "Reached max iterations ($MAX_ITERATIONS)" show_summary notify_done "Ralphy stopped after $MAX_ITERATIONS iterations" exit 0 fi # Small delay between iterations sleep 1 done } # Run main main "$@"