mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 17:24:30 +00:00
- Add ralphy.sh: Autonomous AI coding loop supporting multiple engines - Initialize .ralphy/ config directory for project automation - Update PRD.md with task list for AnchorOS development
2371 lines
68 KiB
Bash
Executable File
2371 lines
68 KiB
Bash
Executable File
#!/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 <promise>COMPLETE</promise>."
|
|
|
|
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" == *"<promise>COMPLETE</promise>"* ]]; 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 "$@"
|