bmalph 2.7.3 → 2.7.5

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.
Files changed (41) hide show
  1. package/README.md +68 -30
  2. package/dist/commands/doctor-checks.js +5 -4
  3. package/dist/commands/doctor-checks.js.map +1 -1
  4. package/dist/commands/run.js +4 -0
  5. package/dist/commands/run.js.map +1 -1
  6. package/dist/commands/status.js +12 -3
  7. package/dist/commands/status.js.map +1 -1
  8. package/dist/installer.js +101 -48
  9. package/dist/installer.js.map +1 -1
  10. package/dist/platform/cursor-runtime-checks.js +81 -0
  11. package/dist/platform/cursor-runtime-checks.js.map +1 -0
  12. package/dist/platform/cursor.js +4 -3
  13. package/dist/platform/cursor.js.map +1 -1
  14. package/dist/platform/detect.js +28 -5
  15. package/dist/platform/detect.js.map +1 -1
  16. package/dist/platform/instructions-snippet.js +18 -0
  17. package/dist/platform/instructions-snippet.js.map +1 -1
  18. package/dist/platform/resolve.js +23 -5
  19. package/dist/platform/resolve.js.map +1 -1
  20. package/dist/run/ralph-process.js +84 -15
  21. package/dist/run/ralph-process.js.map +1 -1
  22. package/dist/transition/artifact-scan.js +15 -3
  23. package/dist/transition/artifact-scan.js.map +1 -1
  24. package/dist/transition/fix-plan.js +4 -4
  25. package/dist/transition/fix-plan.js.map +1 -1
  26. package/dist/transition/orchestration.js +45 -13
  27. package/dist/transition/orchestration.js.map +1 -1
  28. package/dist/transition/preflight.js +9 -1
  29. package/dist/transition/preflight.js.map +1 -1
  30. package/dist/transition/story-id.js +46 -0
  31. package/dist/transition/story-id.js.map +1 -0
  32. package/dist/transition/story-parsing.js +3 -4
  33. package/dist/transition/story-parsing.js.map +1 -1
  34. package/package.json +1 -1
  35. package/ralph/RALPH-REFERENCE.md +27 -3
  36. package/ralph/drivers/claude-code.sh +44 -2
  37. package/ralph/drivers/codex.sh +10 -1
  38. package/ralph/drivers/copilot.sh +5 -0
  39. package/ralph/drivers/cursor.sh +50 -29
  40. package/ralph/lib/response_analyzer.sh +440 -111
  41. package/ralph/ralph_loop.sh +73 -61
@@ -27,8 +27,215 @@ 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
+ # Normalize Cursor stream-json event output into the object shape expected downstream.
101
+ normalize_cursor_stream_json_response() {
102
+ local output_file=$1
103
+ local normalized_file=$2
104
+
105
+ jq -rs '
106
+ def assistant_text($item):
107
+ [($item.message.content // [])[]? | select(.type == "text") | .text]
108
+ | join("\n");
109
+
110
+ (map(select(.type == "result")) | last // {}) as $result_event
111
+ | {
112
+ result: (
113
+ $result_event.result
114
+ // (
115
+ map(select(.type == "assistant"))
116
+ | map(assistant_text(.))
117
+ | map(select(length > 0))
118
+ | join("\n")
119
+ )
120
+ ),
121
+ sessionId: (
122
+ $result_event.session_id
123
+ // (map(select(.type == "system" and .subtype == "init") | .session_id // empty) | first)
124
+ // ""
125
+ ),
126
+ metadata: {}
127
+ }
128
+ ' "$output_file" > "$normalized_file"
129
+ }
130
+
131
+ # Detect whether a multi-document stream matches Codex JSONL events.
132
+ is_codex_jsonl_output() {
133
+ local output_file=$1
134
+
135
+ jq -n -j '
136
+ reduce inputs as $item (
137
+ false;
138
+ . or (
139
+ $item.type == "thread.started" or
140
+ ($item.type == "item.completed" and ($item.item.type? != null))
141
+ )
142
+ )
143
+ ' < "$output_file" 2>/dev/null
144
+ }
145
+
146
+ # Detect whether a multi-document stream matches Cursor stream-json events.
147
+ is_cursor_stream_json_output() {
148
+ local output_file=$1
149
+
150
+ jq -n -j '
151
+ reduce inputs as $item (
152
+ false;
153
+ . or (
154
+ $item.type == "system" or
155
+ $item.type == "user" or
156
+ $item.type == "assistant" or
157
+ $item.type == "tool_call" or
158
+ $item.type == "result"
159
+ )
160
+ )
161
+ ' < "$output_file" 2>/dev/null
162
+ }
163
+
164
+ # Normalize structured output to a single object when downstream parsing expects one.
165
+ normalize_json_output() {
166
+ local output_file=$1
167
+ local normalized_file=$2
168
+ local json_document_count
169
+
170
+ json_document_count=$(count_json_documents "$output_file") || return 1
171
+
172
+ if [[ "$json_document_count" -gt 1 ]]; then
173
+ local is_codex_jsonl
174
+ is_codex_jsonl=$(is_codex_jsonl_output "$output_file") || return 1
175
+
176
+ if [[ "$is_codex_jsonl" == "true" ]]; then
177
+ normalize_codex_jsonl_response "$output_file" "$normalized_file"
178
+ return $?
179
+ fi
180
+
181
+ local is_cursor_stream_json
182
+ is_cursor_stream_json=$(is_cursor_stream_json_output "$output_file") || return 1
183
+
184
+ if [[ "$is_cursor_stream_json" == "true" ]]; then
185
+ normalize_cursor_stream_json_response "$output_file" "$normalized_file"
186
+ return $?
187
+ fi
188
+
189
+ return 1
190
+ fi
191
+
192
+ if jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
193
+ normalize_cli_array_response "$output_file" "$normalized_file"
194
+ return $?
195
+ fi
196
+
197
+ return 1
198
+ }
199
+
200
+ # Extract persisted session ID from any supported structured output.
201
+ extract_session_id_from_output() {
202
+ local output_file=$1
203
+ local normalized_file=""
204
+ local session_id=""
205
+ local json_document_count
206
+
207
+ if [[ ! -f "$output_file" ]] || [[ ! -s "$output_file" ]]; then
208
+ echo ""
209
+ return 1
210
+ fi
211
+
212
+ json_document_count=$(count_json_documents "$output_file") || {
213
+ echo ""
214
+ return 1
215
+ }
216
+
217
+ if [[ "$json_document_count" -gt 1 ]] || jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
218
+ normalized_file=$(create_jq_temp_file)
219
+ if ! normalize_json_output "$output_file" "$normalized_file"; then
220
+ rm -f "$normalized_file"
221
+ echo ""
222
+ return 1
223
+ fi
224
+ output_file="$normalized_file"
225
+ fi
226
+
227
+ session_id=$(jq -r '.sessionId // .metadata.session_id // .session_id // empty' "$output_file" 2>/dev/null | head -1 | tr -d '\r')
228
+
229
+ if [[ -n "$normalized_file" && -f "$normalized_file" ]]; then
230
+ rm -f "$normalized_file"
231
+ fi
232
+
233
+ echo "$session_id"
234
+ [[ -n "$session_id" && "$session_id" != "null" ]]
235
+ }
236
+
30
237
  # Detect output format (json or text)
31
- # Returns: "json" if valid JSON, "text" otherwise
238
+ # Returns: "json" for single-document JSON and newline-delimited JSON, "text" otherwise
32
239
  detect_output_format() {
33
240
  local output_file=$1
34
241
 
@@ -45,24 +252,68 @@ detect_output_format() {
45
252
  return
46
253
  fi
47
254
 
48
- # Validate as JSON using jq
49
- if jq empty "$output_file" 2>/dev/null; then
50
- echo "json"
51
- else
255
+ local json_document_count
256
+ json_document_count=$(count_json_documents "$output_file") || {
52
257
  echo "text"
258
+ return
259
+ }
260
+
261
+ if [[ "$json_document_count" -eq 1 ]]; then
262
+ echo "json"
263
+ return
53
264
  fi
265
+
266
+ if [[ "$json_document_count" -gt 1 ]]; then
267
+ local is_codex_jsonl
268
+ is_codex_jsonl=$(is_codex_jsonl_output "$output_file") || {
269
+ echo "text"
270
+ return
271
+ }
272
+
273
+ if [[ "$is_codex_jsonl" == "true" ]]; then
274
+ echo "json"
275
+ return
276
+ fi
277
+
278
+ local is_cursor_stream_json
279
+ is_cursor_stream_json=$(is_cursor_stream_json_output "$output_file") || {
280
+ echo "text"
281
+ return
282
+ }
283
+
284
+ if [[ "$is_cursor_stream_json" == "true" ]]; then
285
+ echo "json"
286
+ return
287
+ fi
288
+ fi
289
+
290
+ echo "text"
291
+ }
292
+
293
+ trim_shell_whitespace() {
294
+ local value="${1//$'\r'/}"
295
+
296
+ value="${value#"${value%%[![:space:]]*}"}"
297
+ value="${value%"${value##*[![:space:]]}"}"
298
+
299
+ printf '%s' "$value"
54
300
  }
55
301
 
56
302
  # Parse JSON response and extract structured fields
57
303
  # Creates .ralph/.json_parse_result with normalized analysis data
58
- # Supports THREE JSON formats:
304
+ # Supports FIVE JSON formats:
59
305
  # 1. Flat format: { status, exit_signal, work_type, files_modified, ... }
60
306
  # 2. Claude CLI object format: { result, sessionId, metadata: { files_changed, has_errors, completion_status, ... } }
61
307
  # 3. Claude CLI array format: [ {type: "system", ...}, {type: "assistant", ...}, {type: "result", ...} ]
308
+ # 4. Codex JSONL format: {"type":"thread.started",...}\n{"type":"item.completed","item":{...}}
309
+ # 5. Cursor stream-json format: {"type":"assistant",...}\n{"type":"result",...}
62
310
  parse_json_response() {
63
311
  local output_file=$1
64
312
  local result_file="${2:-$RALPH_DIR/.json_parse_result}"
313
+ local original_output_file=$output_file
65
314
  local normalized_file=""
315
+ local json_document_count=""
316
+ local response_shape="object"
66
317
 
67
318
  if [[ ! -f "$output_file" ]]; then
68
319
  echo "ERROR: Output file not found: $output_file" >&2
@@ -70,71 +321,63 @@ parse_json_response() {
70
321
  fi
71
322
 
72
323
  # Validate JSON first
73
- if ! jq empty "$output_file" 2>/dev/null; then
324
+ json_document_count=$(count_json_documents "$output_file") || {
74
325
  echo "ERROR: Invalid JSON in output file" >&2
75
326
  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)
327
+ }
86
328
 
87
- # Guard against empty result_obj if jq fails (review fix: Macroscope)
88
- [[ -z "$result_obj" ]] && result_obj="{}"
89
-
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"
329
+ # Normalize multi-document JSONL and array responses to a single object.
330
+ if [[ "$json_document_count" -gt 1 ]] || jq -e 'type == "array"' "$output_file" >/dev/null 2>&1; then
331
+ if [[ "$json_document_count" -gt 1 ]]; then
332
+ response_shape="jsonl"
104
333
  else
105
- echo "$result_obj" | jq 'del(.session_id)' > "$normalized_file"
334
+ response_shape="array"
335
+ fi
336
+ normalized_file=$(create_jq_temp_file)
337
+ if ! normalize_json_output "$output_file" "$normalized_file"; then
338
+ rm -f "$normalized_file"
339
+ echo "ERROR: Failed to normalize JSON output" >&2
340
+ return 1
106
341
  fi
107
342
 
108
343
  # Use normalized file for subsequent parsing
109
344
  output_file="$normalized_file"
345
+
346
+ if [[ "$response_shape" == "jsonl" ]]; then
347
+ if is_codex_jsonl_output "$original_output_file" >/dev/null 2>&1; then
348
+ response_shape="codex_jsonl"
349
+ else
350
+ response_shape="cursor_stream_jsonl"
351
+ fi
352
+ fi
110
353
  fi
111
354
 
112
355
  # Detect JSON format by checking for Claude CLI fields
113
- local has_result_field=$(jq -r 'has("result")' "$output_file" 2>/dev/null)
356
+ local has_result_field=$(jq -r -j 'has("result")' "$output_file" 2>/dev/null)
114
357
 
115
358
  # Extract fields - support both flat format and Claude CLI format
116
359
  # Priority: Claude CLI fields first, then flat format fields
117
360
 
118
361
  # 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)
362
+ local status=$(jq -r -j '.status // "UNKNOWN"' "$output_file" 2>/dev/null)
363
+ local completion_status=$(jq -r -j '.metadata.completion_status // ""' "$output_file" 2>/dev/null)
121
364
  if [[ "$completion_status" == "complete" || "$completion_status" == "COMPLETE" ]]; then
122
365
  status="COMPLETE"
123
366
  fi
124
367
 
125
368
  # Exit signal: from flat format OR derived from completion_status
126
369
  # 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)
370
+ local exit_signal=$(jq -r -j '.exit_signal // false' "$output_file" 2>/dev/null)
371
+ local explicit_exit_signal_found=$(jq -r -j 'has("exit_signal")' "$output_file" 2>/dev/null)
129
372
 
130
373
  # Bug #1 Fix: If exit_signal is still false, check for RALPH_STATUS block in .result field
131
374
  # Claude CLI JSON format embeds the RALPH_STATUS block within the .result text field
132
375
  if [[ "$exit_signal" == "false" && "$has_result_field" == "true" ]]; then
133
- local result_text=$(jq -r '.result // ""' "$output_file" 2>/dev/null)
376
+ local result_text=$(jq -r -j '.result // ""' "$output_file" 2>/dev/null)
134
377
  if [[ -n "$result_text" ]] && echo "$result_text" | grep -q -- "---RALPH_STATUS---"; then
135
378
  # Extract EXIT_SIGNAL value from RALPH_STATUS block within result text
136
379
  local embedded_exit_sig
137
- embedded_exit_sig=$(echo "$result_text" | grep "EXIT_SIGNAL:" | cut -d: -f2 | xargs)
380
+ embedded_exit_sig=$(trim_shell_whitespace "$(printf '%s\n' "$result_text" | grep "EXIT_SIGNAL:" | cut -d: -f2)")
138
381
  if [[ -n "$embedded_exit_sig" ]]; then
139
382
  # Explicit EXIT_SIGNAL found in RALPH_STATUS block
140
383
  explicit_exit_signal_found="true"
@@ -149,7 +392,7 @@ parse_json_response() {
149
392
  # Also check STATUS field as fallback ONLY when EXIT_SIGNAL was not specified
150
393
  # This respects explicit EXIT_SIGNAL: false which means "task complete, continue working"
151
394
  local embedded_status
152
- embedded_status=$(echo "$result_text" | grep "STATUS:" | cut -d: -f2 | xargs)
395
+ embedded_status=$(trim_shell_whitespace "$(printf '%s\n' "$result_text" | grep "STATUS:" | cut -d: -f2)")
153
396
  if [[ "$embedded_status" == "COMPLETE" && "$explicit_exit_signal_found" != "true" ]]; then
154
397
  # STATUS: COMPLETE without any EXIT_SIGNAL field implies completion
155
398
  exit_signal="true"
@@ -159,40 +402,40 @@ parse_json_response() {
159
402
  fi
160
403
 
161
404
  # Work type: from flat format
162
- local work_type=$(jq -r '.work_type // "UNKNOWN"' "$output_file" 2>/dev/null)
405
+ local work_type=$(jq -r -j '.work_type // "UNKNOWN"' "$output_file" 2>/dev/null)
163
406
 
164
407
  # 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)
408
+ local files_modified=$(jq -r -j '.metadata.files_changed // .files_modified // 0' "$output_file" 2>/dev/null)
166
409
 
167
410
  # Error count: from flat format OR derived from metadata.has_errors
168
411
  # Note: When only has_errors=true is present (without explicit error_count),
169
412
  # we set error_count=1 as a minimum. This is defensive programming since
170
413
  # the stuck detection threshold is >5 errors, so 1 error won't trigger it.
171
414
  # 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)
415
+ local error_count=$(jq -r -j '.error_count // 0' "$output_file" 2>/dev/null)
416
+ local has_errors=$(jq -r -j '.metadata.has_errors // false' "$output_file" 2>/dev/null)
174
417
  if [[ "$has_errors" == "true" && "$error_count" == "0" ]]; then
175
418
  error_count=1 # At least one error if has_errors is true
176
419
  fi
177
420
 
178
421
  # Summary: from flat format OR from result field (Claude CLI format)
179
- local summary=$(jq -r '.result // .summary // ""' "$output_file" 2>/dev/null)
422
+ local summary=$(jq -r -j '.result // .summary // ""' "$output_file" 2>/dev/null)
180
423
 
181
424
  # 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)
425
+ local session_id=$(jq -r -j '.sessionId // .metadata.session_id // .session_id // ""' "$output_file" 2>/dev/null)
183
426
 
184
427
  # Loop number: from metadata
185
- local loop_number=$(jq -r '.metadata.loop_number // .loop_number // 0' "$output_file" 2>/dev/null)
428
+ local loop_number=$(jq -r -j '.metadata.loop_number // .loop_number // 0' "$output_file" 2>/dev/null)
186
429
 
187
430
  # Confidence: from flat format
188
- local confidence=$(jq -r '.confidence // 0' "$output_file" 2>/dev/null)
431
+ local confidence=$(jq -r -j '.confidence // 0' "$output_file" 2>/dev/null)
189
432
 
190
433
  # 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)
434
+ local progress_count=$(jq -r -j '.metadata.progress_indicators | if . then length else 0 end' "$output_file" 2>/dev/null)
192
435
 
193
436
  # Permission denials: from Claude Code output (Issue #101)
194
437
  # 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)
438
+ local permission_denial_count=$(jq -r -j '.permission_denials | if . then length else 0 end' "$output_file" 2>/dev/null)
196
439
  permission_denial_count=$((permission_denial_count + 0)) # Ensure integer
197
440
 
198
441
  local has_permission_denials="false"
@@ -206,7 +449,27 @@ parse_json_response() {
206
449
  # while Bash denial shows "Bash(git commit -m ...)" with truncated command
207
450
  local denied_commands_json="[]"
208
451
  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 "[]")
452
+ 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 "[]")
453
+ fi
454
+
455
+ # Apply completion heuristics to normalized summary text when explicit structured
456
+ # completion markers are absent. This keeps JSONL analysis aligned with text mode.
457
+ local summary_has_completion_keyword="false"
458
+ local summary_has_no_work_pattern="false"
459
+ if [[ "$response_shape" == "codex_jsonl" || "$response_shape" == "cursor_stream_jsonl" ]] && [[ "$explicit_exit_signal_found" != "true" && -n "$summary" ]]; then
460
+ for keyword in "${COMPLETION_KEYWORDS[@]}"; do
461
+ if echo "$summary" | grep -qi "$keyword"; then
462
+ summary_has_completion_keyword="true"
463
+ break
464
+ fi
465
+ done
466
+
467
+ for pattern in "${NO_WORK_PATTERNS[@]}"; do
468
+ if echo "$summary" | grep -qi "$pattern"; then
469
+ summary_has_no_work_pattern="true"
470
+ break
471
+ fi
472
+ done
210
473
  fi
211
474
 
212
475
  # Normalize values
@@ -215,7 +478,7 @@ parse_json_response() {
215
478
  if [[ "$explicit_exit_signal_found" == "true" ]]; then
216
479
  # Respect explicit EXIT_SIGNAL value (already set above)
217
480
  [[ "$exit_signal" == "true" ]] && exit_signal="true" || exit_signal="false"
218
- elif [[ "$exit_signal" == "true" || "$status" == "COMPLETE" || "$completion_status" == "complete" || "$completion_status" == "COMPLETE" ]]; then
481
+ 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
482
  exit_signal="true"
220
483
  else
221
484
  exit_signal="false"
@@ -242,7 +505,11 @@ parse_json_response() {
242
505
 
243
506
  # Calculate has_completion_signal
244
507
  local has_completion_signal="false"
245
- if [[ "$status" == "COMPLETE" || "$exit_signal" == "true" ]]; then
508
+ if [[ "$explicit_exit_signal_found" == "true" ]]; then
509
+ if [[ "$exit_signal" == "true" ]]; then
510
+ has_completion_signal="true"
511
+ fi
512
+ elif [[ "$status" == "COMPLETE" || "$exit_signal" == "true" || "$summary_has_completion_keyword" == "true" || "$summary_has_no_work_pattern" == "true" ]]; then
246
513
  has_completion_signal="true"
247
514
  fi
248
515
 
@@ -327,24 +594,26 @@ analyze_response() {
327
594
 
328
595
  # Detect output format and try JSON parsing first
329
596
  local output_format=$(detect_output_format "$output_file")
597
+ local json_parse_result_file=""
330
598
 
331
599
  if [[ "$output_format" == "json" ]]; then
332
600
  # Try JSON parsing
333
- if parse_json_response "$output_file" "$RALPH_DIR/.json_parse_result" 2>/dev/null; then
601
+ json_parse_result_file=$(create_jq_temp_file)
602
+ if parse_json_response "$output_file" "$json_parse_result_file" 2>/dev/null; then
334
603
  # 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 "")
604
+ has_completion_signal=$(jq -r -j '.has_completion_signal' "$json_parse_result_file" 2>/dev/null || echo "false")
605
+ exit_signal=$(jq -r -j '.exit_signal' "$json_parse_result_file" 2>/dev/null || echo "false")
606
+ is_test_only=$(jq -r -j '.is_test_only' "$json_parse_result_file" 2>/dev/null || echo "false")
607
+ is_stuck=$(jq -r -j '.is_stuck' "$json_parse_result_file" 2>/dev/null || echo "false")
608
+ work_summary=$(jq -r -j '.summary' "$json_parse_result_file" 2>/dev/null || echo "")
609
+ files_modified=$(jq -r -j '.files_modified' "$json_parse_result_file" 2>/dev/null || echo "0")
610
+ local json_confidence=$(jq -r -j '.confidence' "$json_parse_result_file" 2>/dev/null || echo "0")
611
+ local session_id=$(jq -r -j '.session_id' "$json_parse_result_file" 2>/dev/null || echo "")
343
612
 
344
613
  # 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 "[]")
614
+ local has_permission_denials=$(jq -r -j '.has_permission_denials' "$json_parse_result_file" 2>/dev/null || echo "false")
615
+ local permission_denial_count=$(jq -r -j '.permission_denial_count' "$json_parse_result_file" 2>/dev/null || echo "0")
616
+ local denied_commands_json=$(jq -r -j '.denied_commands' "$json_parse_result_file" 2>/dev/null || echo "[]")
348
617
 
349
618
  # Persist session ID if present (for session continuity across loop iterations)
350
619
  if [[ -n "$session_id" && "$session_id" != "null" ]]; then
@@ -435,9 +704,10 @@ analyze_response() {
435
704
  denied_commands: $denied_commands
436
705
  }
437
706
  }' > "$analysis_result_file"
438
- rm -f "$RALPH_DIR/.json_parse_result"
707
+ rm -f "$json_parse_result_file"
439
708
  return 0
440
709
  fi
710
+ rm -f "$json_parse_result_file"
441
711
  # If JSON parsing failed, fall through to text parsing
442
712
  fi
443
713
 
@@ -450,8 +720,8 @@ analyze_response() {
450
720
  # 1. Check for explicit structured output (if Claude follows schema)
451
721
  if grep -q -- "---RALPH_STATUS---" "$output_file"; then
452
722
  # 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)
723
+ local status=$(trim_shell_whitespace "$(grep "STATUS:" "$output_file" | cut -d: -f2)")
724
+ local exit_sig=$(trim_shell_whitespace "$(grep "EXIT_SIGNAL:" "$output_file" | cut -d: -f2)")
455
725
 
456
726
  # If EXIT_SIGNAL is explicitly provided, respect it
457
727
  if [[ -n "$exit_sig" ]]; then
@@ -588,6 +858,12 @@ analyze_response() {
588
858
  fi
589
859
  fi
590
860
 
861
+ # Explicit EXIT_SIGNAL=false means "continue working", so completion
862
+ # heuristics must not register a done signal.
863
+ if [[ "$explicit_exit_signal_found" == "true" && "$exit_signal" == "false" ]]; then
864
+ has_completion_signal=false
865
+ fi
866
+
591
867
  # 9. Determine exit signal based on confidence (heuristic)
592
868
  # IMPORTANT: Only apply heuristics if no explicit EXIT_SIGNAL was found in RALPH_STATUS
593
869
  # Claude's explicit intent takes precedence over natural language pattern matching
@@ -650,10 +926,10 @@ update_exit_signals() {
650
926
  fi
651
927
 
652
928
  # 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")
929
+ local is_test_only=$(jq -r -j '.analysis.is_test_only' "$analysis_file")
930
+ local has_completion_signal=$(jq -r -j '.analysis.has_completion_signal' "$analysis_file")
931
+ local loop_number=$(jq -r -j '.loop_number' "$analysis_file")
932
+ local has_progress=$(jq -r -j '.analysis.has_progress' "$analysis_file")
657
933
 
658
934
  # Read current exit signals
659
935
  local signals=$(cat "$exit_signals_file" 2>/dev/null || echo '{"test_only_loops": [], "done_signals": [], "completion_indicators": []}')
@@ -677,7 +953,7 @@ update_exit_signals() {
677
953
  # Note: Previously used confidence >= 60, but JSON mode always has confidence >= 70
678
954
  # due to deterministic scoring (+50 for JSON format, +20 for result field).
679
955
  # 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")
956
+ local exit_signal=$(jq -r -j '.analysis.exit_signal // false' "$analysis_file")
681
957
  if [[ "$exit_signal" == "true" ]]; then
682
958
  signals=$(echo "$signals" | jq ".completion_indicators += [$loop_number]")
683
959
  fi
@@ -701,12 +977,12 @@ log_analysis_summary() {
701
977
  return 1
702
978
  fi
703
979
 
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")
980
+ local loop=$(jq -r -j '.loop_number' "$analysis_file")
981
+ local exit_sig=$(jq -r -j '.analysis.exit_signal' "$analysis_file")
982
+ local confidence=$(jq -r -j '.analysis.confidence_score' "$analysis_file")
983
+ local test_only=$(jq -r -j '.analysis.is_test_only' "$analysis_file")
984
+ local files_changed=$(jq -r -j '.analysis.files_modified' "$analysis_file")
985
+ local summary=$(jq -r -j '.analysis.work_summary' "$analysis_file")
710
986
 
711
987
  echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
712
988
  echo -e "${BLUE}║ Response Analysis - Loop #$loop ║${NC}"
@@ -777,6 +1053,52 @@ SESSION_FILE="$RALPH_DIR/.claude_session_id"
777
1053
  # Session expiration time in seconds (24 hours)
778
1054
  SESSION_EXPIRATION_SECONDS=86400
779
1055
 
1056
+ get_session_file_age_seconds() {
1057
+ if [[ ! -f "$SESSION_FILE" ]]; then
1058
+ echo "-1"
1059
+ return 1
1060
+ fi
1061
+
1062
+ local now
1063
+ now=$(get_epoch_seconds)
1064
+ local file_time=""
1065
+
1066
+ if file_time=$(stat -c %Y "$SESSION_FILE" 2>/dev/null); then
1067
+ :
1068
+ elif file_time=$(stat -f %m "$SESSION_FILE" 2>/dev/null); then
1069
+ :
1070
+ else
1071
+ echo "-1"
1072
+ return 1
1073
+ fi
1074
+
1075
+ echo $((now - file_time))
1076
+ }
1077
+
1078
+ read_session_id_from_file() {
1079
+ if [[ ! -f "$SESSION_FILE" ]]; then
1080
+ echo ""
1081
+ return 1
1082
+ fi
1083
+
1084
+ local raw_content
1085
+ raw_content=$(cat "$SESSION_FILE" 2>/dev/null)
1086
+ if [[ -z "$raw_content" ]]; then
1087
+ echo ""
1088
+ return 1
1089
+ fi
1090
+
1091
+ local session_id=""
1092
+ if echo "$raw_content" | jq -e . >/dev/null 2>&1; then
1093
+ session_id=$(echo "$raw_content" | jq -r -j '.session_id // .sessionId // ""' 2>/dev/null)
1094
+ else
1095
+ session_id=$(printf '%s' "$raw_content" | tr -d '\r' | head -n 1)
1096
+ fi
1097
+
1098
+ echo "$session_id"
1099
+ [[ -n "$session_id" && "$session_id" != "null" ]]
1100
+ }
1101
+
780
1102
  # Store session ID to file with timestamp
781
1103
  # Usage: store_session_id "session-uuid-123"
782
1104
  store_session_id() {
@@ -786,14 +1108,8 @@ store_session_id() {
786
1108
  return 1
787
1109
  fi
788
1110
 
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"
1111
+ # Persist the session as a raw ID so the main loop can resume it directly.
1112
+ printf '%s\n' "$session_id" > "$SESSION_FILE"
797
1113
 
798
1114
  return 0
799
1115
  }
@@ -801,14 +1117,7 @@ store_session_id() {
801
1117
  # Get the last stored session ID
802
1118
  # Returns: session ID string or empty if not found
803
1119
  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"
1120
+ read_session_id_from_file || true
812
1121
  return 0
813
1122
  }
814
1123
 
@@ -820,23 +1129,35 @@ should_resume_session() {
820
1129
  return 1
821
1130
  fi
822
1131
 
823
- # Get session timestamp
824
- local timestamp=$(jq -r '.timestamp // ""' "$SESSION_FILE" 2>/dev/null)
825
-
826
- if [[ -z "$timestamp" ]]; then
1132
+ local session_id
1133
+ session_id=$(read_session_id_from_file) || {
827
1134
  echo "false"
828
1135
  return 1
829
- fi
1136
+ }
830
1137
 
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")
1138
+ # Support legacy JSON session files that still carry a timestamp.
1139
+ local timestamp=""
1140
+ if jq -e . "$SESSION_FILE" >/dev/null 2>&1; then
1141
+ timestamp=$(jq -r -j '.timestamp // ""' "$SESSION_FILE" 2>/dev/null)
1142
+ fi
836
1143
 
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))
1144
+ local age=0
1145
+ if [[ -n "$timestamp" && "$timestamp" != "null" ]]; then
1146
+ # Calculate session age using date utilities
1147
+ local now
1148
+ now=$(get_epoch_seconds)
1149
+ local session_time
1150
+ session_time=$(parse_iso_to_epoch "$timestamp")
1151
+
1152
+ # If parse_iso_to_epoch fell back to current epoch, session_time ≈ now → age ≈ 0.
1153
+ # That's a safe default: treat unparseable timestamps as fresh rather than expired.
1154
+ age=$((now - session_time))
1155
+ else
1156
+ age=$(get_session_file_age_seconds) || {
1157
+ echo "false"
1158
+ return 1
1159
+ }
1160
+ fi
840
1161
 
841
1162
  # Check if session is still valid (less than expiration time)
842
1163
  if [[ $age -lt $SESSION_EXPIRATION_SECONDS ]]; then
@@ -850,6 +1171,14 @@ should_resume_session() {
850
1171
 
851
1172
  # Export functions for use in ralph_loop.sh
852
1173
  export -f detect_output_format
1174
+ export -f count_json_documents
1175
+ export -f normalize_cli_array_response
1176
+ export -f normalize_codex_jsonl_response
1177
+ export -f normalize_cursor_stream_json_response
1178
+ export -f is_codex_jsonl_output
1179
+ export -f is_cursor_stream_json_output
1180
+ export -f normalize_json_output
1181
+ export -f extract_session_id_from_output
853
1182
  export -f parse_json_response
854
1183
  export -f analyze_response
855
1184
  export -f update_exit_signals