#!/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="opencode" # 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
## Progress
$(cat "$PROGRESS_FILE")
## 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
$(cat "$PRD_FILE")
## Progress
$(cat "$PROGRESS_FILE")"
;;
yaml)
prompt="## Tasks
$(cat "$PRD_FILE")
## Progress
$(cat "$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
$(cat "$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 "$@"