#!/bin/bash # Response Analyzer Component for Ralph # Analyzes Claude Code output to detect completion signals, test-only loops, and progress # Source date utilities for cross-platform compatibility source "$(dirname "${BASH_SOURCE[0]}")/date_utils.sh" # Response Analysis Functions # Based on expert recommendations from Martin Fowler, Michael Nygard, Sam Newman # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # Use RALPH_DIR if set by main script, otherwise default to .ralph RALPH_DIR="${RALPH_DIR:-.ralph}" # Analysis configuration COMPLETION_KEYWORDS=("done" "complete" "finished" "all tasks complete" "project complete" "ready for review") TEST_ONLY_PATTERNS=("npm test" "bats" "pytest" "jest" "cargo test" "go test" "running tests") NO_WORK_PATTERNS=("nothing to do" "no changes" "already implemented" "up to date") # ============================================================================= # JSON OUTPUT FORMAT DETECTION AND PARSING # ============================================================================= # Detect output format (json or text) # Returns: "json" if valid JSON, "text" otherwise detect_output_format() { local output_file=$1 if [[ ! -f "$output_file" ]] || [[ ! -s "$output_file" ]]; then echo "text" return fi # Check if file starts with { or [ (JSON indicators) local first_char=$(head -c 1 "$output_file" 2>/dev/null | tr -d '[:space:]') if [[ "$first_char" != "{" && "$first_char" != "[" ]]; then echo "text" return fi # Validate as JSON using jq if jq empty "$output_file" 2>/dev/null; then echo "json" else echo "text" fi } # Parse JSON response and extract structured fields # Creates .ralph/.json_parse_result with normalized analysis data # Supports TWO JSON formats: # 1. Flat format: { status, exit_signal, work_type, files_modified, ... } # 2. Claude CLI format: { result, sessionId, metadata: { files_changed, has_errors, completion_status, ... } } parse_json_response() { local output_file=$1 local result_file="${2:-$RALPH_DIR/.json_parse_result}" if [[ ! -f "$output_file" ]]; then echo "ERROR: Output file not found: $output_file" >&2 return 1 fi # Validate JSON first if ! jq empty "$output_file" 2>/dev/null; then echo "ERROR: Invalid JSON in output file" >&2 return 1 fi # Detect JSON format by checking for Claude CLI fields local has_result_field=$(jq -r 'has("result")' "$output_file" 2>/dev/null) # Extract fields - support both flat format and Claude CLI format # Priority: Claude CLI fields first, then flat format fields # Status: from flat format OR derived from metadata.completion_status local status=$(jq -r '.status // "UNKNOWN"' "$output_file" 2>/dev/null) local completion_status=$(jq -r '.metadata.completion_status // ""' "$output_file" 2>/dev/null) if [[ "$completion_status" == "complete" || "$completion_status" == "COMPLETE" ]]; then status="COMPLETE" fi # Exit signal: from flat format OR derived from completion_status local exit_signal=$(jq -r '.exit_signal // false' "$output_file" 2>/dev/null) # Work type: from flat format local work_type=$(jq -r '.work_type // "UNKNOWN"' "$output_file" 2>/dev/null) # Files modified: from flat format OR from metadata.files_changed local files_modified=$(jq -r '.metadata.files_changed // .files_modified // 0' "$output_file" 2>/dev/null) # Error count: from flat format OR derived from metadata.has_errors # Note: When only has_errors=true is present (without explicit error_count), # we set error_count=1 as a minimum. This is defensive programming since # the stuck detection threshold is >5 errors, so 1 error won't trigger it. # Actual error count may be higher, but precise count isn't critical for our logic. local error_count=$(jq -r '.error_count // 0' "$output_file" 2>/dev/null) local has_errors=$(jq -r '.metadata.has_errors // false' "$output_file" 2>/dev/null) if [[ "$has_errors" == "true" && "$error_count" == "0" ]]; then error_count=1 # At least one error if has_errors is true fi # Summary: from flat format OR from result field (Claude CLI format) local summary=$(jq -r '.result // .summary // ""' "$output_file" 2>/dev/null) # Session ID: from Claude CLI format (sessionId) OR from metadata.session_id local session_id=$(jq -r '.sessionId // .metadata.session_id // ""' "$output_file" 2>/dev/null) # Loop number: from metadata local loop_number=$(jq -r '.metadata.loop_number // .loop_number // 0' "$output_file" 2>/dev/null) # Confidence: from flat format local confidence=$(jq -r '.confidence // 0' "$output_file" 2>/dev/null) # Progress indicators: from Claude CLI metadata (optional) local progress_count=$(jq -r '.metadata.progress_indicators | if . then length else 0 end' "$output_file" 2>/dev/null) # Normalize values # Convert exit_signal to boolean string if [[ "$exit_signal" == "true" || "$status" == "COMPLETE" || "$completion_status" == "complete" || "$completion_status" == "COMPLETE" ]]; then exit_signal="true" else exit_signal="false" fi # Determine is_test_only from work_type local is_test_only="false" if [[ "$work_type" == "TEST_ONLY" ]]; then is_test_only="true" fi # Determine is_stuck from error_count (threshold >5) local is_stuck="false" error_count=$((error_count + 0)) # Ensure integer if [[ $error_count -gt 5 ]]; then is_stuck="true" fi # Ensure files_modified is integer files_modified=$((files_modified + 0)) # Ensure progress_count is integer progress_count=$((progress_count + 0)) # Calculate has_completion_signal local has_completion_signal="false" if [[ "$status" == "COMPLETE" || "$exit_signal" == "true" ]]; then has_completion_signal="true" fi # Boost confidence based on structured data availability if [[ "$has_result_field" == "true" ]]; then confidence=$((confidence + 20)) # Structured response boost fi if [[ $progress_count -gt 0 ]]; then confidence=$((confidence + progress_count * 5)) # Progress indicators boost fi # Write normalized result using jq for safe JSON construction # String fields use --arg (auto-escapes), numeric/boolean use --argjson jq -n \ --arg status "$status" \ --argjson exit_signal "$exit_signal" \ --argjson is_test_only "$is_test_only" \ --argjson is_stuck "$is_stuck" \ --argjson has_completion_signal "$has_completion_signal" \ --argjson files_modified "$files_modified" \ --argjson error_count "$error_count" \ --arg summary "$summary" \ --argjson loop_number "$loop_number" \ --arg session_id "$session_id" \ --argjson confidence "$confidence" \ '{ status: $status, exit_signal: $exit_signal, is_test_only: $is_test_only, is_stuck: $is_stuck, has_completion_signal: $has_completion_signal, files_modified: $files_modified, error_count: $error_count, summary: $summary, loop_number: $loop_number, session_id: $session_id, confidence: $confidence, metadata: { loop_number: $loop_number, session_id: $session_id } }' > "$result_file" return 0 } # Analyze Claude Code response and extract signals analyze_response() { local output_file=$1 local loop_number=$2 local analysis_result_file=${3:-"$RALPH_DIR/.response_analysis"} # Initialize analysis result local has_completion_signal=false local is_test_only=false local is_stuck=false local has_progress=false local confidence_score=0 local exit_signal=false local work_summary="" local files_modified=0 # Read output file if [[ ! -f "$output_file" ]]; then echo "ERROR: Output file not found: $output_file" return 1 fi local output_content=$(cat "$output_file") local output_length=${#output_content} # Detect output format and try JSON parsing first local output_format=$(detect_output_format "$output_file") if [[ "$output_format" == "json" ]]; then # Try JSON parsing if parse_json_response "$output_file" "$RALPH_DIR/.json_parse_result" 2>/dev/null; then # Extract values from JSON parse result has_completion_signal=$(jq -r '.has_completion_signal' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "false") exit_signal=$(jq -r '.exit_signal' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "false") is_test_only=$(jq -r '.is_test_only' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "false") is_stuck=$(jq -r '.is_stuck' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "false") work_summary=$(jq -r '.summary' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "") files_modified=$(jq -r '.files_modified' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "0") local json_confidence=$(jq -r '.confidence' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "0") local session_id=$(jq -r '.session_id' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "") # Persist session ID if present (for session continuity across loop iterations) if [[ -n "$session_id" && "$session_id" != "null" ]]; then store_session_id "$session_id" [[ "${VERBOSE_PROGRESS:-}" == "true" ]] && echo "DEBUG: Persisted session ID: $session_id" >&2 fi # JSON parsing provides high confidence if [[ "$exit_signal" == "true" ]]; then confidence_score=100 else confidence_score=$((json_confidence + 50)) fi # Check for file changes via git (supplements JSON data) if command -v git &>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then local git_files=$(git diff --name-only 2>/dev/null | wc -l) if [[ $git_files -gt 0 ]]; then has_progress=true files_modified=$git_files fi fi # Write analysis results for JSON path using jq for safe construction jq -n \ --argjson loop_number "$loop_number" \ --arg timestamp "$(get_iso_timestamp)" \ --arg output_file "$output_file" \ --arg output_format "json" \ --argjson has_completion_signal "$has_completion_signal" \ --argjson is_test_only "$is_test_only" \ --argjson is_stuck "$is_stuck" \ --argjson has_progress "$has_progress" \ --argjson files_modified "$files_modified" \ --argjson confidence_score "$confidence_score" \ --argjson exit_signal "$exit_signal" \ --arg work_summary "$work_summary" \ --argjson output_length "$output_length" \ '{ loop_number: $loop_number, timestamp: $timestamp, output_file: $output_file, output_format: $output_format, analysis: { has_completion_signal: $has_completion_signal, is_test_only: $is_test_only, is_stuck: $is_stuck, has_progress: $has_progress, files_modified: $files_modified, confidence_score: $confidence_score, exit_signal: $exit_signal, work_summary: $work_summary, output_length: $output_length } }' > "$analysis_result_file" rm -f "$RALPH_DIR/.json_parse_result" return 0 fi # If JSON parsing failed, fall through to text parsing fi # Text parsing fallback (original logic) # Track whether an explicit EXIT_SIGNAL was found in RALPH_STATUS block # If explicit signal found, heuristics should NOT override Claude's intent local explicit_exit_signal_found=false # 1. Check for explicit structured output (if Claude follows schema) if grep -q -- "---RALPH_STATUS---" "$output_file"; then # Parse structured output local status=$(grep "STATUS:" "$output_file" | cut -d: -f2 | xargs) local exit_sig=$(grep "EXIT_SIGNAL:" "$output_file" | cut -d: -f2 | xargs) # If EXIT_SIGNAL is explicitly provided, respect it if [[ -n "$exit_sig" ]]; then explicit_exit_signal_found=true if [[ "$exit_sig" == "true" ]]; then has_completion_signal=true exit_signal=true confidence_score=100 else # Explicit EXIT_SIGNAL: false - Claude says to continue exit_signal=false fi elif [[ "$status" == "COMPLETE" ]]; then # No explicit EXIT_SIGNAL but STATUS is COMPLETE has_completion_signal=true exit_signal=true confidence_score=100 fi fi # 2. Detect completion keywords in natural language output for keyword in "${COMPLETION_KEYWORDS[@]}"; do if grep -qi "$keyword" "$output_file"; then has_completion_signal=true ((confidence_score+=10)) break fi done # 3. Detect test-only loops local test_command_count=0 local implementation_count=0 local error_count=0 test_command_count=$(grep -c -i "running tests\|npm test\|bats\|pytest\|jest" "$output_file" 2>/dev/null | head -1 || echo "0") implementation_count=$(grep -c -i "implementing\|creating\|writing\|adding\|function\|class" "$output_file" 2>/dev/null | head -1 || echo "0") # Strip whitespace and ensure it's a number test_command_count=$(echo "$test_command_count" | tr -d '[:space:]') implementation_count=$(echo "$implementation_count" | tr -d '[:space:]') # Convert to integers with default fallback test_command_count=${test_command_count:-0} implementation_count=${implementation_count:-0} test_command_count=$((test_command_count + 0)) implementation_count=$((implementation_count + 0)) if [[ $test_command_count -gt 0 ]] && [[ $implementation_count -eq 0 ]]; then is_test_only=true work_summary="Test execution only, no implementation" fi # 4. Detect stuck/error loops # Use two-stage filtering to avoid counting JSON field names as errors # Stage 1: Filter out JSON field patterns like "is_error": false # Stage 2: Count actual error messages in specific contexts # Pattern aligned with ralph_loop.sh to ensure consistent behavior error_count=$(grep -v '"[^"]*error[^"]*":' "$output_file" 2>/dev/null | \ grep -cE '(^Error:|^ERROR:|^error:|\]: error|Link: error|Error occurred|failed with error|[Ee]xception|Fatal|FATAL)' \ 2>/dev/null || echo "0") error_count=$(echo "$error_count" | tr -d '[:space:]') error_count=${error_count:-0} error_count=$((error_count + 0)) if [[ $error_count -gt 5 ]]; then is_stuck=true fi # 5. Detect "nothing to do" patterns for pattern in "${NO_WORK_PATTERNS[@]}"; do if grep -qi "$pattern" "$output_file"; then has_completion_signal=true ((confidence_score+=15)) work_summary="No work remaining" break fi done # 6. Check for file changes (git integration) if command -v git &>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then files_modified=$(git diff --name-only 2>/dev/null | wc -l) if [[ $files_modified -gt 0 ]]; then has_progress=true ((confidence_score+=20)) fi fi # 7. Analyze output length trends (detect declining engagement) if [[ -f "$RALPH_DIR/.last_output_length" ]]; then local last_length=$(cat "$RALPH_DIR/.last_output_length") local length_ratio=$((output_length * 100 / last_length)) if [[ $length_ratio -lt 50 ]]; then # Output is less than 50% of previous - possible completion ((confidence_score+=10)) fi fi echo "$output_length" > "$RALPH_DIR/.last_output_length" # 8. Extract work summary from output if [[ -z "$work_summary" ]]; then # Try to find summary in output work_summary=$(grep -i "summary\|completed\|implemented" "$output_file" | head -1 | cut -c 1-100) if [[ -z "$work_summary" ]]; then work_summary="Output analyzed, no explicit summary found" fi fi # 9. Determine exit signal based on confidence (heuristic) # IMPORTANT: Only apply heuristics if no explicit EXIT_SIGNAL was found in RALPH_STATUS # Claude's explicit intent takes precedence over natural language pattern matching if [[ "$explicit_exit_signal_found" != "true" ]]; then if [[ $confidence_score -ge 40 || "$has_completion_signal" == "true" ]]; then exit_signal=true fi fi # Write analysis results to file (text parsing path) using jq for safe construction jq -n \ --argjson loop_number "$loop_number" \ --arg timestamp "$(get_iso_timestamp)" \ --arg output_file "$output_file" \ --arg output_format "text" \ --argjson has_completion_signal "$has_completion_signal" \ --argjson is_test_only "$is_test_only" \ --argjson is_stuck "$is_stuck" \ --argjson has_progress "$has_progress" \ --argjson files_modified "$files_modified" \ --argjson confidence_score "$confidence_score" \ --argjson exit_signal "$exit_signal" \ --arg work_summary "$work_summary" \ --argjson output_length "$output_length" \ '{ loop_number: $loop_number, timestamp: $timestamp, output_file: $output_file, output_format: $output_format, analysis: { has_completion_signal: $has_completion_signal, is_test_only: $is_test_only, is_stuck: $is_stuck, has_progress: $has_progress, files_modified: $files_modified, confidence_score: $confidence_score, exit_signal: $exit_signal, work_summary: $work_summary, output_length: $output_length } }' > "$analysis_result_file" # Always return 0 (success) - callers should check the JSON result file # Returning non-zero would cause issues with set -e and test frameworks return 0 } # Update exit signals file based on analysis update_exit_signals() { local analysis_file=${1:-"$RALPH_DIR/.response_analysis"} local exit_signals_file=${2:-"$RALPH_DIR/.exit_signals"} if [[ ! -f "$analysis_file" ]]; then echo "ERROR: Analysis file not found: $analysis_file" return 1 fi # Read analysis results local is_test_only=$(jq -r '.analysis.is_test_only' "$analysis_file") local has_completion_signal=$(jq -r '.analysis.has_completion_signal' "$analysis_file") local loop_number=$(jq -r '.loop_number' "$analysis_file") local has_progress=$(jq -r '.analysis.has_progress' "$analysis_file") # Read current exit signals local signals=$(cat "$exit_signals_file" 2>/dev/null || echo '{"test_only_loops": [], "done_signals": [], "completion_indicators": []}') # Update test_only_loops array if [[ "$is_test_only" == "true" ]]; then signals=$(echo "$signals" | jq ".test_only_loops += [$loop_number]") else # Clear test_only_loops if we had implementation if [[ "$has_progress" == "true" ]]; then signals=$(echo "$signals" | jq '.test_only_loops = []') fi fi # Update done_signals array if [[ "$has_completion_signal" == "true" ]]; then signals=$(echo "$signals" | jq ".done_signals += [$loop_number]") fi # Update completion_indicators array (strong signals) local confidence=$(jq -r '.analysis.confidence_score' "$analysis_file") if [[ $confidence -ge 60 ]]; then signals=$(echo "$signals" | jq ".completion_indicators += [$loop_number]") fi # Keep only last 5 signals (rolling window) signals=$(echo "$signals" | jq '.test_only_loops = .test_only_loops[-5:]') signals=$(echo "$signals" | jq '.done_signals = .done_signals[-5:]') signals=$(echo "$signals" | jq '.completion_indicators = .completion_indicators[-5:]') # Write updated signals echo "$signals" > "$exit_signals_file" return 0 } # Log analysis results in human-readable format log_analysis_summary() { local analysis_file=${1:-"$RALPH_DIR/.response_analysis"} if [[ ! -f "$analysis_file" ]]; then return 1 fi local loop=$(jq -r '.loop_number' "$analysis_file") local exit_sig=$(jq -r '.analysis.exit_signal' "$analysis_file") local confidence=$(jq -r '.analysis.confidence_score' "$analysis_file") local test_only=$(jq -r '.analysis.is_test_only' "$analysis_file") local files_changed=$(jq -r '.analysis.files_modified' "$analysis_file") local summary=$(jq -r '.analysis.work_summary' "$analysis_file") echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" echo -e "${BLUE}║ Response Analysis - Loop #$loop ║${NC}" echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" echo -e "${YELLOW}Exit Signal:${NC} $exit_sig" echo -e "${YELLOW}Confidence:${NC} $confidence%" echo -e "${YELLOW}Test Only:${NC} $test_only" echo -e "${YELLOW}Files Changed:${NC} $files_changed" echo -e "${YELLOW}Summary:${NC} $summary" echo "" } # Detect if Claude is stuck (repeating same errors) detect_stuck_loop() { local current_output=$1 local history_dir=${2:-"$RALPH_DIR/logs"} # Get last 3 output files local recent_outputs=$(ls -t "$history_dir"/claude_output_*.log 2>/dev/null | head -3) if [[ -z "$recent_outputs" ]]; then return 1 # Not enough history fi # Extract key errors from current output using two-stage filtering # Stage 1: Filter out JSON field patterns to avoid false positives # Stage 2: Extract actual error messages local current_errors=$(grep -v '"[^"]*error[^"]*":' "$current_output" 2>/dev/null | \ grep -E '(^Error:|^ERROR:|^error:|\]: error|Link: error|Error occurred|failed with error|[Ee]xception|Fatal|FATAL)' 2>/dev/null | \ sort | uniq) if [[ -z "$current_errors" ]]; then return 1 # No errors fi # Check if same errors appear in all recent outputs # For multi-line errors, verify ALL error lines appear in ALL history files local all_files_match=true while IFS= read -r output_file; do local file_matches_all=true while IFS= read -r error_line; do # Use -F for literal fixed-string matching (not regex) if ! grep -qF "$error_line" "$output_file" 2>/dev/null; then file_matches_all=false break fi done <<< "$current_errors" if [[ "$file_matches_all" != "true" ]]; then all_files_match=false break fi done <<< "$recent_outputs" if [[ "$all_files_match" == "true" ]]; then return 0 # Stuck on same error(s) else return 1 # Making progress or different errors fi } # ============================================================================= # SESSION MANAGEMENT FUNCTIONS # ============================================================================= # Session file location - standardized across ralph_loop.sh and response_analyzer.sh SESSION_FILE="$RALPH_DIR/.claude_session_id" # Session expiration time in seconds (24 hours) SESSION_EXPIRATION_SECONDS=86400 # Store session ID to file with timestamp # Usage: store_session_id "session-uuid-123" store_session_id() { local session_id=$1 if [[ -z "$session_id" ]]; then return 1 fi # Write session with timestamp using jq for safe JSON construction jq -n \ --arg session_id "$session_id" \ --arg timestamp "$(get_iso_timestamp)" \ '{ session_id: $session_id, timestamp: $timestamp }' > "$SESSION_FILE" return 0 } # Get the last stored session ID # Returns: session ID string or empty if not found get_last_session_id() { if [[ ! -f "$SESSION_FILE" ]]; then echo "" return 0 fi # Extract session_id from JSON file local session_id=$(jq -r '.session_id // ""' "$SESSION_FILE" 2>/dev/null) echo "$session_id" return 0 } # Check if the stored session should be resumed # Returns: 0 (true) if session is valid and recent, 1 (false) otherwise should_resume_session() { if [[ ! -f "$SESSION_FILE" ]]; then echo "false" return 1 fi # Get session timestamp local timestamp=$(jq -r '.timestamp // ""' "$SESSION_FILE" 2>/dev/null) if [[ -z "$timestamp" ]]; then echo "false" return 1 fi # Calculate session age using date utilities local now=$(get_epoch_seconds) local session_time # Parse ISO timestamp to epoch - try multiple formats for cross-platform compatibility # Strip milliseconds if present (e.g., 2026-01-09T10:30:00.123+00:00 → 2026-01-09T10:30:00+00:00) local clean_timestamp="${timestamp}" if [[ "$timestamp" =~ \.[0-9]+[+-Z] ]]; then clean_timestamp=$(echo "$timestamp" | sed 's/\.[0-9]*\([+-Z]\)/\1/') fi if command -v gdate &>/dev/null; then # macOS with coreutils session_time=$(gdate -d "$clean_timestamp" +%s 2>/dev/null) elif date --version 2>&1 | grep -q GNU; then # GNU date (Linux) session_time=$(date -d "$clean_timestamp" +%s 2>/dev/null) else # BSD date (macOS without coreutils) - try parsing ISO format # Format: 2026-01-09T10:30:00+00:00 or 2026-01-09T10:30:00Z # Strip timezone suffix for BSD date parsing local date_only="${clean_timestamp%[+-Z]*}" session_time=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$date_only" +%s 2>/dev/null) fi # If we couldn't parse the timestamp, consider session expired if [[ -z "$session_time" || ! "$session_time" =~ ^[0-9]+$ ]]; then echo "false" return 1 fi # Calculate age in seconds local age=$((now - session_time)) # Check if session is still valid (less than expiration time) if [[ $age -lt $SESSION_EXPIRATION_SECONDS ]]; then echo "true" return 0 else echo "false" return 1 fi } # Export functions for use in ralph_loop.sh export -f detect_output_format export -f parse_json_response export -f analyze_response export -f update_exit_signals export -f log_analysis_summary export -f detect_stuck_loop export -f store_session_id export -f get_last_session_id export -f should_resume_session