ralph-claude-code/lib/response_analyzer.sh
frankbria 3d7db2c3ae feat(date): add cross-platform date compatibility for macOS and Linux
Add cross-platform date utility library to handle differences between
GNU date (Linux) and BSD date (macOS). Fixes issues with:
- ISO 8601 timestamp formatting (-Iseconds flag)
- Date arithmetic operations (-d vs -v flags)

Changes:
- Created lib/date_utils.sh with get_iso_timestamp() and get_next_hour_time()
- Updated ralph_loop.sh to use date utilities (2 instances)
- Updated lib/circuit_breaker.sh to use date utilities (4 instances)
- Updated lib/response_analyzer.sh to use date utilities (1 instance)

All date operations now work consistently across both platforms without
modification. The utility automatically detects the OS and uses the
appropriate date command syntax.

Tested on Linux with GNU date - all syntax checks and integration tests pass.
2025-12-31 16:04:49 -07:00

309 lines
12 KiB
Bash

#!/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'
# 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")
# Analyze Claude Code response and extract signals
analyze_response() {
local output_file=$1
local loop_number=$2
local analysis_result_file=${3:-".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}
# 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_sig" == "true" || "$status" == "COMPLETE" ]]; then
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 ".last_output_length" ]]; then
local last_length=$(cat ".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" > ".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
if [[ $confidence_score -ge 40 || "$has_completion_signal" == "true" ]]; then
exit_signal=true
fi
# Write analysis results to file
cat > "$analysis_result_file" << EOF
{
"loop_number": $loop_number,
"timestamp": "$(get_iso_timestamp)",
"output_file": "$output_file",
"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
}
}
EOF
# 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:-".response_analysis"}
local exit_signals_file=${2:-".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:-".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:-"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
}
# Export functions for use in ralph_loop.sh
export -f analyze_response
export -f update_exit_signals
export -f log_analysis_summary
export -f detect_stuck_loop