Files
Keep/.ralph/lib/response_analyzer.sh

1563 lines
60 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'
# 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")
PERMISSION_DENIAL_INLINE_PATTERNS=(
"requires approval before it can run"
"requires approval before it can proceed"
"not allowed to use tool"
"not permitted to use tool"
)
extract_permission_signal_text() {
local text=$1
if [[ -z "$text" ]]; then
echo ""
return 0
fi
# Only inspect the response preamble for tool refusals. Later paragraphs and
# copied logs often contain old permission errors that should not halt Ralph.
local signal_source="${text//$'\r'/}"
if [[ "$signal_source" == *"---RALPH_STATUS---"* ]]; then
signal_source="${signal_source%%---RALPH_STATUS---*}"
fi
local signal_text=""
local non_empty_lines=0
local trimmed=""
local line=""
while IFS= read -r line; do
trimmed="${line#"${line%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
if [[ -z "$trimmed" ]]; then
if [[ $non_empty_lines -gt 0 ]]; then
break
fi
continue
fi
signal_text+="$trimmed"$'\n'
((non_empty_lines += 1))
if [[ $non_empty_lines -ge 5 ]]; then
break
fi
done <<< "$signal_source"
printf '%s' "$signal_text"
}
permission_denial_line_matches() {
local normalized=$1
case "$normalized" in
permission\ denied:*|denied\ permission:*)
[[ "$normalized" == *approval* || "$normalized" == *tool* || "$normalized" == *command* || "$normalized" == *blocked* || "$normalized" == *"not allowed"* || "$normalized" == *"not permitted"* ]]
return
;;
approval\ required:*)
[[ "$normalized" == *run* || "$normalized" == *proceed* || "$normalized" == *tool* || "$normalized" == *command* || "$normalized" == *blocked* ]]
return
;;
esac
return 1
}
contains_permission_denial_signal() {
local signal_text=$1
if [[ -z "$signal_text" ]]; then
return 1
fi
local line
while IFS= read -r line; do
local trimmed="${line#"${line%%[![:space:]]*}"}"
local normalized
normalized="$(printf '%s' "$trimmed" | tr '[:upper:]' '[:lower:]')"
if permission_denial_line_matches "$normalized"; then
return 0
fi
local pattern
for pattern in "${PERMISSION_DENIAL_INLINE_PATTERNS[@]}"; do
if [[ "$normalized" == *"$pattern"* ]]; then
return 0
fi
done
done <<< "$signal_text"
return 1
}
contains_permission_denial_text() {
local signal_text
signal_text=$(extract_permission_signal_text "$1")
contains_permission_denial_signal "$signal_text"
}
# =============================================================================
# JSON OUTPUT FORMAT DETECTION AND PARSING
# =============================================================================
# Windows jq.exe handles workspace-relative paths more reliably than POSIX
# absolute temp paths like /tmp/... when invoked from Git Bash.
create_jq_temp_file() {
mktemp "./.response_analyzer.XXXXXX"
}
# Count parseable top-level JSON documents in an output file.
count_json_documents() {
local output_file=$1
if [[ ! -f "$output_file" ]] || [[ ! -s "$output_file" ]]; then
echo "0"
return 1
fi
jq -n -j 'reduce inputs as $item (0; . + 1)' < "$output_file" 2>/dev/null
}
# Normalize a Claude CLI array response into a single object file.
normalize_cli_array_response() {
local output_file=$1
local normalized_file=$2
# Extract the "result" type message from the array (usually the last entry)
# This contains: result, session_id, is_error, duration_ms, etc.
local result_obj=$(jq '[.[] | select(.type == "result")] | .[-1] // {}' "$output_file" 2>/dev/null)
# Guard against empty result_obj if jq fails (review fix: Macroscope)
[[ -z "$result_obj" ]] && result_obj="{}"
# Extract session_id from init message as fallback
local init_session_id=$(jq -r '.[] | select(.type == "system" and .subtype == "init") | .session_id // empty' "$output_file" 2>/dev/null | head -1 | tr -d '\r')
# Prioritize result object's own session_id, then fall back to init message (review fix: CodeRabbit)
# This prevents session ID loss when arrays lack an init message with session_id
local effective_session_id
effective_session_id=$(echo "$result_obj" | jq -r -j '.sessionId // .session_id // empty' 2>/dev/null)
if [[ -z "$effective_session_id" || "$effective_session_id" == "null" ]]; then
effective_session_id="$init_session_id"
fi
# Build normalized object merging result with effective session_id
if [[ -n "$effective_session_id" && "$effective_session_id" != "null" ]]; then
echo "$result_obj" | jq --arg sid "$effective_session_id" '. + {sessionId: $sid} | del(.session_id)' > "$normalized_file"
else
echo "$result_obj" | jq 'del(.session_id)' > "$normalized_file"
fi
}
# Normalize Codex JSONL event output into the object shape expected downstream.
normalize_codex_jsonl_response() {
local output_file=$1
local normalized_file=$2
jq -rs '
def agent_text($item):
$item.text // (
[($item.content // [])[]? | select(.type == "output_text") | .text]
| join("\n")
) // "";
(map(select(.type == "item.completed" and .item.type == "agent_message")) | last | .item // {}) as $agent_message
| {
result: agent_text($agent_message),
sessionId: (map(select(.type == "thread.started") | .thread_id // empty) | first // ""),
metadata: {}
}
' "$output_file" > "$normalized_file"
}
# Normalize Cursor stream-json event output into the object shape expected downstream.
normalize_cursor_stream_json_response() {
local output_file=$1
local normalized_file=$2
jq -rs '
def assistant_text($item):
[($item.message.content // [])[]? | select(.type == "text") | .text]
| join("\n");
(map(select(.type == "result")) | last // {}) as $result_event
| {
result: (
$result_event.result
// (
map(select(.type == "assistant"))
| map(assistant_text(.))
| map(select(length > 0))
| join("\n")
)
),
sessionId: (
$result_event.session_id
// (map(select(.type == "system" and .subtype == "init") | .session_id // empty) | first)
// ""
),
metadata: {}
}
' "$output_file" > "$normalized_file"
}
# Normalize OpenCode JSON event output into the object shape expected downstream.
normalize_opencode_jsonl_response() {
local output_file=$1
local normalized_file=$2
jq -rs '
def assistant_text($message):
[($message.parts // [])[]? | select(.type == "text") | .text]
| join("\n");
(map(
select(
(.type == "message.updated" or .type == "message.completed")
and (.message.role // "") == "assistant"
)
) | last | .message // {}) as $assistant_message
| {
result: assistant_text($assistant_message),
sessionId: (
map(.session.id // .session_id // .sessionId // empty)
| map(select(length > 0))
| first
// ""
),
metadata: {}
}
' "$output_file" > "$normalized_file"
}
# Detect whether a multi-document stream matches Codex JSONL events.
is_codex_jsonl_output() {
local output_file=$1
jq -n -j '
reduce inputs as $item (
false;
. or (
$item.type == "thread.started" or
($item.type == "item.completed" and ($item.item.type? != null))
)
)
' < "$output_file" 2>/dev/null
}
# Detect whether a multi-document stream matches OpenCode JSON events.
is_opencode_jsonl_output() {
local output_file=$1
jq -n -j '
reduce inputs as $item (
false;
. or (
$item.type == "session.created" or
$item.type == "session.updated" or
(
($item.type == "message.updated" or $item.type == "message.completed")
and ($item.message.role? != null)
)
)
)
' < "$output_file" 2>/dev/null
}
# Detect whether a multi-document stream matches Cursor stream-json events.
is_cursor_stream_json_output() {
local output_file=$1
jq -n -j '
reduce inputs as $item (
false;
. or (
$item.type == "system" or
$item.type == "user" or
$item.type == "assistant" or
$item.type == "tool_call" or
$item.type == "result"
)
)
' < "$output_file" 2>/dev/null
}
# Normalize structured output to a single object when downstream parsing expects one.
normalize_json_output() {
local output_file=$1
local normalized_file=$2
local json_document_count
json_document_count=$(count_json_documents "$output_file") || return 1
if [[ "$json_document_count" -gt 1 ]]; then
local is_codex_jsonl
is_codex_jsonl=$(is_codex_jsonl_output "$output_file") || return 1
if [[ "$is_codex_jsonl" == "true" ]]; then
normalize_codex_jsonl_response "$output_file" "$normalized_file"
return $?
fi
local is_opencode_jsonl
is_opencode_jsonl=$(is_opencode_jsonl_output "$output_file") || return 1
if [[ "$is_opencode_jsonl" == "true" ]]; then
normalize_opencode_jsonl_response "$output_file" "$normalized_file"
return $?
fi
local is_cursor_stream_json
is_cursor_stream_json=$(is_cursor_stream_json_output "$output_file") || return 1
if [[ "$is_cursor_stream_json" == "true" ]]; then
normalize_cursor_stream_json_response "$output_file" "$normalized_file"
return $?
fi
return 1
fi
if jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
normalize_cli_array_response "$output_file" "$normalized_file"
return $?
fi
return 1
}
# Extract persisted session ID from any supported structured output.
extract_session_id_from_output() {
local output_file=$1
local normalized_file=""
local session_id=""
local json_document_count
if [[ ! -f "$output_file" ]] || [[ ! -s "$output_file" ]]; then
echo ""
return 1
fi
json_document_count=$(count_json_documents "$output_file") || {
echo ""
return 1
}
if [[ "$json_document_count" -gt 1 ]] || jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
normalized_file=$(create_jq_temp_file)
if ! normalize_json_output "$output_file" "$normalized_file"; then
rm -f "$normalized_file"
echo ""
return 1
fi
output_file="$normalized_file"
fi
session_id=$(jq -r '.sessionId // .metadata.session_id // .session_id // empty' "$output_file" 2>/dev/null | head -1 | tr -d '\r')
if [[ -n "$normalized_file" && -f "$normalized_file" ]]; then
rm -f "$normalized_file"
fi
echo "$session_id"
[[ -n "$session_id" && "$session_id" != "null" ]]
}
# Detect output format (json or text)
# Returns: "json" for single-document JSON and newline-delimited 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
local json_document_count
json_document_count=$(count_json_documents "$output_file") || {
echo "text"
return
}
if [[ "$json_document_count" -eq 1 ]]; then
echo "json"
return
fi
if [[ "$json_document_count" -gt 1 ]]; then
local is_codex_jsonl
is_codex_jsonl=$(is_codex_jsonl_output "$output_file") || {
echo "text"
return
}
if [[ "$is_codex_jsonl" == "true" ]]; then
echo "json"
return
fi
local is_opencode_jsonl
is_opencode_jsonl=$(is_opencode_jsonl_output "$output_file") || {
echo "text"
return
}
if [[ "$is_opencode_jsonl" == "true" ]]; then
echo "json"
return
fi
local is_cursor_stream_json
is_cursor_stream_json=$(is_cursor_stream_json_output "$output_file") || {
echo "text"
return
}
if [[ "$is_cursor_stream_json" == "true" ]]; then
echo "json"
return
fi
fi
echo "text"
}
trim_shell_whitespace() {
local value="${1//$'\r'/}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
extract_ralph_status_block_json() {
local text=$1
local normalized="${text//$'\r'/}"
if [[ "$normalized" != *"---RALPH_STATUS---"* ]]; then
return 1
fi
local block="${normalized#*---RALPH_STATUS---}"
if [[ "$block" == "$normalized" ]]; then
return 1
fi
if [[ "$block" == *"---END_RALPH_STATUS---"* ]]; then
block="${block%%---END_RALPH_STATUS---*}"
fi
local status=""
local exit_signal="false"
local exit_signal_found="false"
local tasks_completed_this_loop=0
local tests_status="UNKNOWN"
local line=""
local trimmed=""
local value=""
while IFS= read -r line; do
trimmed=$(trim_shell_whitespace "$line")
case "$trimmed" in
STATUS:*)
value=$(trim_shell_whitespace "${trimmed#STATUS:}")
[[ -n "$value" ]] && status="$value"
;;
EXIT_SIGNAL:*)
value=$(trim_shell_whitespace "${trimmed#EXIT_SIGNAL:}")
if [[ "$value" == "true" || "$value" == "false" ]]; then
exit_signal="$value"
exit_signal_found="true"
fi
;;
TASKS_COMPLETED_THIS_LOOP:*)
value=$(trim_shell_whitespace "${trimmed#TASKS_COMPLETED_THIS_LOOP:}")
if [[ "$value" =~ ^-?[0-9]+$ ]]; then
tasks_completed_this_loop=$value
fi
;;
TESTS_STATUS:*)
value=$(trim_shell_whitespace "${trimmed#TESTS_STATUS:}")
[[ -n "$value" ]] && tests_status="$value"
;;
esac
done <<< "$block"
jq -n \
--arg status "$status" \
--argjson exit_signal_found "$exit_signal_found" \
--argjson exit_signal "$exit_signal" \
--argjson tasks_completed_this_loop "$tasks_completed_this_loop" \
--arg tests_status "$tests_status" \
'{
status: $status,
exit_signal_found: $exit_signal_found,
exit_signal: $exit_signal,
tasks_completed_this_loop: $tasks_completed_this_loop,
tests_status: $tests_status
}'
}
# Parse JSON response and extract structured fields
# Creates .ralph/.json_parse_result with normalized analysis data
# Supports SIX JSON formats:
# 1. Flat format: { status, exit_signal, work_type, files_modified, ... }
# 2. Claude CLI object format: { result, sessionId, metadata: { files_changed, has_errors, completion_status, ... } }
# 3. Claude CLI array format: [ {type: "system", ...}, {type: "assistant", ...}, {type: "result", ...} ]
# 4. Codex JSONL format: {"type":"thread.started",...}\n{"type":"item.completed","item":{...}}
# 5. OpenCode JSON event format: {"type":"session.created",...}\n{"type":"message.updated",...}
# 6. Cursor stream-json format: {"type":"assistant",...}\n{"type":"result",...}
parse_json_response() {
local output_file=$1
local result_file="${2:-$RALPH_DIR/.json_parse_result}"
local original_output_file=$output_file
local normalized_file=""
local json_document_count=""
local response_shape="object"
if [[ ! -f "$output_file" ]]; then
echo "ERROR: Output file not found: $output_file" >&2
return 1
fi
# Validate JSON first
json_document_count=$(count_json_documents "$output_file") || {
echo "ERROR: Invalid JSON in output file" >&2
return 1
}
# Normalize multi-document JSONL and array responses to a single object.
if [[ "$json_document_count" -gt 1 ]] || jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
if [[ "$json_document_count" -gt 1 ]]; then
response_shape="jsonl"
else
response_shape="array"
fi
normalized_file=$(create_jq_temp_file)
if ! normalize_json_output "$output_file" "$normalized_file"; then
rm -f "$normalized_file"
echo "ERROR: Failed to normalize JSON output" >&2
return 1
fi
# Use normalized file for subsequent parsing
output_file="$normalized_file"
if [[ "$response_shape" == "jsonl" ]]; then
if [[ "$(is_codex_jsonl_output "$original_output_file")" == "true" ]]; then
response_shape="codex_jsonl"
elif [[ "$(is_opencode_jsonl_output "$original_output_file")" == "true" ]]; then
response_shape="opencode_jsonl"
else
response_shape="cursor_stream_jsonl"
fi
fi
fi
local has_result_field="false"
local status="UNKNOWN"
local completion_status=""
local exit_signal="false"
local explicit_exit_signal_found="false"
local tasks_completed_this_loop=0
local tests_status="UNKNOWN"
local result_text=""
local work_type="UNKNOWN"
local files_modified=0
local error_count=0
local has_errors="false"
local summary=""
local session_id=""
local loop_number=0
local confidence=0
local progress_count=0
local permission_denial_count=0
local has_permission_denials="false"
local denied_commands_json="[]"
if [[ "$response_shape" == "codex_jsonl" || "$response_shape" == "opencode_jsonl" || "$response_shape" == "cursor_stream_jsonl" ]]; then
local driver_fields=""
driver_fields=$(jq -r '
[
(.result // ""),
(.sessionId // .metadata.session_id // .session_id // ""),
((.permission_denials // []) | length),
((.permission_denials // []) | map(
if .tool_name == "Bash" then
"Bash(\(.tool_input.command // "?" | split("\n")[0] | .[0:60]))"
else
.tool_name // "unknown"
end
) | @json)
] | @tsv
' "$output_file" 2>/dev/null)
local denied_commands_field="[]"
IFS=$'\t' read -r result_text session_id permission_denial_count denied_commands_field <<< "$driver_fields"
has_result_field="true"
summary="$result_text"
denied_commands_json="${denied_commands_field:-[]}"
if [[ ! "$permission_denial_count" =~ ^-?[0-9]+$ ]]; then
permission_denial_count=0
fi
if [[ $permission_denial_count -gt 0 ]]; then
has_permission_denials="true"
fi
else
# Detect JSON format by checking for Claude CLI fields
has_result_field=$(jq -r -j '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
status=$(jq -r -j '.status // "UNKNOWN"' "$output_file" 2>/dev/null)
completion_status=$(jq -r -j '.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
# Track whether EXIT_SIGNAL was explicitly provided (vs inferred from STATUS)
exit_signal=$(jq -r -j '.exit_signal // false' "$output_file" 2>/dev/null)
explicit_exit_signal_found=$(jq -r -j 'has("exit_signal")' "$output_file" 2>/dev/null)
tasks_completed_this_loop=$(jq -r -j '.tasks_completed_this_loop // 0' "$output_file" 2>/dev/null)
if [[ ! "$tasks_completed_this_loop" =~ ^-?[0-9]+$ ]]; then
tasks_completed_this_loop=0
fi
if [[ "$has_result_field" == "true" ]]; then
result_text=$(jq -r -j '.result // ""' "$output_file" 2>/dev/null)
fi
# Work type: from flat format
work_type=$(jq -r -j '.work_type // "UNKNOWN"' "$output_file" 2>/dev/null)
# Files modified: from flat format OR from metadata.files_changed
files_modified=$(jq -r -j '.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.
error_count=$(jq -r -j '.error_count // 0' "$output_file" 2>/dev/null)
has_errors=$(jq -r -j '.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)
summary=$(jq -r -j '.result // .summary // ""' "$output_file" 2>/dev/null)
# Session ID: from Claude CLI format (sessionId) OR from metadata.session_id
session_id=$(jq -r -j '.sessionId // .metadata.session_id // .session_id // ""' "$output_file" 2>/dev/null)
# Loop number: from metadata
loop_number=$(jq -r -j '.metadata.loop_number // .loop_number // 0' "$output_file" 2>/dev/null)
# Confidence: from flat format
confidence=$(jq -r -j '.confidence // 0' "$output_file" 2>/dev/null)
# Progress indicators: from Claude CLI metadata (optional)
progress_count=$(jq -r -j '.metadata.progress_indicators | if . then length else 0 end' "$output_file" 2>/dev/null)
# Permission denials: from Claude Code output (Issue #101)
# When Claude Code is denied permission to run commands, it outputs a permission_denials array
permission_denial_count=$(jq -r -j '.permission_denials | if . then length else 0 end' "$output_file" 2>/dev/null)
permission_denial_count=$((permission_denial_count + 0)) # Ensure integer
if [[ $permission_denial_count -gt 0 ]]; then
has_permission_denials="true"
fi
# Extract denied tool names and commands for logging/display
# Shows tool_name for non-Bash tools, and for Bash tools shows the command that was denied
# This handles both cases: AskUserQuestion denial shows "AskUserQuestion",
# while Bash denial shows "Bash(git commit -m ...)" with truncated command
if [[ $permission_denial_count -gt 0 ]]; then
denied_commands_json=$(jq -r -j '[.permission_denials[] | if .tool_name == "Bash" then "Bash(\(.tool_input.command // "?" | split("\n")[0] | .[0:60]))" else .tool_name // "unknown" end]' "$output_file" 2>/dev/null || echo "[]")
fi
fi
local ralph_status_json=""
if [[ -n "$result_text" ]] && ralph_status_json=$(extract_ralph_status_block_json "$result_text" 2>/dev/null); then
local embedded_exit_signal_found
embedded_exit_signal_found=$(printf '%s' "$ralph_status_json" | jq -r -j '.exit_signal_found' 2>/dev/null)
local embedded_exit_sig
embedded_exit_sig=$(printf '%s' "$ralph_status_json" | jq -r -j '.exit_signal' 2>/dev/null)
local embedded_status
embedded_status=$(printf '%s' "$ralph_status_json" | jq -r -j '.status' 2>/dev/null)
local embedded_tasks_completed
embedded_tasks_completed=$(printf '%s' "$ralph_status_json" | jq -r -j '.tasks_completed_this_loop' 2>/dev/null)
local embedded_tests_status
embedded_tests_status=$(printf '%s' "$ralph_status_json" | jq -r -j '.tests_status' 2>/dev/null)
if [[ "$embedded_tasks_completed" =~ ^-?[0-9]+$ ]]; then
tasks_completed_this_loop=$embedded_tasks_completed
fi
if [[ -n "$embedded_tests_status" && "$embedded_tests_status" != "null" ]]; then
tests_status="$embedded_tests_status"
fi
if [[ "$embedded_exit_signal_found" == "true" ]]; then
explicit_exit_signal_found="true"
exit_signal="$embedded_exit_sig"
[[ "${VERBOSE_PROGRESS:-}" == "true" ]] && echo "DEBUG: Extracted EXIT_SIGNAL=$embedded_exit_sig from .result RALPH_STATUS block" >&2
elif [[ "$embedded_status" == "COMPLETE" && "$explicit_exit_signal_found" != "true" ]]; then
exit_signal="true"
[[ "${VERBOSE_PROGRESS:-}" == "true" ]] && echo "DEBUG: Inferred EXIT_SIGNAL=true from .result STATUS=COMPLETE (no explicit EXIT_SIGNAL found)" >&2
fi
fi
# Heuristic permission-denial matching is limited to the refusal-shaped
# response preamble, not arbitrary prose or copied logs later in the body.
if [[ "$has_permission_denials" != "true" ]] && contains_permission_denial_text "$summary"; then
has_permission_denials="true"
permission_denial_count=1
denied_commands_json='["permission_denied"]'
fi
# Apply completion heuristics to normalized summary text when explicit structured
# completion markers are absent. This keeps JSONL analysis aligned with text mode.
local summary_has_completion_keyword="false"
local summary_has_no_work_pattern="false"
if [[ "$response_shape" == "codex_jsonl" || "$response_shape" == "opencode_jsonl" || "$response_shape" == "cursor_stream_jsonl" ]] && [[ "$explicit_exit_signal_found" != "true" && -n "$summary" ]]; then
for keyword in "${COMPLETION_KEYWORDS[@]}"; do
if echo "$summary" | grep -qiw "$keyword"; then
summary_has_completion_keyword="true"
break
fi
done
for pattern in "${NO_WORK_PATTERNS[@]}"; do
if echo "$summary" | grep -qiw "$pattern"; then
summary_has_no_work_pattern="true"
break
fi
done
fi
# Normalize values
# Convert exit_signal to boolean string
# Only infer from status/completion_status if no explicit EXIT_SIGNAL was provided
if [[ "$explicit_exit_signal_found" == "true" ]]; then
# Respect explicit EXIT_SIGNAL value (already set above)
[[ "$exit_signal" == "true" ]] && exit_signal="true" || exit_signal="false"
elif [[ "$exit_signal" == "true" || "$status" == "COMPLETE" || "$completion_status" == "complete" || "$completion_status" == "COMPLETE" || "$summary_has_completion_keyword" == "true" || "$summary_has_no_work_pattern" == "true" ]]; 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 [[ "$explicit_exit_signal_found" == "true" ]]; then
if [[ "$exit_signal" == "true" ]]; then
has_completion_signal="true"
fi
elif [[ "$status" == "COMPLETE" || "$exit_signal" == "true" || "$summary_has_completion_keyword" == "true" || "$summary_has_no_work_pattern" == "true" ]]; then
has_completion_signal="true"
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" \
--argjson tasks_completed_this_loop "$tasks_completed_this_loop" \
--argjson has_permission_denials "$has_permission_denials" \
--argjson permission_denial_count "$permission_denial_count" \
--argjson denied_commands "$denied_commands_json" \
--arg tests_status "$tests_status" \
--argjson has_result_field "$has_result_field" \
'{
status: $status,
exit_signal: $exit_signal,
is_test_only: $is_test_only,
is_stuck: $is_stuck,
has_completion_signal: $has_completion_signal,
has_result_field: $has_result_field,
files_modified: $files_modified,
error_count: $error_count,
summary: $summary,
loop_number: $loop_number,
session_id: $session_id,
confidence: $confidence,
tasks_completed_this_loop: $tasks_completed_this_loop,
tests_status: $tests_status,
has_permission_denials: $has_permission_denials,
permission_denial_count: $permission_denial_count,
denied_commands: $denied_commands,
metadata: {
loop_number: $loop_number,
session_id: $session_id
}
}' > "$result_file"
# Cleanup temporary normalized file if created (for array format handling)
if [[ -n "$normalized_file" && -f "$normalized_file" ]]; then
rm -f "$normalized_file"
fi
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 format_confidence=0
local work_summary=""
local files_modified=0
local tasks_completed_this_loop=0
local tests_status="UNKNOWN"
# 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")
local json_parse_result_file=""
if [[ "$output_format" == "json" ]]; then
# Try JSON parsing
json_parse_result_file=$(create_jq_temp_file)
if parse_json_response "$output_file" "$json_parse_result_file" 2>/dev/null; then
# Extract values from JSON parse result
has_completion_signal=$(jq -r -j '.has_completion_signal' "$json_parse_result_file" 2>/dev/null || echo "false")
exit_signal=$(jq -r -j '.exit_signal' "$json_parse_result_file" 2>/dev/null || echo "false")
is_test_only=$(jq -r -j '.is_test_only' "$json_parse_result_file" 2>/dev/null || echo "false")
is_stuck=$(jq -r -j '.is_stuck' "$json_parse_result_file" 2>/dev/null || echo "false")
work_summary=$(jq -r -j '.summary' "$json_parse_result_file" 2>/dev/null || echo "")
files_modified=$(jq -r -j '.files_modified' "$json_parse_result_file" 2>/dev/null || echo "0")
tasks_completed_this_loop=$(jq -r -j '.tasks_completed_this_loop // 0' "$json_parse_result_file" 2>/dev/null || echo "0")
tests_status=$(jq -r -j '.tests_status // "UNKNOWN"' "$json_parse_result_file" 2>/dev/null || echo "UNKNOWN")
local json_confidence=$(jq -r -j '.confidence' "$json_parse_result_file" 2>/dev/null || echo "0")
local json_has_result_field=$(jq -r -j '.has_result_field' "$json_parse_result_file" 2>/dev/null || echo "false")
local session_id=$(jq -r -j '.session_id' "$json_parse_result_file" 2>/dev/null || echo "")
# Extract permission denial fields (Issue #101)
local has_permission_denials=$(jq -r -j '.has_permission_denials' "$json_parse_result_file" 2>/dev/null || echo "false")
local permission_denial_count=$(jq -r -j '.permission_denial_count' "$json_parse_result_file" 2>/dev/null || echo "0")
local denied_commands_json=$(jq -r -j '.denied_commands' "$json_parse_result_file" 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
# Separate format confidence from completion confidence (Issue #124)
if [[ "$json_has_result_field" == "true" ]]; then
format_confidence=100
else
format_confidence=80
fi
if [[ "$exit_signal" == "true" ]]; then
confidence_score=100
else
confidence_score=$json_confidence
fi
if [[ ! "$tasks_completed_this_loop" =~ ^-?[0-9]+$ ]]; then
tasks_completed_this_loop=0
fi
# Check for file changes via git (supplements JSON data)
# Fix #141: Detect both uncommitted changes AND committed changes
if command -v git &>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then
local git_files=0
local loop_start_sha=""
local current_sha=""
if [[ -f "$RALPH_DIR/.loop_start_sha" ]]; then
loop_start_sha=$(cat "$RALPH_DIR/.loop_start_sha" 2>/dev/null || echo "")
fi
current_sha=$(git rev-parse HEAD 2>/dev/null || echo "")
# Check if commits were made (HEAD changed)
if [[ -n "$loop_start_sha" && -n "$current_sha" && "$loop_start_sha" != "$current_sha" ]]; then
# Commits were made - count union of committed files AND working tree changes
git_files=$(
{
git diff --name-only "$loop_start_sha" "$current_sha" 2>/dev/null
git diff --name-only HEAD 2>/dev/null # unstaged changes
git diff --name-only --cached 2>/dev/null # staged changes
} | sort -u | wc -l
)
else
# No commits - check for uncommitted changes (staged + unstaged)
git_files=$(
{
git diff --name-only 2>/dev/null # unstaged changes
git diff --name-only --cached 2>/dev/null # staged changes
} | sort -u | wc -l
)
fi
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 format_confidence "$format_confidence" \
--argjson confidence_score "$confidence_score" \
--argjson exit_signal "$exit_signal" \
--argjson tasks_completed_this_loop "$tasks_completed_this_loop" \
--arg work_summary "$work_summary" \
--argjson output_length "$output_length" \
--argjson has_permission_denials "$has_permission_denials" \
--argjson permission_denial_count "$permission_denial_count" \
--argjson denied_commands "$denied_commands_json" \
--arg tests_status "$tests_status" \
'{
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,
format_confidence: $format_confidence,
confidence_score: $confidence_score,
exit_signal: $exit_signal,
tasks_completed_this_loop: $tasks_completed_this_loop,
tests_status: $tests_status,
fix_plan_completed_delta: 0,
has_progress_tracking_mismatch: false,
work_summary: $work_summary,
output_length: $output_length,
has_permission_denials: $has_permission_denials,
permission_denial_count: $permission_denial_count,
denied_commands: $denied_commands
}
}' > "$analysis_result_file"
rm -f "$json_parse_result_file"
return 0
fi
rm -f "$json_parse_result_file"
# If JSON parsing failed, fall through to text parsing
fi
# Text parsing fallback (original logic)
# 1. Check for explicit structured output (RALPH_STATUS block)
# When a status block is present, it is authoritative — skip all heuristics.
# A structurally valid but field-empty block results in exit_signal=false,
# confidence=0 by design (AI produced a block but provided no signal).
local ralph_status_block_found=false
local ralph_status_json=""
if ralph_status_json=$(extract_ralph_status_block_json "$output_content" 2>/dev/null); then
ralph_status_block_found=true
format_confidence=70
local status
status=$(printf '%s' "$ralph_status_json" | jq -r -j '.status' 2>/dev/null)
local exit_sig_found
exit_sig_found=$(printf '%s' "$ralph_status_json" | jq -r -j '.exit_signal_found' 2>/dev/null)
local exit_sig
exit_sig=$(printf '%s' "$ralph_status_json" | jq -r -j '.exit_signal' 2>/dev/null)
local parsed_tasks_completed
parsed_tasks_completed=$(printf '%s' "$ralph_status_json" | jq -r -j '.tasks_completed_this_loop' 2>/dev/null)
local parsed_tests_status
parsed_tests_status=$(printf '%s' "$ralph_status_json" | jq -r -j '.tests_status' 2>/dev/null)
if [[ "$parsed_tasks_completed" =~ ^-?[0-9]+$ ]]; then
tasks_completed_this_loop=$parsed_tasks_completed
fi
if [[ -n "$parsed_tests_status" && "$parsed_tests_status" != "null" ]]; then
tests_status="$parsed_tests_status"
fi
# If EXIT_SIGNAL is explicitly provided, respect it
if [[ "$exit_sig_found" == "true" ]]; then
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
confidence_score=80
fi
elif [[ "$status" == "COMPLETE" ]]; then
# No explicit EXIT_SIGNAL but STATUS is COMPLETE
has_completion_signal=true
exit_signal=true
confidence_score=100
fi
# is_test_only and is_stuck stay false (defaults) — status block is authoritative
fi
if [[ "$ralph_status_block_found" != "true" ]]; then
# No status block found — fall back to heuristic analysis
format_confidence=30
# 2. Detect completion keywords in natural language output
for keyword in "${COMPLETION_KEYWORDS[@]}"; do
if grep -qiw "$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 -qiw "$pattern" "$output_file"; then
has_completion_signal=true
((confidence_score+=15))
work_summary="No work remaining"
break
fi
done
# 7. Analyze output length trends (detect declining engagement)
if [[ -f "$RALPH_DIR/.last_output_length" ]]; then
local last_length
last_length=$(cat "$RALPH_DIR/.last_output_length")
if [[ "$last_length" -gt 0 ]]; then
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
fi
# 9. Determine exit signal based on confidence (heuristic)
if [[ $confidence_score -ge 40 || "$has_completion_signal" == "true" ]]; then
exit_signal=true
fi
fi
# Always persist output length for next iteration (both paths)
echo "$output_length" > "$RALPH_DIR/.last_output_length"
# 6. Check for file changes (git integration) — always runs
if command -v git &>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then
local loop_start_sha=""
local current_sha=""
if [[ -f "$RALPH_DIR/.loop_start_sha" ]]; then
loop_start_sha=$(cat "$RALPH_DIR/.loop_start_sha" 2>/dev/null || echo "")
fi
current_sha=$(git rev-parse HEAD 2>/dev/null || echo "")
# Check if commits were made (HEAD changed)
if [[ -n "$loop_start_sha" && -n "$current_sha" && "$loop_start_sha" != "$current_sha" ]]; then
# Commits were made - count union of committed files AND working tree changes
files_modified=$(
{
git diff --name-only "$loop_start_sha" "$current_sha" 2>/dev/null
git diff --name-only HEAD 2>/dev/null # unstaged changes
git diff --name-only --cached 2>/dev/null # staged changes
} | sort -u | wc -l
)
else
# No commits - check for uncommitted changes (staged + unstaged)
files_modified=$(
{
git diff --name-only 2>/dev/null # unstaged changes
git diff --name-only --cached 2>/dev/null # staged changes
} | sort -u | wc -l
)
fi
if [[ $files_modified -gt 0 ]]; then
has_progress=true
# Only boost completion confidence in heuristic path (Issue #124)
# RALPH_STATUS block is authoritative — git changes shouldn't inflate it
if [[ "$ralph_status_block_found" != "true" ]]; then
((confidence_score+=20))
fi
fi
fi
# 8. Extract work summary from output — always runs
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
local has_permission_denials=false
local permission_denial_count=0
local denied_commands_json='[]'
local permission_signal_text=""
permission_signal_text=$(extract_permission_signal_text "$output_content")
if contains_permission_denial_text "$work_summary" || contains_permission_denial_signal "$permission_signal_text"; then
has_permission_denials=true
permission_denial_count=1
denied_commands_json='["permission_denied"]'
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 format_confidence "$format_confidence" \
--argjson confidence_score "$confidence_score" \
--argjson exit_signal "$exit_signal" \
--argjson tasks_completed_this_loop "$tasks_completed_this_loop" \
--arg work_summary "$work_summary" \
--argjson output_length "$output_length" \
--argjson has_permission_denials "$has_permission_denials" \
--argjson permission_denial_count "$permission_denial_count" \
--argjson denied_commands "$denied_commands_json" \
--arg tests_status "$tests_status" \
'{
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,
format_confidence: $format_confidence,
confidence_score: $confidence_score,
exit_signal: $exit_signal,
tasks_completed_this_loop: $tasks_completed_this_loop,
tests_status: $tests_status,
fix_plan_completed_delta: 0,
has_progress_tracking_mismatch: false,
work_summary: $work_summary,
output_length: $output_length,
has_permission_denials: $has_permission_denials,
permission_denial_count: $permission_denial_count,
denied_commands: $denied_commands
}
}' > "$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 -j '.analysis.is_test_only' "$analysis_file")
local has_completion_signal=$(jq -r -j '.analysis.has_completion_signal' "$analysis_file")
local loop_number=$(jq -r -j '.loop_number' "$analysis_file")
local has_progress=$(jq -r -j '.analysis.has_progress' "$analysis_file")
local has_permission_denials=$(jq -r -j '.analysis.has_permission_denials // false' "$analysis_file")
local has_progress_tracking_mismatch=$(jq -r -j '.analysis.has_progress_tracking_mismatch // false' "$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
# Permission denials are handled in the same loop, so they must not become
# completion state that can halt the next loop.
if [[ "$has_permission_denials" != "true" && "$has_progress_tracking_mismatch" != "true" && "$has_completion_signal" == "true" ]]; then
signals=$(echo "$signals" | jq ".done_signals += [$loop_number]")
fi
# Update completion_indicators array (only when Claude explicitly signals exit)
# Note: Format confidence (parse quality) is separated from completion confidence
# since Issue #124. Only exit_signal drives completion indicators, not confidence score.
local exit_signal=$(jq -r -j '.analysis.exit_signal // false' "$analysis_file")
if [[ "$has_permission_denials" != "true" && "$has_progress_tracking_mismatch" != "true" && "$exit_signal" == "true" ]]; 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 -j '.loop_number' "$analysis_file")
local exit_sig=$(jq -r -j '.analysis.exit_signal' "$analysis_file")
local format_conf=$(jq -r -j '.analysis.format_confidence // 0' "$analysis_file")
local confidence=$(jq -r -j '.analysis.confidence_score' "$analysis_file")
local test_only=$(jq -r -j '.analysis.is_test_only' "$analysis_file")
local files_changed=$(jq -r -j '.analysis.files_modified' "$analysis_file")
local summary=$(jq -r -j '.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}Parse quality:${NC} $format_conf%"
echo -e "${YELLOW}Completion:${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
get_session_file_age_seconds() {
if [[ ! -f "$SESSION_FILE" ]]; then
echo "-1"
return 1
fi
local now
now=$(get_epoch_seconds)
local file_time=""
if file_time=$(stat -c %Y "$SESSION_FILE" 2>/dev/null); then
:
elif file_time=$(stat -f %m "$SESSION_FILE" 2>/dev/null); then
:
else
echo "-1"
return 1
fi
echo $((now - file_time))
}
read_session_id_from_file() {
if [[ ! -f "$SESSION_FILE" ]]; then
echo ""
return 1
fi
local raw_content
raw_content=$(cat "$SESSION_FILE" 2>/dev/null)
if [[ -z "$raw_content" ]]; then
echo ""
return 1
fi
local session_id=""
if echo "$raw_content" | jq -e . >/dev/null 2>&1; then
session_id=$(echo "$raw_content" | jq -r -j '.session_id // .sessionId // ""' 2>/dev/null)
else
session_id=$(printf '%s' "$raw_content" | tr -d '\r' | head -n 1)
fi
echo "$session_id"
[[ -n "$session_id" && "$session_id" != "null" ]]
}
# 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
# Persist the session as a raw ID so the main loop can resume it directly.
printf '%s\n' "$session_id" > "$SESSION_FILE"
return 0
}
# Get the last stored session ID
# Returns: session ID string or empty if not found
get_last_session_id() {
read_session_id_from_file || true
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
local session_id
session_id=$(read_session_id_from_file) || {
echo "false"
return 1
}
# Support legacy JSON session files that still carry a timestamp.
local timestamp=""
if jq -e . "$SESSION_FILE" >/dev/null 2>&1; then
timestamp=$(jq -r -j '.timestamp // ""' "$SESSION_FILE" 2>/dev/null)
fi
local age=0
if [[ -n "$timestamp" && "$timestamp" != "null" ]]; then
# Calculate session age using date utilities
local now
now=$(get_epoch_seconds)
local session_time
session_time=$(parse_iso_to_epoch "$timestamp")
# If parse_iso_to_epoch fell back to current epoch, session_time ≈ now → age ≈ 0.
# That's a safe default: treat unparseable timestamps as fresh rather than expired.
age=$((now - session_time))
else
age=$(get_session_file_age_seconds) || {
echo "false"
return 1
}
fi
# 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 count_json_documents
export -f normalize_cli_array_response
export -f normalize_codex_jsonl_response
export -f normalize_opencode_jsonl_response
export -f normalize_cursor_stream_json_response
export -f is_codex_jsonl_output
export -f is_opencode_jsonl_output
export -f is_cursor_stream_json_output
export -f normalize_json_output
export -f extract_session_id_from_output
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