Fixes #113 - EXIT_SIGNAL not detected in JSON output format Fixes #117 - ralph_monitor.sh uses wrong paths after v0.10.0 migration Bug fixes: - Parse EXIT_SIGNAL from .result field when Claude CLI returns JSON format - Add safety circuit breaker: force exit after 5 consecutive completion indicators - Fix checkbox parsing for indented markdown with POSIX [[:space:]]* pattern - Update ralph_monitor.sh paths: status.json, logs/ralph.log, progress.json Files changed: - lib/response_analyzer.sh: Extract RALPH_STATUS from embedded .result text - ralph_loop.sh: Safety circuit breaker + indented checkbox patterns - ralph_monitor.sh: All paths updated for .ralph/ subfolder - create_files.sh: Indented checkbox pattern consistency - README.md: v0.10.1 changelog, version bump, test count update - CLAUDE.md: Version bump to v0.10.1 All 310 tests pass.
1368 lines
48 KiB
Bash
Executable file
1368 lines
48 KiB
Bash
Executable file
#!/bin/bash
|
|
|
|
# Claude Code Ralph Loop with Rate Limiting and Documentation
|
|
# Adaptation of the Ralph technique for Claude Code with usage management
|
|
|
|
set -e # Exit on any error
|
|
|
|
# Source library components
|
|
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
|
source "$SCRIPT_DIR/lib/date_utils.sh"
|
|
source "$SCRIPT_DIR/lib/timeout_utils.sh"
|
|
source "$SCRIPT_DIR/lib/response_analyzer.sh"
|
|
source "$SCRIPT_DIR/lib/circuit_breaker.sh"
|
|
|
|
# Configuration
|
|
# Ralph-specific files live in .ralph/ subfolder
|
|
RALPH_DIR=".ralph"
|
|
PROMPT_FILE="$RALPH_DIR/PROMPT.md"
|
|
LOG_DIR="$RALPH_DIR/logs"
|
|
DOCS_DIR="$RALPH_DIR/docs/generated"
|
|
STATUS_FILE="$RALPH_DIR/status.json"
|
|
PROGRESS_FILE="$RALPH_DIR/progress.json"
|
|
CLAUDE_CODE_CMD="claude"
|
|
MAX_CALLS_PER_HOUR=100 # Adjust based on your plan
|
|
VERBOSE_PROGRESS=false # Default: no verbose progress updates
|
|
CLAUDE_TIMEOUT_MINUTES=15 # Default: 15 minutes timeout for Claude Code execution
|
|
SLEEP_DURATION=3600 # 1 hour in seconds
|
|
CALL_COUNT_FILE="$RALPH_DIR/.call_count"
|
|
TIMESTAMP_FILE="$RALPH_DIR/.last_reset"
|
|
USE_TMUX=false
|
|
|
|
# Modern Claude CLI configuration (Phase 1.1)
|
|
CLAUDE_OUTPUT_FORMAT="json" # Options: json, text
|
|
CLAUDE_ALLOWED_TOOLS="Write,Bash(git *),Read" # Comma-separated list of allowed tools
|
|
CLAUDE_USE_CONTINUE=true # Enable session continuity
|
|
CLAUDE_SESSION_FILE="$RALPH_DIR/.claude_session_id" # Session ID persistence file
|
|
CLAUDE_MIN_VERSION="2.0.76" # Minimum required Claude CLI version
|
|
|
|
# Session management configuration (Phase 1.2)
|
|
# Note: SESSION_EXPIRATION_SECONDS is defined in lib/response_analyzer.sh (86400 = 24 hours)
|
|
RALPH_SESSION_FILE="$RALPH_DIR/.ralph_session" # Ralph-specific session tracking (lifecycle)
|
|
RALPH_SESSION_HISTORY_FILE="$RALPH_DIR/.ralph_session_history" # Session transition history
|
|
# Session expiration: 24 hours default balances project continuity with fresh context
|
|
# Too short = frequent context loss; Too long = stale context causes unpredictable behavior
|
|
CLAUDE_SESSION_EXPIRY_HOURS=${CLAUDE_SESSION_EXPIRY_HOURS:-24}
|
|
|
|
# Valid tool patterns for --allowed-tools validation
|
|
# Tools can be exact matches or pattern matches with wildcards in parentheses
|
|
VALID_TOOL_PATTERNS=(
|
|
"Write"
|
|
"Read"
|
|
"Edit"
|
|
"MultiEdit"
|
|
"Glob"
|
|
"Grep"
|
|
"Task"
|
|
"TodoWrite"
|
|
"WebFetch"
|
|
"WebSearch"
|
|
"Bash"
|
|
"Bash(git *)"
|
|
"Bash(npm *)"
|
|
"Bash(bats *)"
|
|
"Bash(python *)"
|
|
"Bash(node *)"
|
|
"NotebookEdit"
|
|
)
|
|
|
|
# Exit detection configuration
|
|
EXIT_SIGNALS_FILE="$RALPH_DIR/.exit_signals"
|
|
RESPONSE_ANALYSIS_FILE="$RALPH_DIR/.response_analysis"
|
|
MAX_CONSECUTIVE_TEST_LOOPS=3
|
|
MAX_CONSECUTIVE_DONE_SIGNALS=2
|
|
TEST_PERCENTAGE_THRESHOLD=30 # If more than 30% of recent loops are test-only, flag it
|
|
|
|
# Colors for terminal output
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
PURPLE='\033[0;35m'
|
|
NC='\033[0m' # No Color
|
|
|
|
# Initialize directories
|
|
mkdir -p "$LOG_DIR" "$DOCS_DIR"
|
|
|
|
# Check if tmux is available
|
|
check_tmux_available() {
|
|
if ! command -v tmux &> /dev/null; then
|
|
log_status "ERROR" "tmux is not installed. Please install tmux or run without --monitor flag."
|
|
echo "Install tmux:"
|
|
echo " Ubuntu/Debian: sudo apt-get install tmux"
|
|
echo " macOS: brew install tmux"
|
|
echo " CentOS/RHEL: sudo yum install tmux"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Setup tmux session with monitor
|
|
setup_tmux_session() {
|
|
local session_name="ralph-$(date +%s)"
|
|
local ralph_home="${RALPH_HOME:-$HOME/.ralph}"
|
|
|
|
log_status "INFO" "Setting up tmux session: $session_name"
|
|
|
|
# Create new tmux session detached
|
|
tmux new-session -d -s "$session_name" -c "$(pwd)"
|
|
|
|
# Split window vertically to create monitor pane on the right
|
|
tmux split-window -h -t "$session_name" -c "$(pwd)"
|
|
|
|
# Start monitor in the right pane
|
|
if command -v ralph-monitor &> /dev/null; then
|
|
tmux send-keys -t "$session_name:0.1" "ralph-monitor" Enter
|
|
else
|
|
tmux send-keys -t "$session_name:0.1" "'$ralph_home/ralph_monitor.sh'" Enter
|
|
fi
|
|
|
|
# Start ralph loop in the left pane (exclude tmux flag to avoid recursion)
|
|
local ralph_cmd
|
|
if command -v ralph &> /dev/null; then
|
|
ralph_cmd="ralph"
|
|
else
|
|
ralph_cmd="'$ralph_home/ralph_loop.sh'"
|
|
fi
|
|
|
|
if [[ "$MAX_CALLS_PER_HOUR" != "100" ]]; then
|
|
ralph_cmd="$ralph_cmd --calls $MAX_CALLS_PER_HOUR"
|
|
fi
|
|
if [[ "$PROMPT_FILE" != "$RALPH_DIR/PROMPT.md" ]]; then
|
|
ralph_cmd="$ralph_cmd --prompt '$PROMPT_FILE'"
|
|
fi
|
|
|
|
tmux send-keys -t "$session_name:0.0" "$ralph_cmd" Enter
|
|
|
|
# Focus on left pane (main ralph loop)
|
|
tmux select-pane -t "$session_name:0.0"
|
|
|
|
# Set window title
|
|
tmux rename-window -t "$session_name:0" "Ralph: Loop | Monitor"
|
|
|
|
log_status "SUCCESS" "Tmux session created. Attaching to session..."
|
|
log_status "INFO" "Use Ctrl+B then D to detach from session"
|
|
log_status "INFO" "Use 'tmux attach -t $session_name' to reattach"
|
|
|
|
# Attach to session (this will block until session ends)
|
|
tmux attach-session -t "$session_name"
|
|
|
|
exit 0
|
|
}
|
|
|
|
# Initialize call tracking
|
|
init_call_tracking() {
|
|
log_status "INFO" "DEBUG: Entered init_call_tracking..."
|
|
local current_hour=$(date +%Y%m%d%H)
|
|
local last_reset_hour=""
|
|
|
|
if [[ -f "$TIMESTAMP_FILE" ]]; then
|
|
last_reset_hour=$(cat "$TIMESTAMP_FILE")
|
|
fi
|
|
|
|
# Reset counter if it's a new hour
|
|
if [[ "$current_hour" != "$last_reset_hour" ]]; then
|
|
echo "0" > "$CALL_COUNT_FILE"
|
|
echo "$current_hour" > "$TIMESTAMP_FILE"
|
|
log_status "INFO" "Call counter reset for new hour: $current_hour"
|
|
fi
|
|
|
|
# Initialize exit signals tracking if it doesn't exist
|
|
if [[ ! -f "$EXIT_SIGNALS_FILE" ]]; then
|
|
echo '{"test_only_loops": [], "done_signals": [], "completion_indicators": []}' > "$EXIT_SIGNALS_FILE"
|
|
fi
|
|
|
|
# Initialize circuit breaker
|
|
init_circuit_breaker
|
|
|
|
log_status "INFO" "DEBUG: Completed init_call_tracking successfully"
|
|
}
|
|
|
|
# Log function with timestamps and colors
|
|
log_status() {
|
|
local level=$1
|
|
local message=$2
|
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
local color=""
|
|
|
|
case $level in
|
|
"INFO") color=$BLUE ;;
|
|
"WARN") color=$YELLOW ;;
|
|
"ERROR") color=$RED ;;
|
|
"SUCCESS") color=$GREEN ;;
|
|
"LOOP") color=$PURPLE ;;
|
|
esac
|
|
|
|
echo -e "${color}[$timestamp] [$level] $message${NC}"
|
|
echo "[$timestamp] [$level] $message" >> "$LOG_DIR/ralph.log"
|
|
}
|
|
|
|
# Update status JSON for external monitoring
|
|
update_status() {
|
|
local loop_count=$1
|
|
local calls_made=$2
|
|
local last_action=$3
|
|
local status=$4
|
|
local exit_reason=${5:-""}
|
|
|
|
cat > "$STATUS_FILE" << STATUSEOF
|
|
{
|
|
"timestamp": "$(get_iso_timestamp)",
|
|
"loop_count": $loop_count,
|
|
"calls_made_this_hour": $calls_made,
|
|
"max_calls_per_hour": $MAX_CALLS_PER_HOUR,
|
|
"last_action": "$last_action",
|
|
"status": "$status",
|
|
"exit_reason": "$exit_reason",
|
|
"next_reset": "$(get_next_hour_time)"
|
|
}
|
|
STATUSEOF
|
|
}
|
|
|
|
# Check if we can make another call
|
|
can_make_call() {
|
|
local calls_made=0
|
|
if [[ -f "$CALL_COUNT_FILE" ]]; then
|
|
calls_made=$(cat "$CALL_COUNT_FILE")
|
|
fi
|
|
|
|
if [[ $calls_made -ge $MAX_CALLS_PER_HOUR ]]; then
|
|
return 1 # Cannot make call
|
|
else
|
|
return 0 # Can make call
|
|
fi
|
|
}
|
|
|
|
# Increment call counter
|
|
increment_call_counter() {
|
|
local calls_made=0
|
|
if [[ -f "$CALL_COUNT_FILE" ]]; then
|
|
calls_made=$(cat "$CALL_COUNT_FILE")
|
|
fi
|
|
|
|
((calls_made++))
|
|
echo "$calls_made" > "$CALL_COUNT_FILE"
|
|
echo "$calls_made"
|
|
}
|
|
|
|
# Wait for rate limit reset with countdown
|
|
wait_for_reset() {
|
|
local calls_made=$(cat "$CALL_COUNT_FILE" 2>/dev/null || echo "0")
|
|
log_status "WARN" "Rate limit reached ($calls_made/$MAX_CALLS_PER_HOUR). Waiting for reset..."
|
|
|
|
# Calculate time until next hour
|
|
local current_minute=$(date +%M)
|
|
local current_second=$(date +%S)
|
|
local wait_time=$(((60 - current_minute - 1) * 60 + (60 - current_second)))
|
|
|
|
log_status "INFO" "Sleeping for $wait_time seconds until next hour..."
|
|
|
|
# Countdown display
|
|
while [[ $wait_time -gt 0 ]]; do
|
|
local hours=$((wait_time / 3600))
|
|
local minutes=$(((wait_time % 3600) / 60))
|
|
local seconds=$((wait_time % 60))
|
|
|
|
printf "\r${YELLOW}Time until reset: %02d:%02d:%02d${NC}" $hours $minutes $seconds
|
|
sleep 1
|
|
((wait_time--))
|
|
done
|
|
printf "\n"
|
|
|
|
# Reset counter
|
|
echo "0" > "$CALL_COUNT_FILE"
|
|
echo "$(date +%Y%m%d%H)" > "$TIMESTAMP_FILE"
|
|
log_status "SUCCESS" "Rate limit reset! Ready for new calls."
|
|
}
|
|
|
|
# Check if we should gracefully exit
|
|
should_exit_gracefully() {
|
|
log_status "INFO" "DEBUG: Checking exit conditions..." >&2
|
|
|
|
if [[ ! -f "$EXIT_SIGNALS_FILE" ]]; then
|
|
log_status "INFO" "DEBUG: No exit signals file found, continuing..." >&2
|
|
return 1 # Don't exit, file doesn't exist
|
|
fi
|
|
|
|
local signals=$(cat "$EXIT_SIGNALS_FILE")
|
|
log_status "INFO" "DEBUG: Exit signals content: $signals" >&2
|
|
|
|
# Count recent signals (last 5 loops) - with error handling
|
|
local recent_test_loops
|
|
local recent_done_signals
|
|
local recent_completion_indicators
|
|
|
|
recent_test_loops=$(echo "$signals" | jq '.test_only_loops | length' 2>/dev/null || echo "0")
|
|
recent_done_signals=$(echo "$signals" | jq '.done_signals | length' 2>/dev/null || echo "0")
|
|
recent_completion_indicators=$(echo "$signals" | jq '.completion_indicators | length' 2>/dev/null || echo "0")
|
|
|
|
log_status "INFO" "DEBUG: Exit counts - test_loops:$recent_test_loops, done_signals:$recent_done_signals, completion:$recent_completion_indicators" >&2
|
|
|
|
# Check for exit conditions
|
|
|
|
# 1. Too many consecutive test-only loops
|
|
if [[ $recent_test_loops -ge $MAX_CONSECUTIVE_TEST_LOOPS ]]; then
|
|
log_status "WARN" "Exit condition: Too many test-focused loops ($recent_test_loops >= $MAX_CONSECUTIVE_TEST_LOOPS)"
|
|
echo "test_saturation"
|
|
return 0
|
|
fi
|
|
|
|
# 2. Multiple "done" signals
|
|
if [[ $recent_done_signals -ge $MAX_CONSECUTIVE_DONE_SIGNALS ]]; then
|
|
log_status "WARN" "Exit condition: Multiple completion signals ($recent_done_signals >= $MAX_CONSECUTIVE_DONE_SIGNALS)"
|
|
echo "completion_signals"
|
|
return 0
|
|
fi
|
|
|
|
# 3. Safety circuit breaker - force exit after 5 consecutive completion indicators
|
|
# Bug #2 Fix: Prevents infinite loops when EXIT_SIGNAL is not explicitly set
|
|
# but completion patterns clearly indicate work is done. Threshold of 5 is higher
|
|
# than normal threshold (2) to avoid false positives while preventing API waste.
|
|
if [[ $recent_completion_indicators -ge 5 ]]; then
|
|
log_status "WARN" "🚨 SAFETY CIRCUIT BREAKER: Force exit after 5 consecutive completion indicators ($recent_completion_indicators)" >&2
|
|
echo "safety_circuit_breaker"
|
|
return 0
|
|
fi
|
|
|
|
# 4. Strong completion indicators (only if Claude's EXIT_SIGNAL is true)
|
|
# This prevents premature exits when heuristics detect completion patterns
|
|
# but Claude explicitly indicates work is still in progress via RALPH_STATUS block.
|
|
# The exit_signal in .response_analysis represents Claude's explicit intent.
|
|
local claude_exit_signal="false"
|
|
if [[ -f "$RESPONSE_ANALYSIS_FILE" ]]; then
|
|
claude_exit_signal=$(jq -r '.analysis.exit_signal // false' "$RESPONSE_ANALYSIS_FILE" 2>/dev/null || echo "false")
|
|
fi
|
|
|
|
if [[ $recent_completion_indicators -ge 2 ]] && [[ "$claude_exit_signal" == "true" ]]; then
|
|
log_status "WARN" "Exit condition: Strong completion indicators ($recent_completion_indicators) with EXIT_SIGNAL=true" >&2
|
|
echo "project_complete"
|
|
return 0
|
|
elif [[ $recent_completion_indicators -ge 2 ]]; then
|
|
log_status "INFO" "DEBUG: Completion indicators ($recent_completion_indicators) present but EXIT_SIGNAL=false, continuing..." >&2
|
|
fi
|
|
|
|
# 5. Check fix_plan.md for completion
|
|
# Bug #3 Fix: Support indented markdown checkboxes with [[:space:]]* pattern
|
|
if [[ -f "$RALPH_DIR/@fix_plan.md" ]]; then
|
|
local total_items=$(grep -cE "^[[:space:]]*- \[" "$RALPH_DIR/@fix_plan.md" 2>/dev/null)
|
|
local completed_items=$(grep -cE "^[[:space:]]*- \[x\]" "$RALPH_DIR/@fix_plan.md" 2>/dev/null)
|
|
|
|
# Handle case where grep returns no matches (exit code 1)
|
|
[[ -z "$total_items" ]] && total_items=0
|
|
[[ -z "$completed_items" ]] && completed_items=0
|
|
|
|
log_status "INFO" "DEBUG: .ralph/@fix_plan.md check - total_items:$total_items, completed_items:$completed_items" >&2
|
|
|
|
if [[ $total_items -gt 0 ]] && [[ $completed_items -eq $total_items ]]; then
|
|
log_status "WARN" "Exit condition: All fix_plan.md items completed ($completed_items/$total_items)" >&2
|
|
echo "plan_complete"
|
|
return 0
|
|
fi
|
|
else
|
|
log_status "INFO" "DEBUG: .ralph/@fix_plan.md file not found" >&2
|
|
fi
|
|
|
|
log_status "INFO" "DEBUG: No exit conditions met, continuing loop" >&2
|
|
echo "" # Return empty string instead of using return code
|
|
}
|
|
|
|
# =============================================================================
|
|
# MODERN CLI HELPER FUNCTIONS (Phase 1.1)
|
|
# =============================================================================
|
|
|
|
# Check Claude CLI version for compatibility with modern flags
|
|
check_claude_version() {
|
|
local version=$($CLAUDE_CODE_CMD --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
|
|
|
if [[ -z "$version" ]]; then
|
|
log_status "WARN" "Cannot detect Claude CLI version, assuming compatible"
|
|
return 0
|
|
fi
|
|
|
|
# Compare versions (simplified semver comparison)
|
|
local required="$CLAUDE_MIN_VERSION"
|
|
|
|
# Convert to comparable integers (major * 10000 + minor * 100 + patch)
|
|
local ver_parts=(${version//./ })
|
|
local req_parts=(${required//./ })
|
|
|
|
local ver_num=$((${ver_parts[0]:-0} * 10000 + ${ver_parts[1]:-0} * 100 + ${ver_parts[2]:-0}))
|
|
local req_num=$((${req_parts[0]:-0} * 10000 + ${req_parts[1]:-0} * 100 + ${req_parts[2]:-0}))
|
|
|
|
if [[ $ver_num -lt $req_num ]]; then
|
|
log_status "WARN" "Claude CLI version $version < $required. Some modern features may not work."
|
|
log_status "WARN" "Consider upgrading: npm update -g @anthropic-ai/claude-code"
|
|
return 1
|
|
fi
|
|
|
|
log_status "INFO" "Claude CLI version $version (>= $required) - modern features enabled"
|
|
return 0
|
|
}
|
|
|
|
# Validate allowed tools against whitelist
|
|
# Returns 0 if valid, 1 if invalid with error message
|
|
validate_allowed_tools() {
|
|
local tools_input=$1
|
|
|
|
if [[ -z "$tools_input" ]]; then
|
|
return 0 # Empty is valid (uses defaults)
|
|
fi
|
|
|
|
# Split by comma
|
|
local IFS=','
|
|
read -ra tools <<< "$tools_input"
|
|
|
|
for tool in "${tools[@]}"; do
|
|
# Trim whitespace
|
|
tool=$(echo "$tool" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
|
|
if [[ -z "$tool" ]]; then
|
|
continue
|
|
fi
|
|
|
|
local valid=false
|
|
|
|
# Check against valid patterns
|
|
for pattern in "${VALID_TOOL_PATTERNS[@]}"; do
|
|
if [[ "$tool" == "$pattern" ]]; then
|
|
valid=true
|
|
break
|
|
fi
|
|
|
|
# Check for Bash(*) pattern - any Bash with parentheses is allowed
|
|
if [[ "$tool" =~ ^Bash\(.+\)$ ]]; then
|
|
valid=true
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ "$valid" == "false" ]]; then
|
|
echo "Error: Invalid tool in --allowed-tools: '$tool'"
|
|
echo "Valid tools: ${VALID_TOOL_PATTERNS[*]}"
|
|
echo "Note: Bash(...) patterns with any content are allowed (e.g., 'Bash(git *)')"
|
|
return 1
|
|
fi
|
|
done
|
|
|
|
return 0
|
|
}
|
|
|
|
# Build loop context for Claude Code session
|
|
# Provides loop-specific context via --append-system-prompt
|
|
build_loop_context() {
|
|
local loop_count=$1
|
|
local context=""
|
|
|
|
# Add loop number
|
|
context="Loop #${loop_count}. "
|
|
|
|
# Extract incomplete tasks from @fix_plan.md
|
|
# Bug #3 Fix: Support indented markdown checkboxes with [[:space:]]* pattern
|
|
if [[ -f "$RALPH_DIR/@fix_plan.md" ]]; then
|
|
local incomplete_tasks=$(grep -cE "^[[:space:]]*- \[ \]" "$RALPH_DIR/@fix_plan.md" 2>/dev/null || echo "0")
|
|
context+="Remaining tasks: ${incomplete_tasks}. "
|
|
fi
|
|
|
|
# Add circuit breaker state
|
|
if [[ -f "$RALPH_DIR/.circuit_breaker_state" ]]; then
|
|
local cb_state=$(jq -r '.state // "UNKNOWN"' "$RALPH_DIR/.circuit_breaker_state" 2>/dev/null)
|
|
if [[ "$cb_state" != "CLOSED" && "$cb_state" != "null" && -n "$cb_state" ]]; then
|
|
context+="Circuit breaker: ${cb_state}. "
|
|
fi
|
|
fi
|
|
|
|
# Add previous loop summary (truncated)
|
|
if [[ -f "$RESPONSE_ANALYSIS_FILE" ]]; then
|
|
local prev_summary=$(jq -r '.analysis.work_summary // ""' "$RESPONSE_ANALYSIS_FILE" 2>/dev/null | head -c 200)
|
|
if [[ -n "$prev_summary" && "$prev_summary" != "null" ]]; then
|
|
context+="Previous: ${prev_summary}"
|
|
fi
|
|
fi
|
|
|
|
# Limit total length to ~500 chars
|
|
echo "${context:0:500}"
|
|
}
|
|
|
|
# Get session file age in hours (cross-platform)
|
|
# Returns: age in hours on stdout, or -1 if stat fails
|
|
# Note: Returns 0 for files less than 1 hour old
|
|
get_session_file_age_hours() {
|
|
local file=$1
|
|
|
|
if [[ ! -f "$file" ]]; then
|
|
echo "0"
|
|
return
|
|
fi
|
|
|
|
local os_type
|
|
os_type=$(uname)
|
|
|
|
local file_mtime
|
|
if [[ "$os_type" == "Darwin" ]]; then
|
|
# macOS (BSD stat)
|
|
file_mtime=$(stat -f %m "$file" 2>/dev/null)
|
|
else
|
|
# Linux (GNU stat)
|
|
file_mtime=$(stat -c %Y "$file" 2>/dev/null)
|
|
fi
|
|
|
|
# Handle stat failure - return -1 to indicate error
|
|
# This prevents false expiration when stat fails
|
|
if [[ -z "$file_mtime" || "$file_mtime" == "0" ]]; then
|
|
echo "-1"
|
|
return
|
|
fi
|
|
|
|
local current_time
|
|
current_time=$(date +%s)
|
|
|
|
local age_seconds=$((current_time - file_mtime))
|
|
local age_hours=$((age_seconds / 3600))
|
|
|
|
echo "$age_hours"
|
|
}
|
|
|
|
# Initialize or resume Claude session (with expiration check)
|
|
#
|
|
# Session Expiration Strategy:
|
|
# - Default expiration: 24 hours (configurable via CLAUDE_SESSION_EXPIRY_HOURS)
|
|
# - 24 hours chosen because: long enough for multi-day projects, short enough
|
|
# to prevent stale context from causing unpredictable behavior
|
|
# - Sessions auto-expire to ensure Claude starts fresh periodically
|
|
#
|
|
# Returns (stdout):
|
|
# - Session ID string: when resuming a valid, non-expired session
|
|
# - Empty string: when starting new session (no file, expired, or stat error)
|
|
#
|
|
# Return codes:
|
|
# - 0: Always returns success (caller should check stdout for session ID)
|
|
#
|
|
init_claude_session() {
|
|
if [[ -f "$CLAUDE_SESSION_FILE" ]]; then
|
|
# Check session age
|
|
local age_hours
|
|
age_hours=$(get_session_file_age_hours "$CLAUDE_SESSION_FILE")
|
|
|
|
# Handle stat failure (-1) - treat as needing new session
|
|
# Don't expire sessions when we can't determine age
|
|
if [[ $age_hours -eq -1 ]]; then
|
|
log_status "WARN" "Could not determine session age, starting new session"
|
|
rm -f "$CLAUDE_SESSION_FILE"
|
|
echo ""
|
|
return 0
|
|
fi
|
|
|
|
# Check if session has expired
|
|
if [[ $age_hours -ge $CLAUDE_SESSION_EXPIRY_HOURS ]]; then
|
|
log_status "INFO" "Session expired (${age_hours}h old, max ${CLAUDE_SESSION_EXPIRY_HOURS}h), starting new session"
|
|
rm -f "$CLAUDE_SESSION_FILE"
|
|
echo ""
|
|
return 0
|
|
fi
|
|
|
|
# Session is valid, try to read it
|
|
local session_id=$(cat "$CLAUDE_SESSION_FILE" 2>/dev/null)
|
|
if [[ -n "$session_id" ]]; then
|
|
log_status "INFO" "Resuming Claude session: ${session_id:0:20}... (${age_hours}h old)"
|
|
echo "$session_id"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
log_status "INFO" "Starting new Claude session"
|
|
echo ""
|
|
}
|
|
|
|
# Save session ID after successful execution
|
|
save_claude_session() {
|
|
local output_file=$1
|
|
|
|
# Try to extract session ID from JSON output
|
|
if [[ -f "$output_file" ]]; then
|
|
local session_id=$(jq -r '.metadata.session_id // .session_id // empty' "$output_file" 2>/dev/null)
|
|
if [[ -n "$session_id" && "$session_id" != "null" ]]; then
|
|
echo "$session_id" > "$CLAUDE_SESSION_FILE"
|
|
log_status "INFO" "Saved Claude session: ${session_id:0:20}..."
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# SESSION LIFECYCLE MANAGEMENT FUNCTIONS (Phase 1.2)
|
|
# =============================================================================
|
|
|
|
# Get current session ID from Ralph session file
|
|
# Returns: session ID string or empty if not found
|
|
get_session_id() {
|
|
if [[ ! -f "$RALPH_SESSION_FILE" ]]; then
|
|
echo ""
|
|
return 0
|
|
fi
|
|
|
|
# Extract session_id from JSON file (SC2155: separate declare from assign)
|
|
local session_id
|
|
session_id=$(jq -r '.session_id // ""' "$RALPH_SESSION_FILE" 2>/dev/null)
|
|
local jq_status=$?
|
|
|
|
# Handle jq failure or null/empty results
|
|
if [[ $jq_status -ne 0 || -z "$session_id" || "$session_id" == "null" ]]; then
|
|
session_id=""
|
|
fi
|
|
echo "$session_id"
|
|
return 0
|
|
}
|
|
|
|
# Reset session with reason logging
|
|
# Usage: reset_session "reason_for_reset"
|
|
reset_session() {
|
|
local reason=${1:-"manual_reset"}
|
|
|
|
# Get current timestamp
|
|
local reset_timestamp
|
|
reset_timestamp=$(get_iso_timestamp)
|
|
|
|
# Always create/overwrite the session file using jq for safe JSON escaping
|
|
jq -n \
|
|
--arg session_id "" \
|
|
--arg created_at "" \
|
|
--arg last_used "" \
|
|
--arg reset_at "$reset_timestamp" \
|
|
--arg reset_reason "$reason" \
|
|
'{
|
|
session_id: $session_id,
|
|
created_at: $created_at,
|
|
last_used: $last_used,
|
|
reset_at: $reset_at,
|
|
reset_reason: $reset_reason
|
|
}' > "$RALPH_SESSION_FILE"
|
|
|
|
# Also clear the Claude session file for consistency
|
|
rm -f "$CLAUDE_SESSION_FILE" 2>/dev/null
|
|
|
|
# Log the session transition (non-fatal to prevent script exit under set -e)
|
|
log_session_transition "active" "reset" "$reason" "${loop_count:-0}" || true
|
|
|
|
log_status "INFO" "Session reset: $reason"
|
|
}
|
|
|
|
# Log session state transitions to history file
|
|
# Usage: log_session_transition from_state to_state reason loop_number
|
|
log_session_transition() {
|
|
local from_state=$1
|
|
local to_state=$2
|
|
local reason=$3
|
|
local loop_number=${4:-0}
|
|
|
|
# Get timestamp once (SC2155: separate declare from assign)
|
|
local ts
|
|
ts=$(get_iso_timestamp)
|
|
|
|
# Create transition entry using jq for safe JSON (SC2155: separate declare from assign)
|
|
local transition
|
|
transition=$(jq -n -c \
|
|
--arg timestamp "$ts" \
|
|
--arg from_state "$from_state" \
|
|
--arg to_state "$to_state" \
|
|
--arg reason "$reason" \
|
|
--argjson loop_number "$loop_number" \
|
|
'{
|
|
timestamp: $timestamp,
|
|
from_state: $from_state,
|
|
to_state: $to_state,
|
|
reason: $reason,
|
|
loop_number: $loop_number
|
|
}')
|
|
|
|
# Read history file defensively - fallback to empty array on any failure
|
|
local history
|
|
if [[ -f "$RALPH_SESSION_HISTORY_FILE" ]]; then
|
|
history=$(cat "$RALPH_SESSION_HISTORY_FILE" 2>/dev/null)
|
|
# Validate JSON, fallback to empty array if corrupted
|
|
if ! echo "$history" | jq empty 2>/dev/null; then
|
|
history='[]'
|
|
fi
|
|
else
|
|
history='[]'
|
|
fi
|
|
|
|
# Append transition and keep only last 50 entries
|
|
local updated_history
|
|
updated_history=$(echo "$history" | jq ". += [$transition] | .[-50:]" 2>/dev/null)
|
|
local jq_status=$?
|
|
|
|
# Only write if jq succeeded
|
|
if [[ $jq_status -eq 0 && -n "$updated_history" ]]; then
|
|
echo "$updated_history" > "$RALPH_SESSION_HISTORY_FILE"
|
|
else
|
|
# Fallback: start fresh with just this transition
|
|
echo "[$transition]" > "$RALPH_SESSION_HISTORY_FILE"
|
|
fi
|
|
}
|
|
|
|
# Generate a unique session ID using timestamp and random component
|
|
generate_session_id() {
|
|
local ts
|
|
ts=$(date +%s)
|
|
local rand
|
|
rand=$RANDOM
|
|
echo "ralph-${ts}-${rand}"
|
|
}
|
|
|
|
# Initialize session tracking (called at loop start)
|
|
init_session_tracking() {
|
|
local ts
|
|
ts=$(get_iso_timestamp)
|
|
|
|
# Create session file if it doesn't exist
|
|
if [[ ! -f "$RALPH_SESSION_FILE" ]]; then
|
|
local new_session_id
|
|
new_session_id=$(generate_session_id)
|
|
|
|
jq -n \
|
|
--arg session_id "$new_session_id" \
|
|
--arg created_at "$ts" \
|
|
--arg last_used "$ts" \
|
|
--arg reset_at "" \
|
|
--arg reset_reason "" \
|
|
'{
|
|
session_id: $session_id,
|
|
created_at: $created_at,
|
|
last_used: $last_used,
|
|
reset_at: $reset_at,
|
|
reset_reason: $reset_reason
|
|
}' > "$RALPH_SESSION_FILE"
|
|
|
|
log_status "INFO" "Initialized session tracking (session: $new_session_id)"
|
|
return 0
|
|
fi
|
|
|
|
# Validate existing session file
|
|
if ! jq empty "$RALPH_SESSION_FILE" 2>/dev/null; then
|
|
log_status "WARN" "Corrupted session file detected, recreating..."
|
|
local new_session_id
|
|
new_session_id=$(generate_session_id)
|
|
|
|
jq -n \
|
|
--arg session_id "$new_session_id" \
|
|
--arg created_at "$ts" \
|
|
--arg last_used "$ts" \
|
|
--arg reset_at "$ts" \
|
|
--arg reset_reason "corrupted_file_recovery" \
|
|
'{
|
|
session_id: $session_id,
|
|
created_at: $created_at,
|
|
last_used: $last_used,
|
|
reset_at: $reset_at,
|
|
reset_reason: $reset_reason
|
|
}' > "$RALPH_SESSION_FILE"
|
|
fi
|
|
}
|
|
|
|
# Update last_used timestamp in session file (called on each loop iteration)
|
|
update_session_last_used() {
|
|
if [[ ! -f "$RALPH_SESSION_FILE" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local ts
|
|
ts=$(get_iso_timestamp)
|
|
|
|
# Update last_used in existing session file
|
|
local updated
|
|
updated=$(jq --arg last_used "$ts" '.last_used = $last_used' "$RALPH_SESSION_FILE" 2>/dev/null)
|
|
local jq_status=$?
|
|
|
|
if [[ $jq_status -eq 0 && -n "$updated" ]]; then
|
|
echo "$updated" > "$RALPH_SESSION_FILE"
|
|
fi
|
|
}
|
|
|
|
# Global array for Claude command arguments (avoids shell injection)
|
|
declare -a CLAUDE_CMD_ARGS=()
|
|
|
|
# Build Claude CLI command with modern flags using array (shell-injection safe)
|
|
# Populates global CLAUDE_CMD_ARGS array for direct execution
|
|
# Uses -p flag with prompt content (Claude CLI does not have --prompt-file)
|
|
build_claude_command() {
|
|
local prompt_file=$1
|
|
local loop_context=$2
|
|
local session_id=$3
|
|
|
|
# Reset global array
|
|
CLAUDE_CMD_ARGS=("$CLAUDE_CODE_CMD")
|
|
|
|
# Check if prompt file exists
|
|
if [[ ! -f "$prompt_file" ]]; then
|
|
log_status "ERROR" "Prompt file not found: $prompt_file"
|
|
return 1
|
|
fi
|
|
|
|
# Add output format flag
|
|
if [[ "$CLAUDE_OUTPUT_FORMAT" == "json" ]]; then
|
|
CLAUDE_CMD_ARGS+=("--output-format" "json")
|
|
fi
|
|
|
|
# Add allowed tools (each tool as separate array element)
|
|
if [[ -n "$CLAUDE_ALLOWED_TOOLS" ]]; then
|
|
CLAUDE_CMD_ARGS+=("--allowedTools")
|
|
# Split by comma and add each tool
|
|
local IFS=','
|
|
read -ra tools_array <<< "$CLAUDE_ALLOWED_TOOLS"
|
|
for tool in "${tools_array[@]}"; do
|
|
# Trim whitespace
|
|
tool=$(echo "$tool" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
if [[ -n "$tool" ]]; then
|
|
CLAUDE_CMD_ARGS+=("$tool")
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# Add session continuity flag
|
|
if [[ "$CLAUDE_USE_CONTINUE" == "true" ]]; then
|
|
CLAUDE_CMD_ARGS+=("--continue")
|
|
fi
|
|
|
|
# Add loop context as system prompt (no escaping needed - array handles it)
|
|
if [[ -n "$loop_context" ]]; then
|
|
CLAUDE_CMD_ARGS+=("--append-system-prompt" "$loop_context")
|
|
fi
|
|
|
|
# Read prompt file content and use -p flag
|
|
# Note: Claude CLI uses -p for prompts, not --prompt-file (which doesn't exist)
|
|
# Array-based approach maintains shell injection safety
|
|
local prompt_content
|
|
prompt_content=$(cat "$prompt_file")
|
|
CLAUDE_CMD_ARGS+=("-p" "$prompt_content")
|
|
}
|
|
|
|
# Main execution function
|
|
execute_claude_code() {
|
|
local timestamp=$(date '+%Y-%m-%d_%H-%M-%S')
|
|
local output_file="$LOG_DIR/claude_output_${timestamp}.log"
|
|
local loop_count=$1
|
|
local calls_made=$(cat "$CALL_COUNT_FILE" 2>/dev/null || echo "0")
|
|
calls_made=$((calls_made + 1))
|
|
|
|
log_status "LOOP" "Executing Claude Code (Call $calls_made/$MAX_CALLS_PER_HOUR)"
|
|
local timeout_seconds=$((CLAUDE_TIMEOUT_MINUTES * 60))
|
|
log_status "INFO" "⏳ Starting Claude Code execution... (timeout: ${CLAUDE_TIMEOUT_MINUTES}m)"
|
|
|
|
# Build loop context for session continuity
|
|
local loop_context=""
|
|
if [[ "$CLAUDE_USE_CONTINUE" == "true" ]]; then
|
|
loop_context=$(build_loop_context "$loop_count")
|
|
if [[ -n "$loop_context" && "$VERBOSE_PROGRESS" == "true" ]]; then
|
|
log_status "INFO" "Loop context: $loop_context"
|
|
fi
|
|
fi
|
|
|
|
# Initialize or resume session
|
|
local session_id=""
|
|
if [[ "$CLAUDE_USE_CONTINUE" == "true" ]]; then
|
|
session_id=$(init_claude_session)
|
|
fi
|
|
|
|
# Build the Claude CLI command with modern flags
|
|
# Note: We use the modern CLI with -p flag when CLAUDE_OUTPUT_FORMAT is "json"
|
|
# For backward compatibility, fall back to stdin piping for text mode
|
|
local use_modern_cli=false
|
|
|
|
if [[ "$CLAUDE_OUTPUT_FORMAT" == "json" ]]; then
|
|
# Modern approach: use CLI flags (builds CLAUDE_CMD_ARGS array)
|
|
if build_claude_command "$PROMPT_FILE" "$loop_context" "$session_id"; then
|
|
use_modern_cli=true
|
|
log_status "INFO" "Using modern CLI mode (JSON output)"
|
|
else
|
|
log_status "WARN" "Failed to build modern CLI command, falling back to legacy mode"
|
|
fi
|
|
else
|
|
log_status "INFO" "Using legacy CLI mode (text output)"
|
|
fi
|
|
|
|
# Execute Claude Code
|
|
if [[ "$use_modern_cli" == "true" ]]; then
|
|
# Modern execution with command array (shell-injection safe)
|
|
# Execute array directly without bash -c to prevent shell metacharacter interpretation
|
|
if portable_timeout ${timeout_seconds}s "${CLAUDE_CMD_ARGS[@]}" > "$output_file" 2>&1 &
|
|
then
|
|
: # Continue to wait loop
|
|
else
|
|
log_status "ERROR" "❌ Failed to start Claude Code process (modern mode)"
|
|
# Fall back to legacy mode
|
|
log_status "INFO" "Falling back to legacy mode..."
|
|
use_modern_cli=false
|
|
fi
|
|
fi
|
|
|
|
# Fall back to legacy stdin piping if modern mode failed or not enabled
|
|
if [[ "$use_modern_cli" == "false" ]]; then
|
|
if portable_timeout ${timeout_seconds}s $CLAUDE_CODE_CMD < "$PROMPT_FILE" > "$output_file" 2>&1 &
|
|
then
|
|
: # Continue to wait loop
|
|
else
|
|
log_status "ERROR" "❌ Failed to start Claude Code process"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# Get PID and monitor progress
|
|
local claude_pid=$!
|
|
local progress_counter=0
|
|
|
|
# Show progress while Claude Code is running
|
|
while kill -0 $claude_pid 2>/dev/null; do
|
|
progress_counter=$((progress_counter + 1))
|
|
case $((progress_counter % 4)) in
|
|
1) progress_indicator="⠋" ;;
|
|
2) progress_indicator="⠙" ;;
|
|
3) progress_indicator="⠹" ;;
|
|
0) progress_indicator="⠸" ;;
|
|
esac
|
|
|
|
# Get last line from output if available
|
|
local last_line=""
|
|
if [[ -f "$output_file" && -s "$output_file" ]]; then
|
|
last_line=$(tail -1 "$output_file" 2>/dev/null | head -c 80)
|
|
fi
|
|
|
|
# Update progress file for monitor
|
|
cat > "$PROGRESS_FILE" << EOF
|
|
{
|
|
"status": "executing",
|
|
"indicator": "$progress_indicator",
|
|
"elapsed_seconds": $((progress_counter * 10)),
|
|
"last_output": "$last_line",
|
|
"timestamp": "$(date '+%Y-%m-%d %H:%M:%S')"
|
|
}
|
|
EOF
|
|
|
|
# Only log if verbose mode is enabled
|
|
if [[ "$VERBOSE_PROGRESS" == "true" ]]; then
|
|
if [[ -n "$last_line" ]]; then
|
|
log_status "INFO" "$progress_indicator Claude Code: $last_line... (${progress_counter}0s)"
|
|
else
|
|
log_status "INFO" "$progress_indicator Claude Code working... (${progress_counter}0s elapsed)"
|
|
fi
|
|
fi
|
|
|
|
sleep 10
|
|
done
|
|
|
|
# Wait for the process to finish and get exit code
|
|
wait $claude_pid
|
|
local exit_code=$?
|
|
|
|
if [ $exit_code -eq 0 ]; then
|
|
# Only increment counter on successful execution
|
|
echo "$calls_made" > "$CALL_COUNT_FILE"
|
|
|
|
# Clear progress file
|
|
echo '{"status": "completed", "timestamp": "'$(date '+%Y-%m-%d %H:%M:%S')'"}' > "$PROGRESS_FILE"
|
|
|
|
log_status "SUCCESS" "✅ Claude Code execution completed successfully"
|
|
|
|
# Save session ID from JSON output (Phase 1.1)
|
|
if [[ "$CLAUDE_USE_CONTINUE" == "true" ]]; then
|
|
save_claude_session "$output_file"
|
|
fi
|
|
|
|
# Analyze the response
|
|
log_status "INFO" "🔍 Analyzing Claude Code response..."
|
|
analyze_response "$output_file" "$loop_count"
|
|
local analysis_exit_code=$?
|
|
|
|
# Update exit signals based on analysis
|
|
update_exit_signals
|
|
|
|
# Log analysis summary
|
|
log_analysis_summary
|
|
|
|
# Get file change count for circuit breaker
|
|
local files_changed=$(git diff --name-only 2>/dev/null | wc -l || echo 0)
|
|
local has_errors="false"
|
|
|
|
# Two-stage error detection to avoid JSON field false positives
|
|
# Stage 1: Filter out JSON field patterns like "is_error": false
|
|
# Stage 2: Look for actual error messages in specific contexts
|
|
# Avoid type annotations like "error: Error" by requiring lowercase after ": error"
|
|
if grep -v '"[^"]*error[^"]*":' "$output_file" 2>/dev/null | \
|
|
grep -qE '(^Error:|^ERROR:|^error:|\]: error|Link: error|Error occurred|failed with error|[Ee]xception|Fatal|FATAL)'; then
|
|
has_errors="true"
|
|
|
|
# Debug logging: show what triggered error detection
|
|
if [[ "$VERBOSE_PROGRESS" == "true" ]]; then
|
|
log_status "DEBUG" "Error patterns found:"
|
|
grep -v '"[^"]*error[^"]*":' "$output_file" 2>/dev/null | \
|
|
grep -nE '(^Error:|^ERROR:|^error:|\]: error|Link: error|Error occurred|failed with error|[Ee]xception|Fatal|FATAL)' | \
|
|
head -3 | while IFS= read -r line; do
|
|
log_status "DEBUG" " $line"
|
|
done
|
|
fi
|
|
|
|
log_status "WARN" "Errors detected in output, check: $output_file"
|
|
fi
|
|
local output_length=$(wc -c < "$output_file" 2>/dev/null || echo 0)
|
|
|
|
# Record result in circuit breaker
|
|
record_loop_result "$loop_count" "$files_changed" "$has_errors" "$output_length"
|
|
local circuit_result=$?
|
|
|
|
if [[ $circuit_result -ne 0 ]]; then
|
|
log_status "WARN" "Circuit breaker opened - halting execution"
|
|
return 3 # Special code for circuit breaker trip
|
|
fi
|
|
|
|
return 0
|
|
else
|
|
# Clear progress file on failure
|
|
echo '{"status": "failed", "timestamp": "'$(date '+%Y-%m-%d %H:%M:%S')'"}' > "$PROGRESS_FILE"
|
|
|
|
# Check if the failure is due to API 5-hour limit
|
|
if grep -qi "5.*hour.*limit\|limit.*reached.*try.*back\|usage.*limit.*reached" "$output_file"; then
|
|
log_status "ERROR" "🚫 Claude API 5-hour usage limit reached"
|
|
return 2 # Special return code for API limit
|
|
else
|
|
log_status "ERROR" "❌ Claude Code execution failed, check: $output_file"
|
|
return 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Cleanup function
|
|
cleanup() {
|
|
log_status "INFO" "Ralph loop interrupted. Cleaning up..."
|
|
reset_session "manual_interrupt"
|
|
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE" 2>/dev/null || echo "0")" "interrupted" "stopped"
|
|
exit 0
|
|
}
|
|
|
|
# Set up signal handlers
|
|
trap cleanup SIGINT SIGTERM
|
|
|
|
# Global variable for loop count (needed by cleanup function)
|
|
loop_count=0
|
|
|
|
# Main loop
|
|
main() {
|
|
|
|
log_status "SUCCESS" "🚀 Ralph loop starting with Claude Code"
|
|
log_status "INFO" "Max calls per hour: $MAX_CALLS_PER_HOUR"
|
|
log_status "INFO" "Logs: $LOG_DIR/ | Docs: $DOCS_DIR/ | Status: $STATUS_FILE"
|
|
|
|
# Check if project uses old flat structure and needs migration
|
|
if [[ -f "PROMPT.md" ]] && [[ ! -d ".ralph" ]]; then
|
|
log_status "ERROR" "This project uses the old flat structure."
|
|
echo ""
|
|
echo "Ralph v0.10.0+ uses a .ralph/ subfolder to keep your project root clean."
|
|
echo ""
|
|
echo "To upgrade your project, run:"
|
|
echo " ralph-migrate"
|
|
echo ""
|
|
echo "This will move Ralph-specific files to .ralph/ while preserving src/ at root."
|
|
echo "A backup will be created before migration."
|
|
exit 1
|
|
fi
|
|
|
|
# Check if this is a Ralph project directory
|
|
if [[ ! -f "$PROMPT_FILE" ]]; then
|
|
log_status "ERROR" "Prompt file '$PROMPT_FILE' not found!"
|
|
echo ""
|
|
|
|
# Check if this looks like a partial Ralph project
|
|
if [[ -f "$RALPH_DIR/@fix_plan.md" ]] || [[ -d "$RALPH_DIR/specs" ]] || [[ -f "$RALPH_DIR/@AGENT.md" ]]; then
|
|
echo "This appears to be a Ralph project but is missing .ralph/PROMPT.md."
|
|
echo "You may need to create or restore the PROMPT.md file."
|
|
else
|
|
echo "This directory is not a Ralph project."
|
|
fi
|
|
|
|
echo ""
|
|
echo "To fix this:"
|
|
echo " 1. Create a new project: ralph-setup my-project"
|
|
echo " 2. Import existing requirements: ralph-import requirements.md"
|
|
echo " 3. Navigate to an existing Ralph project directory"
|
|
echo " 4. Or create .ralph/PROMPT.md manually in this directory"
|
|
echo ""
|
|
echo "Ralph projects should contain: .ralph/PROMPT.md, .ralph/@fix_plan.md, .ralph/specs/, src/, etc."
|
|
exit 1
|
|
fi
|
|
|
|
# Initialize session tracking before entering the loop
|
|
init_session_tracking
|
|
|
|
log_status "INFO" "Starting main loop..."
|
|
log_status "INFO" "DEBUG: About to enter while loop, loop_count=$loop_count"
|
|
|
|
while true; do
|
|
loop_count=$((loop_count + 1))
|
|
log_status "INFO" "DEBUG: Successfully incremented loop_count to $loop_count"
|
|
|
|
# Update session last_used timestamp
|
|
update_session_last_used
|
|
|
|
log_status "INFO" "Loop #$loop_count - calling init_call_tracking..."
|
|
init_call_tracking
|
|
|
|
log_status "LOOP" "=== Starting Loop #$loop_count ==="
|
|
|
|
# Check circuit breaker before attempting execution
|
|
if should_halt_execution; then
|
|
reset_session "circuit_breaker_open"
|
|
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE")" "circuit_breaker_open" "halted" "stagnation_detected"
|
|
log_status "ERROR" "🛑 Circuit breaker has opened - execution halted"
|
|
break
|
|
fi
|
|
|
|
# Check rate limits
|
|
if ! can_make_call; then
|
|
wait_for_reset
|
|
continue
|
|
fi
|
|
|
|
# Check for graceful exit conditions
|
|
local exit_reason=$(should_exit_gracefully)
|
|
if [[ "$exit_reason" != "" ]]; then
|
|
log_status "SUCCESS" "🏁 Graceful exit triggered: $exit_reason"
|
|
reset_session "project_complete"
|
|
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE")" "graceful_exit" "completed" "$exit_reason"
|
|
|
|
log_status "SUCCESS" "🎉 Ralph has completed the project! Final stats:"
|
|
log_status "INFO" " - Total loops: $loop_count"
|
|
log_status "INFO" " - API calls used: $(cat "$CALL_COUNT_FILE")"
|
|
log_status "INFO" " - Exit reason: $exit_reason"
|
|
|
|
break
|
|
fi
|
|
|
|
# Update status
|
|
local calls_made=$(cat "$CALL_COUNT_FILE" 2>/dev/null || echo "0")
|
|
update_status "$loop_count" "$calls_made" "executing" "running"
|
|
|
|
# Execute Claude Code
|
|
execute_claude_code "$loop_count"
|
|
local exec_result=$?
|
|
|
|
if [ $exec_result -eq 0 ]; then
|
|
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE")" "completed" "success"
|
|
|
|
# Brief pause between successful executions
|
|
sleep 5
|
|
elif [ $exec_result -eq 3 ]; then
|
|
# Circuit breaker opened
|
|
reset_session "circuit_breaker_trip"
|
|
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE")" "circuit_breaker_open" "halted" "stagnation_detected"
|
|
log_status "ERROR" "🛑 Circuit breaker has opened - halting loop"
|
|
log_status "INFO" "Run 'ralph --reset-circuit' to reset the circuit breaker after addressing issues"
|
|
break
|
|
elif [ $exec_result -eq 2 ]; then
|
|
# API 5-hour limit reached - handle specially
|
|
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE")" "api_limit" "paused"
|
|
log_status "WARN" "🛑 Claude API 5-hour limit reached!"
|
|
|
|
# Ask user whether to wait or exit
|
|
echo -e "\n${YELLOW}The Claude API 5-hour usage limit has been reached.${NC}"
|
|
echo -e "${YELLOW}You can either:${NC}"
|
|
echo -e " ${GREEN}1)${NC} Wait for the limit to reset (usually within an hour)"
|
|
echo -e " ${GREEN}2)${NC} Exit the loop and try again later"
|
|
echo -e "\n${BLUE}Choose an option (1 or 2):${NC} "
|
|
|
|
# Read user input with timeout
|
|
read -t 30 -n 1 user_choice
|
|
echo # New line after input
|
|
|
|
if [[ "$user_choice" == "2" ]] || [[ -z "$user_choice" ]]; then
|
|
log_status "INFO" "User chose to exit (or timed out). Exiting loop..."
|
|
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE")" "api_limit_exit" "stopped" "api_5hour_limit"
|
|
break
|
|
else
|
|
log_status "INFO" "User chose to wait. Waiting for API limit reset..."
|
|
# Wait for longer period when API limit is hit
|
|
local wait_minutes=60
|
|
log_status "INFO" "Waiting $wait_minutes minutes before retrying..."
|
|
|
|
# Countdown display
|
|
local wait_seconds=$((wait_minutes * 60))
|
|
while [[ $wait_seconds -gt 0 ]]; do
|
|
local minutes=$((wait_seconds / 60))
|
|
local seconds=$((wait_seconds % 60))
|
|
printf "\r${YELLOW}Time until retry: %02d:%02d${NC}" $minutes $seconds
|
|
sleep 1
|
|
((wait_seconds--))
|
|
done
|
|
printf "\n"
|
|
fi
|
|
else
|
|
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE")" "failed" "error"
|
|
log_status "WARN" "Execution failed, waiting 30 seconds before retry..."
|
|
sleep 30
|
|
fi
|
|
|
|
log_status "LOOP" "=== Completed Loop #$loop_count ==="
|
|
done
|
|
}
|
|
|
|
# Help function
|
|
show_help() {
|
|
cat << HELPEOF
|
|
Ralph Loop for Claude Code
|
|
|
|
Usage: $0 [OPTIONS]
|
|
|
|
IMPORTANT: This command must be run from a Ralph project directory.
|
|
Use 'ralph-setup project-name' to create a new project first.
|
|
|
|
Options:
|
|
-h, --help Show this help message
|
|
-c, --calls NUM Set max calls per hour (default: $MAX_CALLS_PER_HOUR)
|
|
-p, --prompt FILE Set prompt file (default: $PROMPT_FILE)
|
|
-s, --status Show current status and exit
|
|
-m, --monitor Start with tmux session and live monitor (requires tmux)
|
|
-v, --verbose Show detailed progress updates during execution
|
|
-t, --timeout MIN Set Claude Code execution timeout in minutes (default: $CLAUDE_TIMEOUT_MINUTES)
|
|
--reset-circuit Reset circuit breaker to CLOSED state
|
|
--circuit-status Show circuit breaker status and exit
|
|
--reset-session Reset session state and exit (clears session continuity)
|
|
|
|
Modern CLI Options (Phase 1.1):
|
|
--output-format FORMAT Set Claude output format: json or text (default: $CLAUDE_OUTPUT_FORMAT)
|
|
--allowed-tools TOOLS Comma-separated list of allowed tools (default: $CLAUDE_ALLOWED_TOOLS)
|
|
--no-continue Disable session continuity across loops
|
|
--session-expiry HOURS Set session expiration time in hours (default: $CLAUDE_SESSION_EXPIRY_HOURS)
|
|
|
|
Files created:
|
|
- $LOG_DIR/: All execution logs
|
|
- $DOCS_DIR/: Generated documentation
|
|
- $STATUS_FILE: Current status (JSON)
|
|
- .ralph/.ralph_session: Session lifecycle tracking
|
|
- .ralph/.ralph_session_history: Session transition history (last 50)
|
|
- .ralph/.call_count: API call counter for rate limiting
|
|
- .ralph/.last_reset: Timestamp of last rate limit reset
|
|
|
|
Example workflow:
|
|
ralph-setup my-project # Create project
|
|
cd my-project # Enter project directory
|
|
$0 --monitor # Start Ralph with monitoring
|
|
|
|
Examples:
|
|
$0 --calls 50 --prompt my_prompt.md
|
|
$0 --monitor # Start with integrated tmux monitoring
|
|
$0 --monitor --timeout 30 # 30-minute timeout for complex tasks
|
|
$0 --verbose --timeout 5 # 5-minute timeout with detailed progress
|
|
$0 --output-format text # Use legacy text output format
|
|
$0 --no-continue # Disable session continuity
|
|
$0 --session-expiry 48 # 48-hour session expiration
|
|
|
|
HELPEOF
|
|
}
|
|
|
|
# Parse command line arguments
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-h|--help)
|
|
show_help
|
|
exit 0
|
|
;;
|
|
-c|--calls)
|
|
MAX_CALLS_PER_HOUR="$2"
|
|
shift 2
|
|
;;
|
|
-p|--prompt)
|
|
PROMPT_FILE="$2"
|
|
shift 2
|
|
;;
|
|
-s|--status)
|
|
if [[ -f "$STATUS_FILE" ]]; then
|
|
echo "Current Status:"
|
|
cat "$STATUS_FILE" | jq . 2>/dev/null || cat "$STATUS_FILE"
|
|
else
|
|
echo "No status file found. Ralph may not be running."
|
|
fi
|
|
exit 0
|
|
;;
|
|
-m|--monitor)
|
|
USE_TMUX=true
|
|
shift
|
|
;;
|
|
-v|--verbose)
|
|
VERBOSE_PROGRESS=true
|
|
shift
|
|
;;
|
|
-t|--timeout)
|
|
if [[ "$2" =~ ^[1-9][0-9]*$ ]] && [[ "$2" -le 120 ]]; then
|
|
CLAUDE_TIMEOUT_MINUTES="$2"
|
|
else
|
|
echo "Error: Timeout must be a positive integer between 1 and 120 minutes"
|
|
exit 1
|
|
fi
|
|
shift 2
|
|
;;
|
|
--reset-circuit)
|
|
# Source the circuit breaker library
|
|
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
|
source "$SCRIPT_DIR/lib/circuit_breaker.sh"
|
|
source "$SCRIPT_DIR/lib/date_utils.sh"
|
|
reset_circuit_breaker "Manual reset via command line"
|
|
reset_session "manual_circuit_reset"
|
|
exit 0
|
|
;;
|
|
--reset-session)
|
|
# Reset session state only
|
|
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
|
source "$SCRIPT_DIR/lib/date_utils.sh"
|
|
reset_session "manual_reset_flag"
|
|
echo -e "\033[0;32m✅ Session state reset successfully\033[0m"
|
|
exit 0
|
|
;;
|
|
--circuit-status)
|
|
# Source the circuit breaker library
|
|
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
|
source "$SCRIPT_DIR/lib/circuit_breaker.sh"
|
|
show_circuit_status
|
|
exit 0
|
|
;;
|
|
--output-format)
|
|
if [[ "$2" == "json" || "$2" == "text" ]]; then
|
|
CLAUDE_OUTPUT_FORMAT="$2"
|
|
else
|
|
echo "Error: --output-format must be 'json' or 'text'"
|
|
exit 1
|
|
fi
|
|
shift 2
|
|
;;
|
|
--allowed-tools)
|
|
if ! validate_allowed_tools "$2"; then
|
|
exit 1
|
|
fi
|
|
CLAUDE_ALLOWED_TOOLS="$2"
|
|
shift 2
|
|
;;
|
|
--no-continue)
|
|
CLAUDE_USE_CONTINUE=false
|
|
shift
|
|
;;
|
|
--session-expiry)
|
|
if [[ -z "$2" || ! "$2" =~ ^[1-9][0-9]*$ ]]; then
|
|
echo "Error: --session-expiry requires a positive integer (hours)"
|
|
exit 1
|
|
fi
|
|
CLAUDE_SESSION_EXPIRY_HOURS="$2"
|
|
shift 2
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Only execute when run directly, not when sourced
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
# If tmux mode requested, set it up
|
|
if [[ "$USE_TMUX" == "true" ]]; then
|
|
check_tmux_available
|
|
setup_tmux_session
|
|
fi
|
|
|
|
# Start the main loop
|
|
main
|
|
fi
|