bmalph 2.7.3 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,8 +27,158 @@ NO_WORK_PATTERNS=("nothing to do" "no changes" "already implemented" "up to date
27
27
  # JSON OUTPUT FORMAT DETECTION AND PARSING
28
28
  # =============================================================================
29
29
 
30
+ # Windows jq.exe handles workspace-relative paths more reliably than POSIX
31
+ # absolute temp paths like /tmp/... when invoked from Git Bash.
32
+ create_jq_temp_file() {
33
+ mktemp "./.response_analyzer.XXXXXX"
34
+ }
35
+
36
+ # Count parseable top-level JSON documents in an output file.
37
+ count_json_documents() {
38
+ local output_file=$1
39
+
40
+ if [[ ! -f "$output_file" ]] || [[ ! -s "$output_file" ]]; then
41
+ echo "0"
42
+ return 1
43
+ fi
44
+
45
+ jq -n -j 'reduce inputs as $item (0; . + 1)' < "$output_file" 2>/dev/null
46
+ }
47
+
48
+ # Normalize a Claude CLI array response into a single object file.
49
+ normalize_cli_array_response() {
50
+ local output_file=$1
51
+ local normalized_file=$2
52
+
53
+ # Extract the "result" type message from the array (usually the last entry)
54
+ # This contains: result, session_id, is_error, duration_ms, etc.
55
+ local result_obj=$(jq '[.[] | select(.type == "result")] | .[-1] // {}' "$output_file" 2>/dev/null)
56
+
57
+ # Guard against empty result_obj if jq fails (review fix: Macroscope)
58
+ [[ -z "$result_obj" ]] && result_obj="{}"
59
+
60
+ # Extract session_id from init message as fallback
61
+ 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')
62
+
63
+ # Prioritize result object's own session_id, then fall back to init message (review fix: CodeRabbit)
64
+ # This prevents session ID loss when arrays lack an init message with session_id
65
+ local effective_session_id
66
+ effective_session_id=$(echo "$result_obj" | jq -r -j '.sessionId // .session_id // empty' 2>/dev/null)
67
+ if [[ -z "$effective_session_id" || "$effective_session_id" == "null" ]]; then
68
+ effective_session_id="$init_session_id"
69
+ fi
70
+
71
+ # Build normalized object merging result with effective session_id
72
+ if [[ -n "$effective_session_id" && "$effective_session_id" != "null" ]]; then
73
+ echo "$result_obj" | jq --arg sid "$effective_session_id" '. + {sessionId: $sid} | del(.session_id)' > "$normalized_file"
74
+ else
75
+ echo "$result_obj" | jq 'del(.session_id)' > "$normalized_file"
76
+ fi
77
+ }
78
+
79
+ # Normalize Codex JSONL event output into the object shape expected downstream.
80
+ normalize_codex_jsonl_response() {
81
+ local output_file=$1
82
+ local normalized_file=$2
83
+
84
+ jq -rs '
85
+ def agent_text($item):
86
+ $item.text // (
87
+ [($item.content // [])[]? | select(.type == "output_text") | .text]
88
+ | join("\n")
89
+ ) // "";
90
+
91
+ (map(select(.type == "item.completed" and .item.type == "agent_message")) | last | .item // {}) as $agent_message
92
+ | {
93
+ result: agent_text($agent_message),
94
+ sessionId: (map(select(.type == "thread.started") | .thread_id // empty) | first // ""),
95
+ metadata: {}
96
+ }
97
+ ' "$output_file" > "$normalized_file"
98
+ }
99
+
100
+ # Detect whether a multi-document stream matches Codex JSONL events.
101
+ is_codex_jsonl_output() {
102
+ local output_file=$1
103
+
104
+ jq -n -j '
105
+ reduce inputs as $item (
106
+ false;
107
+ . or (
108
+ $item.type == "thread.started" or
109
+ ($item.type == "item.completed" and ($item.item.type? != null))
110
+ )
111
+ )
112
+ ' < "$output_file" 2>/dev/null
113
+ }
114
+
115
+ # Normalize structured output to a single object when downstream parsing expects one.
116
+ normalize_json_output() {
117
+ local output_file=$1
118
+ local normalized_file=$2
119
+ local json_document_count
120
+
121
+ json_document_count=$(count_json_documents "$output_file") || return 1
122
+
123
+ if [[ "$json_document_count" -gt 1 ]]; then
124
+ local is_codex_jsonl
125
+ is_codex_jsonl=$(is_codex_jsonl_output "$output_file") || return 1
126
+
127
+ if [[ "$is_codex_jsonl" == "true" ]]; then
128
+ normalize_codex_jsonl_response "$output_file" "$normalized_file"
129
+ return $?
130
+ fi
131
+
132
+ return 1
133
+ fi
134
+
135
+ if jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
136
+ normalize_cli_array_response "$output_file" "$normalized_file"
137
+ return $?
138
+ fi
139
+
140
+ return 1
141
+ }
142
+
143
+ # Extract persisted session ID from any supported structured output.
144
+ extract_session_id_from_output() {
145
+ local output_file=$1
146
+ local normalized_file=""
147
+ local session_id=""
148
+ local json_document_count
149
+
150
+ if [[ ! -f "$output_file" ]] || [[ ! -s "$output_file" ]]; then
151
+ echo ""
152
+ return 1
153
+ fi
154
+
155
+ json_document_count=$(count_json_documents "$output_file") || {
156
+ echo ""
157
+ return 1
158
+ }
159
+
160
+ if [[ "$json_document_count" -gt 1 ]] || jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
161
+ normalized_file=$(create_jq_temp_file)
162
+ if ! normalize_json_output "$output_file" "$normalized_file"; then
163
+ rm -f "$normalized_file"
164
+ echo ""
165
+ return 1
166
+ fi
167
+ output_file="$normalized_file"
168
+ fi
169
+
170
+ session_id=$(jq -r '.sessionId // .metadata.session_id // .session_id // empty' "$output_file" 2>/dev/null | head -1 | tr -d '\r')
171
+
172
+ if [[ -n "$normalized_file" && -f "$normalized_file" ]]; then
173
+ rm -f "$normalized_file"
174
+ fi
175
+
176
+ echo "$session_id"
177
+ [[ -n "$session_id" && "$session_id" != "null" ]]
178
+ }
179
+
30
180
  # Detect output format (json or text)
31
- # Returns: "json" if valid JSON, "text" otherwise
181
+ # Returns: "json" for single-document JSON and newline-delimited JSON, "text" otherwise
32
182
  detect_output_format() {
33
183
  local output_file=$1
34
184
 
@@ -45,24 +195,48 @@ detect_output_format() {
45
195
  return
46
196
  fi
47
197
 
48
- # Validate as JSON using jq
49
- if jq empty "$output_file" 2>/dev/null; then
50
- echo "json"
51
- else
198
+ local json_document_count
199
+ json_document_count=$(count_json_documents "$output_file") || {
52
200
  echo "text"
201
+ return
202
+ }
203
+
204
+ if [[ "$json_document_count" -eq 1 ]]; then
205
+ echo "json"
206
+ return
207
+ fi
208
+
209
+ if [[ "$json_document_count" -gt 1 ]]; then
210
+ local is_codex_jsonl
211
+ is_codex_jsonl=$(is_codex_jsonl_output "$output_file") || {
212
+ echo "text"
213
+ return
214
+ }
215
+
216
+ if [[ "$is_codex_jsonl" == "true" ]]; then
217
+ echo "json"
218
+ else
219
+ echo "text"
220
+ fi
221
+ return
53
222
  fi
223
+
224
+ echo "text"
54
225
  }
55
226
 
56
227
  # Parse JSON response and extract structured fields
57
228
  # Creates .ralph/.json_parse_result with normalized analysis data
58
- # Supports THREE JSON formats:
229
+ # Supports FOUR JSON formats:
59
230
  # 1. Flat format: { status, exit_signal, work_type, files_modified, ... }
60
231
  # 2. Claude CLI object format: { result, sessionId, metadata: { files_changed, has_errors, completion_status, ... } }
61
232
  # 3. Claude CLI array format: [ {type: "system", ...}, {type: "assistant", ...}, {type: "result", ...} ]
233
+ # 4. Codex JSONL format: {"type":"thread.started",...}\n{"type":"item.completed","item":{...}}
62
234
  parse_json_response() {
63
235
  local output_file=$1
64
236
  local result_file="${2:-$RALPH_DIR/.json_parse_result}"
65
237
  local normalized_file=""
238
+ local json_document_count=""
239
+ local response_shape="object"
66
240
 
67
241
  if [[ ! -f "$output_file" ]]; then
68
242
  echo "ERROR: Output file not found: $output_file" >&2
@@ -70,71 +244,59 @@ parse_json_response() {
70
244
  fi
71
245
 
72
246
  # Validate JSON first
73
- if ! jq empty "$output_file" 2>/dev/null; then
247
+ json_document_count=$(count_json_documents "$output_file") || {
74
248
  echo "ERROR: Invalid JSON in output file" >&2
75
249
  return 1
76
- fi
77
-
78
- # Check if JSON is an array (Claude CLI array format)
79
- # Claude CLI outputs: [{type: "system", ...}, {type: "assistant", ...}, {type: "result", ...}]
80
- if jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
81
- normalized_file=$(mktemp)
82
-
83
- # Extract the "result" type message from the array (usually the last entry)
84
- # This contains: result, session_id, is_error, duration_ms, etc.
85
- local result_obj=$(jq '[.[] | select(.type == "result")] | .[-1] // {}' "$output_file" 2>/dev/null)
86
-
87
- # Guard against empty result_obj if jq fails (review fix: Macroscope)
88
- [[ -z "$result_obj" ]] && result_obj="{}"
250
+ }
89
251
 
90
- # Extract session_id from init message as fallback
91
- local init_session_id=$(jq -r '.[] | select(.type == "system" and .subtype == "init") | .session_id // empty' "$output_file" 2>/dev/null | head -1)
92
-
93
- # Prioritize result object's own session_id, then fall back to init message (review fix: CodeRabbit)
94
- # This prevents session ID loss when arrays lack an init message with session_id
95
- local effective_session_id
96
- effective_session_id=$(echo "$result_obj" | jq -r '.sessionId // .session_id // empty' 2>/dev/null)
97
- if [[ -z "$effective_session_id" || "$effective_session_id" == "null" ]]; then
98
- effective_session_id="$init_session_id"
99
- fi
100
-
101
- # Build normalized object merging result with effective session_id
102
- if [[ -n "$effective_session_id" && "$effective_session_id" != "null" ]]; then
103
- echo "$result_obj" | jq --arg sid "$effective_session_id" '. + {sessionId: $sid} | del(.session_id)' > "$normalized_file"
252
+ # Normalize multi-document JSONL and array responses to a single object.
253
+ if [[ "$json_document_count" -gt 1 ]] || jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
254
+ if [[ "$json_document_count" -gt 1 ]]; then
255
+ response_shape="jsonl"
104
256
  else
105
- echo "$result_obj" | jq 'del(.session_id)' > "$normalized_file"
257
+ response_shape="array"
258
+ fi
259
+ normalized_file=$(create_jq_temp_file)
260
+ if ! normalize_json_output "$output_file" "$normalized_file"; then
261
+ rm -f "$normalized_file"
262
+ echo "ERROR: Failed to normalize JSON output" >&2
263
+ return 1
106
264
  fi
107
265
 
108
266
  # Use normalized file for subsequent parsing
109
267
  output_file="$normalized_file"
268
+
269
+ if [[ "$response_shape" == "jsonl" ]]; then
270
+ response_shape="codex_jsonl"
271
+ fi
110
272
  fi
111
273
 
112
274
  # Detect JSON format by checking for Claude CLI fields
113
- local has_result_field=$(jq -r 'has("result")' "$output_file" 2>/dev/null)
275
+ local has_result_field=$(jq -r -j 'has("result")' "$output_file" 2>/dev/null)
114
276
 
115
277
  # Extract fields - support both flat format and Claude CLI format
116
278
  # Priority: Claude CLI fields first, then flat format fields
117
279
 
118
280
  # Status: from flat format OR derived from metadata.completion_status
119
- local status=$(jq -r '.status // "UNKNOWN"' "$output_file" 2>/dev/null)
120
- local completion_status=$(jq -r '.metadata.completion_status // ""' "$output_file" 2>/dev/null)
281
+ local status=$(jq -r -j '.status // "UNKNOWN"' "$output_file" 2>/dev/null)
282
+ local completion_status=$(jq -r -j '.metadata.completion_status // ""' "$output_file" 2>/dev/null)
121
283
  if [[ "$completion_status" == "complete" || "$completion_status" == "COMPLETE" ]]; then
122
284
  status="COMPLETE"
123
285
  fi
124
286
 
125
287
  # Exit signal: from flat format OR derived from completion_status
126
288
  # Track whether EXIT_SIGNAL was explicitly provided (vs inferred from STATUS)
127
- local exit_signal=$(jq -r '.exit_signal // false' "$output_file" 2>/dev/null)
128
- local explicit_exit_signal_found=$(jq -r 'has("exit_signal")' "$output_file" 2>/dev/null)
289
+ local exit_signal=$(jq -r -j '.exit_signal // false' "$output_file" 2>/dev/null)
290
+ local explicit_exit_signal_found=$(jq -r -j 'has("exit_signal")' "$output_file" 2>/dev/null)
129
291
 
130
292
  # Bug #1 Fix: If exit_signal is still false, check for RALPH_STATUS block in .result field
131
293
  # Claude CLI JSON format embeds the RALPH_STATUS block within the .result text field
132
294
  if [[ "$exit_signal" == "false" && "$has_result_field" == "true" ]]; then
133
- local result_text=$(jq -r '.result // ""' "$output_file" 2>/dev/null)
295
+ local result_text=$(jq -r -j '.result // ""' "$output_file" 2>/dev/null)
134
296
  if [[ -n "$result_text" ]] && echo "$result_text" | grep -q -- "---RALPH_STATUS---"; then
135
297
  # Extract EXIT_SIGNAL value from RALPH_STATUS block within result text
136
298
  local embedded_exit_sig
137
- embedded_exit_sig=$(echo "$result_text" | grep "EXIT_SIGNAL:" | cut -d: -f2 | xargs)
299
+ embedded_exit_sig=$(echo "$result_text" | grep "EXIT_SIGNAL:" | cut -d: -f2 | tr -d '\r' | xargs)
138
300
  if [[ -n "$embedded_exit_sig" ]]; then
139
301
  # Explicit EXIT_SIGNAL found in RALPH_STATUS block
140
302
  explicit_exit_signal_found="true"
@@ -149,7 +311,7 @@ parse_json_response() {
149
311
  # Also check STATUS field as fallback ONLY when EXIT_SIGNAL was not specified
150
312
  # This respects explicit EXIT_SIGNAL: false which means "task complete, continue working"
151
313
  local embedded_status
152
- embedded_status=$(echo "$result_text" | grep "STATUS:" | cut -d: -f2 | xargs)
314
+ embedded_status=$(echo "$result_text" | grep "STATUS:" | cut -d: -f2 | tr -d '\r' | xargs)
153
315
  if [[ "$embedded_status" == "COMPLETE" && "$explicit_exit_signal_found" != "true" ]]; then
154
316
  # STATUS: COMPLETE without any EXIT_SIGNAL field implies completion
155
317
  exit_signal="true"
@@ -159,40 +321,40 @@ parse_json_response() {
159
321
  fi
160
322
 
161
323
  # Work type: from flat format
162
- local work_type=$(jq -r '.work_type // "UNKNOWN"' "$output_file" 2>/dev/null)
324
+ local work_type=$(jq -r -j '.work_type // "UNKNOWN"' "$output_file" 2>/dev/null)
163
325
 
164
326
  # Files modified: from flat format OR from metadata.files_changed
165
- local files_modified=$(jq -r '.metadata.files_changed // .files_modified // 0' "$output_file" 2>/dev/null)
327
+ local files_modified=$(jq -r -j '.metadata.files_changed // .files_modified // 0' "$output_file" 2>/dev/null)
166
328
 
167
329
  # Error count: from flat format OR derived from metadata.has_errors
168
330
  # Note: When only has_errors=true is present (without explicit error_count),
169
331
  # we set error_count=1 as a minimum. This is defensive programming since
170
332
  # the stuck detection threshold is >5 errors, so 1 error won't trigger it.
171
333
  # Actual error count may be higher, but precise count isn't critical for our logic.
172
- local error_count=$(jq -r '.error_count // 0' "$output_file" 2>/dev/null)
173
- local has_errors=$(jq -r '.metadata.has_errors // false' "$output_file" 2>/dev/null)
334
+ local error_count=$(jq -r -j '.error_count // 0' "$output_file" 2>/dev/null)
335
+ local has_errors=$(jq -r -j '.metadata.has_errors // false' "$output_file" 2>/dev/null)
174
336
  if [[ "$has_errors" == "true" && "$error_count" == "0" ]]; then
175
337
  error_count=1 # At least one error if has_errors is true
176
338
  fi
177
339
 
178
340
  # Summary: from flat format OR from result field (Claude CLI format)
179
- local summary=$(jq -r '.result // .summary // ""' "$output_file" 2>/dev/null)
341
+ local summary=$(jq -r -j '.result // .summary // ""' "$output_file" 2>/dev/null)
180
342
 
181
343
  # Session ID: from Claude CLI format (sessionId) OR from metadata.session_id
182
- local session_id=$(jq -r '.sessionId // .metadata.session_id // ""' "$output_file" 2>/dev/null)
344
+ local session_id=$(jq -r -j '.sessionId // .metadata.session_id // ""' "$output_file" 2>/dev/null)
183
345
 
184
346
  # Loop number: from metadata
185
- local loop_number=$(jq -r '.metadata.loop_number // .loop_number // 0' "$output_file" 2>/dev/null)
347
+ local loop_number=$(jq -r -j '.metadata.loop_number // .loop_number // 0' "$output_file" 2>/dev/null)
186
348
 
187
349
  # Confidence: from flat format
188
- local confidence=$(jq -r '.confidence // 0' "$output_file" 2>/dev/null)
350
+ local confidence=$(jq -r -j '.confidence // 0' "$output_file" 2>/dev/null)
189
351
 
190
352
  # Progress indicators: from Claude CLI metadata (optional)
191
- local progress_count=$(jq -r '.metadata.progress_indicators | if . then length else 0 end' "$output_file" 2>/dev/null)
353
+ local progress_count=$(jq -r -j '.metadata.progress_indicators | if . then length else 0 end' "$output_file" 2>/dev/null)
192
354
 
193
355
  # Permission denials: from Claude Code output (Issue #101)
194
356
  # When Claude Code is denied permission to run commands, it outputs a permission_denials array
195
- local permission_denial_count=$(jq -r '.permission_denials | if . then length else 0 end' "$output_file" 2>/dev/null)
357
+ local permission_denial_count=$(jq -r -j '.permission_denials | if . then length else 0 end' "$output_file" 2>/dev/null)
196
358
  permission_denial_count=$((permission_denial_count + 0)) # Ensure integer
197
359
 
198
360
  local has_permission_denials="false"
@@ -206,7 +368,27 @@ parse_json_response() {
206
368
  # while Bash denial shows "Bash(git commit -m ...)" with truncated command
207
369
  local denied_commands_json="[]"
208
370
  if [[ $permission_denial_count -gt 0 ]]; then
209
- denied_commands_json=$(jq -r '[.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 "[]")
371
+ 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 "[]")
372
+ fi
373
+
374
+ # Apply completion heuristics to normalized summary text when explicit structured
375
+ # completion markers are absent. This keeps JSONL analysis aligned with text mode.
376
+ local summary_has_completion_keyword="false"
377
+ local summary_has_no_work_pattern="false"
378
+ if [[ "$response_shape" == "codex_jsonl" && "$explicit_exit_signal_found" != "true" && -n "$summary" ]]; then
379
+ for keyword in "${COMPLETION_KEYWORDS[@]}"; do
380
+ if echo "$summary" | grep -qi "$keyword"; then
381
+ summary_has_completion_keyword="true"
382
+ break
383
+ fi
384
+ done
385
+
386
+ for pattern in "${NO_WORK_PATTERNS[@]}"; do
387
+ if echo "$summary" | grep -qi "$pattern"; then
388
+ summary_has_no_work_pattern="true"
389
+ break
390
+ fi
391
+ done
210
392
  fi
211
393
 
212
394
  # Normalize values
@@ -215,7 +397,7 @@ parse_json_response() {
215
397
  if [[ "$explicit_exit_signal_found" == "true" ]]; then
216
398
  # Respect explicit EXIT_SIGNAL value (already set above)
217
399
  [[ "$exit_signal" == "true" ]] && exit_signal="true" || exit_signal="false"
218
- elif [[ "$exit_signal" == "true" || "$status" == "COMPLETE" || "$completion_status" == "complete" || "$completion_status" == "COMPLETE" ]]; then
400
+ elif [[ "$exit_signal" == "true" || "$status" == "COMPLETE" || "$completion_status" == "complete" || "$completion_status" == "COMPLETE" || "$summary_has_completion_keyword" == "true" || "$summary_has_no_work_pattern" == "true" ]]; then
219
401
  exit_signal="true"
220
402
  else
221
403
  exit_signal="false"
@@ -242,7 +424,11 @@ parse_json_response() {
242
424
 
243
425
  # Calculate has_completion_signal
244
426
  local has_completion_signal="false"
245
- if [[ "$status" == "COMPLETE" || "$exit_signal" == "true" ]]; then
427
+ if [[ "$explicit_exit_signal_found" == "true" ]]; then
428
+ if [[ "$exit_signal" == "true" ]]; then
429
+ has_completion_signal="true"
430
+ fi
431
+ elif [[ "$status" == "COMPLETE" || "$exit_signal" == "true" || "$summary_has_completion_keyword" == "true" || "$summary_has_no_work_pattern" == "true" ]]; then
246
432
  has_completion_signal="true"
247
433
  fi
248
434
 
@@ -327,24 +513,26 @@ analyze_response() {
327
513
 
328
514
  # Detect output format and try JSON parsing first
329
515
  local output_format=$(detect_output_format "$output_file")
516
+ local json_parse_result_file=""
330
517
 
331
518
  if [[ "$output_format" == "json" ]]; then
332
519
  # Try JSON parsing
333
- if parse_json_response "$output_file" "$RALPH_DIR/.json_parse_result" 2>/dev/null; then
520
+ json_parse_result_file=$(create_jq_temp_file)
521
+ if parse_json_response "$output_file" "$json_parse_result_file" 2>/dev/null; then
334
522
  # Extract values from JSON parse result
335
- has_completion_signal=$(jq -r '.has_completion_signal' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "false")
336
- exit_signal=$(jq -r '.exit_signal' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "false")
337
- is_test_only=$(jq -r '.is_test_only' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "false")
338
- is_stuck=$(jq -r '.is_stuck' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "false")
339
- work_summary=$(jq -r '.summary' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "")
340
- files_modified=$(jq -r '.files_modified' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "0")
341
- local json_confidence=$(jq -r '.confidence' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "0")
342
- local session_id=$(jq -r '.session_id' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "")
523
+ has_completion_signal=$(jq -r -j '.has_completion_signal' "$json_parse_result_file" 2>/dev/null || echo "false")
524
+ exit_signal=$(jq -r -j '.exit_signal' "$json_parse_result_file" 2>/dev/null || echo "false")
525
+ is_test_only=$(jq -r -j '.is_test_only' "$json_parse_result_file" 2>/dev/null || echo "false")
526
+ is_stuck=$(jq -r -j '.is_stuck' "$json_parse_result_file" 2>/dev/null || echo "false")
527
+ work_summary=$(jq -r -j '.summary' "$json_parse_result_file" 2>/dev/null || echo "")
528
+ files_modified=$(jq -r -j '.files_modified' "$json_parse_result_file" 2>/dev/null || echo "0")
529
+ local json_confidence=$(jq -r -j '.confidence' "$json_parse_result_file" 2>/dev/null || echo "0")
530
+ local session_id=$(jq -r -j '.session_id' "$json_parse_result_file" 2>/dev/null || echo "")
343
531
 
344
532
  # Extract permission denial fields (Issue #101)
345
- local has_permission_denials=$(jq -r '.has_permission_denials' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "false")
346
- local permission_denial_count=$(jq -r '.permission_denial_count' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "0")
347
- local denied_commands_json=$(jq -r '.denied_commands' $RALPH_DIR/.json_parse_result 2>/dev/null || echo "[]")
533
+ local has_permission_denials=$(jq -r -j '.has_permission_denials' "$json_parse_result_file" 2>/dev/null || echo "false")
534
+ local permission_denial_count=$(jq -r -j '.permission_denial_count' "$json_parse_result_file" 2>/dev/null || echo "0")
535
+ local denied_commands_json=$(jq -r -j '.denied_commands' "$json_parse_result_file" 2>/dev/null || echo "[]")
348
536
 
349
537
  # Persist session ID if present (for session continuity across loop iterations)
350
538
  if [[ -n "$session_id" && "$session_id" != "null" ]]; then
@@ -435,9 +623,10 @@ analyze_response() {
435
623
  denied_commands: $denied_commands
436
624
  }
437
625
  }' > "$analysis_result_file"
438
- rm -f "$RALPH_DIR/.json_parse_result"
626
+ rm -f "$json_parse_result_file"
439
627
  return 0
440
628
  fi
629
+ rm -f "$json_parse_result_file"
441
630
  # If JSON parsing failed, fall through to text parsing
442
631
  fi
443
632
 
@@ -450,8 +639,8 @@ analyze_response() {
450
639
  # 1. Check for explicit structured output (if Claude follows schema)
451
640
  if grep -q -- "---RALPH_STATUS---" "$output_file"; then
452
641
  # Parse structured output
453
- local status=$(grep "STATUS:" "$output_file" | cut -d: -f2 | xargs)
454
- local exit_sig=$(grep "EXIT_SIGNAL:" "$output_file" | cut -d: -f2 | xargs)
642
+ local status=$(grep "STATUS:" "$output_file" | cut -d: -f2 | tr -d '\r' | xargs)
643
+ local exit_sig=$(grep "EXIT_SIGNAL:" "$output_file" | cut -d: -f2 | tr -d '\r' | xargs)
455
644
 
456
645
  # If EXIT_SIGNAL is explicitly provided, respect it
457
646
  if [[ -n "$exit_sig" ]]; then
@@ -588,6 +777,12 @@ analyze_response() {
588
777
  fi
589
778
  fi
590
779
 
780
+ # Explicit EXIT_SIGNAL=false means "continue working", so completion
781
+ # heuristics must not register a done signal.
782
+ if [[ "$explicit_exit_signal_found" == "true" && "$exit_signal" == "false" ]]; then
783
+ has_completion_signal=false
784
+ fi
785
+
591
786
  # 9. Determine exit signal based on confidence (heuristic)
592
787
  # IMPORTANT: Only apply heuristics if no explicit EXIT_SIGNAL was found in RALPH_STATUS
593
788
  # Claude's explicit intent takes precedence over natural language pattern matching
@@ -650,10 +845,10 @@ update_exit_signals() {
650
845
  fi
651
846
 
652
847
  # Read analysis results
653
- local is_test_only=$(jq -r '.analysis.is_test_only' "$analysis_file")
654
- local has_completion_signal=$(jq -r '.analysis.has_completion_signal' "$analysis_file")
655
- local loop_number=$(jq -r '.loop_number' "$analysis_file")
656
- local has_progress=$(jq -r '.analysis.has_progress' "$analysis_file")
848
+ local is_test_only=$(jq -r -j '.analysis.is_test_only' "$analysis_file")
849
+ local has_completion_signal=$(jq -r -j '.analysis.has_completion_signal' "$analysis_file")
850
+ local loop_number=$(jq -r -j '.loop_number' "$analysis_file")
851
+ local has_progress=$(jq -r -j '.analysis.has_progress' "$analysis_file")
657
852
 
658
853
  # Read current exit signals
659
854
  local signals=$(cat "$exit_signals_file" 2>/dev/null || echo '{"test_only_loops": [], "done_signals": [], "completion_indicators": []}')
@@ -677,7 +872,7 @@ update_exit_signals() {
677
872
  # Note: Previously used confidence >= 60, but JSON mode always has confidence >= 70
678
873
  # due to deterministic scoring (+50 for JSON format, +20 for result field).
679
874
  # This caused premature exits after 5 loops. Now we respect Claude's explicit intent.
680
- local exit_signal=$(jq -r '.analysis.exit_signal // false' "$analysis_file")
875
+ local exit_signal=$(jq -r -j '.analysis.exit_signal // false' "$analysis_file")
681
876
  if [[ "$exit_signal" == "true" ]]; then
682
877
  signals=$(echo "$signals" | jq ".completion_indicators += [$loop_number]")
683
878
  fi
@@ -701,12 +896,12 @@ log_analysis_summary() {
701
896
  return 1
702
897
  fi
703
898
 
704
- local loop=$(jq -r '.loop_number' "$analysis_file")
705
- local exit_sig=$(jq -r '.analysis.exit_signal' "$analysis_file")
706
- local confidence=$(jq -r '.analysis.confidence_score' "$analysis_file")
707
- local test_only=$(jq -r '.analysis.is_test_only' "$analysis_file")
708
- local files_changed=$(jq -r '.analysis.files_modified' "$analysis_file")
709
- local summary=$(jq -r '.analysis.work_summary' "$analysis_file")
899
+ local loop=$(jq -r -j '.loop_number' "$analysis_file")
900
+ local exit_sig=$(jq -r -j '.analysis.exit_signal' "$analysis_file")
901
+ local confidence=$(jq -r -j '.analysis.confidence_score' "$analysis_file")
902
+ local test_only=$(jq -r -j '.analysis.is_test_only' "$analysis_file")
903
+ local files_changed=$(jq -r -j '.analysis.files_modified' "$analysis_file")
904
+ local summary=$(jq -r -j '.analysis.work_summary' "$analysis_file")
710
905
 
711
906
  echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
712
907
  echo -e "${BLUE}║ Response Analysis - Loop #$loop ║${NC}"
@@ -777,6 +972,52 @@ SESSION_FILE="$RALPH_DIR/.claude_session_id"
777
972
  # Session expiration time in seconds (24 hours)
778
973
  SESSION_EXPIRATION_SECONDS=86400
779
974
 
975
+ get_session_file_age_seconds() {
976
+ if [[ ! -f "$SESSION_FILE" ]]; then
977
+ echo "-1"
978
+ return 1
979
+ fi
980
+
981
+ local now
982
+ now=$(get_epoch_seconds)
983
+ local file_time=""
984
+
985
+ if file_time=$(stat -c %Y "$SESSION_FILE" 2>/dev/null); then
986
+ :
987
+ elif file_time=$(stat -f %m "$SESSION_FILE" 2>/dev/null); then
988
+ :
989
+ else
990
+ echo "-1"
991
+ return 1
992
+ fi
993
+
994
+ echo $((now - file_time))
995
+ }
996
+
997
+ read_session_id_from_file() {
998
+ if [[ ! -f "$SESSION_FILE" ]]; then
999
+ echo ""
1000
+ return 1
1001
+ fi
1002
+
1003
+ local raw_content
1004
+ raw_content=$(cat "$SESSION_FILE" 2>/dev/null)
1005
+ if [[ -z "$raw_content" ]]; then
1006
+ echo ""
1007
+ return 1
1008
+ fi
1009
+
1010
+ local session_id=""
1011
+ if echo "$raw_content" | jq -e . >/dev/null 2>&1; then
1012
+ session_id=$(echo "$raw_content" | jq -r -j '.session_id // .sessionId // ""' 2>/dev/null)
1013
+ else
1014
+ session_id=$(printf '%s' "$raw_content" | tr -d '\r' | head -n 1)
1015
+ fi
1016
+
1017
+ echo "$session_id"
1018
+ [[ -n "$session_id" && "$session_id" != "null" ]]
1019
+ }
1020
+
780
1021
  # Store session ID to file with timestamp
781
1022
  # Usage: store_session_id "session-uuid-123"
782
1023
  store_session_id() {
@@ -786,14 +1027,8 @@ store_session_id() {
786
1027
  return 1
787
1028
  fi
788
1029
 
789
- # Write session with timestamp using jq for safe JSON construction
790
- jq -n \
791
- --arg session_id "$session_id" \
792
- --arg timestamp "$(get_iso_timestamp)" \
793
- '{
794
- session_id: $session_id,
795
- timestamp: $timestamp
796
- }' > "$SESSION_FILE"
1030
+ # Persist the session as a raw ID so the main loop can resume it directly.
1031
+ printf '%s\n' "$session_id" > "$SESSION_FILE"
797
1032
 
798
1033
  return 0
799
1034
  }
@@ -801,14 +1036,7 @@ store_session_id() {
801
1036
  # Get the last stored session ID
802
1037
  # Returns: session ID string or empty if not found
803
1038
  get_last_session_id() {
804
- if [[ ! -f "$SESSION_FILE" ]]; then
805
- echo ""
806
- return 0
807
- fi
808
-
809
- # Extract session_id from JSON file
810
- local session_id=$(jq -r '.session_id // ""' "$SESSION_FILE" 2>/dev/null)
811
- echo "$session_id"
1039
+ read_session_id_from_file || true
812
1040
  return 0
813
1041
  }
814
1042
 
@@ -820,23 +1048,35 @@ should_resume_session() {
820
1048
  return 1
821
1049
  fi
822
1050
 
823
- # Get session timestamp
824
- local timestamp=$(jq -r '.timestamp // ""' "$SESSION_FILE" 2>/dev/null)
825
-
826
- if [[ -z "$timestamp" ]]; then
1051
+ local session_id
1052
+ session_id=$(read_session_id_from_file) || {
827
1053
  echo "false"
828
1054
  return 1
829
- fi
1055
+ }
830
1056
 
831
- # Calculate session age using date utilities
832
- local now
833
- now=$(get_epoch_seconds)
834
- local session_time
835
- session_time=$(parse_iso_to_epoch "$timestamp")
1057
+ # Support legacy JSON session files that still carry a timestamp.
1058
+ local timestamp=""
1059
+ if jq -e . "$SESSION_FILE" >/dev/null 2>&1; then
1060
+ timestamp=$(jq -r -j '.timestamp // ""' "$SESSION_FILE" 2>/dev/null)
1061
+ fi
836
1062
 
837
- # If parse_iso_to_epoch fell back to current epoch, session_time ≈ now → age0.
838
- # That's a safe default: treat unparseable timestamps as fresh rather than expired.
839
- local age=$((now - session_time))
1063
+ local age=0
1064
+ if [[ -n "$timestamp" && "$timestamp" != "null" ]]; then
1065
+ # Calculate session age using date utilities
1066
+ local now
1067
+ now=$(get_epoch_seconds)
1068
+ local session_time
1069
+ session_time=$(parse_iso_to_epoch "$timestamp")
1070
+
1071
+ # If parse_iso_to_epoch fell back to current epoch, session_time ≈ now → age ≈ 0.
1072
+ # That's a safe default: treat unparseable timestamps as fresh rather than expired.
1073
+ age=$((now - session_time))
1074
+ else
1075
+ age=$(get_session_file_age_seconds) || {
1076
+ echo "false"
1077
+ return 1
1078
+ }
1079
+ fi
840
1080
 
841
1081
  # Check if session is still valid (less than expiration time)
842
1082
  if [[ $age -lt $SESSION_EXPIRATION_SECONDS ]]; then
@@ -850,6 +1090,12 @@ should_resume_session() {
850
1090
 
851
1091
  # Export functions for use in ralph_loop.sh
852
1092
  export -f detect_output_format
1093
+ export -f count_json_documents
1094
+ export -f normalize_cli_array_response
1095
+ export -f normalize_codex_jsonl_response
1096
+ export -f is_codex_jsonl_output
1097
+ export -f normalize_json_output
1098
+ export -f extract_session_id_from_output
853
1099
  export -f parse_json_response
854
1100
  export -f analyze_response
855
1101
  export -f update_exit_signals