bmalph 2.7.4 → 2.7.6

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 (73) hide show
  1. package/README.md +87 -34
  2. package/dist/commands/doctor-checks.js +5 -4
  3. package/dist/commands/doctor-checks.js.map +1 -1
  4. package/dist/commands/doctor-runtime-checks.js +104 -86
  5. package/dist/commands/doctor-runtime-checks.js.map +1 -1
  6. package/dist/commands/run.js +4 -0
  7. package/dist/commands/run.js.map +1 -1
  8. package/dist/commands/status.js +12 -3
  9. package/dist/commands/status.js.map +1 -1
  10. package/dist/installer/bmad-assets.js +182 -0
  11. package/dist/installer/bmad-assets.js.map +1 -0
  12. package/dist/installer/commands.js +324 -0
  13. package/dist/installer/commands.js.map +1 -0
  14. package/dist/installer/install.js +42 -0
  15. package/dist/installer/install.js.map +1 -0
  16. package/dist/installer/metadata.js +56 -0
  17. package/dist/installer/metadata.js.map +1 -0
  18. package/dist/installer/project-files.js +169 -0
  19. package/dist/installer/project-files.js.map +1 -0
  20. package/dist/installer/ralph-assets.js +91 -0
  21. package/dist/installer/ralph-assets.js.map +1 -0
  22. package/dist/installer/template-files.js +168 -0
  23. package/dist/installer/template-files.js.map +1 -0
  24. package/dist/installer/types.js +2 -0
  25. package/dist/installer/types.js.map +1 -0
  26. package/dist/installer.js +5 -790
  27. package/dist/installer.js.map +1 -1
  28. package/dist/platform/cursor-runtime-checks.js +81 -0
  29. package/dist/platform/cursor-runtime-checks.js.map +1 -0
  30. package/dist/platform/cursor.js +4 -3
  31. package/dist/platform/cursor.js.map +1 -1
  32. package/dist/platform/detect.js +28 -5
  33. package/dist/platform/detect.js.map +1 -1
  34. package/dist/platform/instructions-snippet.js +18 -0
  35. package/dist/platform/instructions-snippet.js.map +1 -1
  36. package/dist/platform/resolve.js +23 -5
  37. package/dist/platform/resolve.js.map +1 -1
  38. package/dist/run/ralph-process.js +84 -15
  39. package/dist/run/ralph-process.js.map +1 -1
  40. package/dist/transition/artifact-loading.js +91 -0
  41. package/dist/transition/artifact-loading.js.map +1 -0
  42. package/dist/transition/artifact-scan.js +15 -3
  43. package/dist/transition/artifact-scan.js.map +1 -1
  44. package/dist/transition/context-output.js +85 -0
  45. package/dist/transition/context-output.js.map +1 -0
  46. package/dist/transition/fix-plan-sync.js +119 -0
  47. package/dist/transition/fix-plan-sync.js.map +1 -0
  48. package/dist/transition/orchestration.js +25 -362
  49. package/dist/transition/orchestration.js.map +1 -1
  50. package/dist/transition/specs-sync.js +78 -2
  51. package/dist/transition/specs-sync.js.map +1 -1
  52. package/dist/utils/ralph-runtime-state.js +222 -0
  53. package/dist/utils/ralph-runtime-state.js.map +1 -0
  54. package/dist/utils/state.js +17 -16
  55. package/dist/utils/state.js.map +1 -1
  56. package/dist/utils/validate.js +16 -0
  57. package/dist/utils/validate.js.map +1 -1
  58. package/dist/watch/renderer.js +48 -6
  59. package/dist/watch/renderer.js.map +1 -1
  60. package/dist/watch/state-reader.js +79 -44
  61. package/dist/watch/state-reader.js.map +1 -1
  62. package/package.json +1 -1
  63. package/ralph/RALPH-REFERENCE.md +60 -16
  64. package/ralph/drivers/claude-code.sh +25 -0
  65. package/ralph/drivers/codex.sh +11 -0
  66. package/ralph/drivers/copilot.sh +11 -0
  67. package/ralph/drivers/cursor.sh +58 -29
  68. package/ralph/lib/circuit_breaker.sh +3 -3
  69. package/ralph/lib/date_utils.sh +28 -9
  70. package/ralph/lib/response_analyzer.sh +220 -17
  71. package/ralph/ralph_loop.sh +464 -121
  72. package/ralph/templates/PROMPT.md +5 -0
  73. package/ralph/templates/ralphrc.template +14 -4
@@ -1,11 +1,7 @@
1
1
  #!/bin/bash
2
- # Cursor CLI driver for Ralph (EXPERIMENTAL)
3
- # Provides platform-specific CLI invocation logic for Cursor CLI.
4
- #
5
- # Known limitations:
6
- # - CLI is in beta — binary name and flags may change
7
- # - NDJSON stream format assumes {type: "text", content: "..."} events
8
- # - Session continuity is disabled until Cursor exposes a stable capturable session ID
2
+ # Cursor CLI driver for Ralph
3
+ # Uses the documented cursor-agent contract for background execution and
4
+ # switches to stream-json only for live display paths.
9
5
 
10
6
  driver_name() {
11
7
  echo "cursor"
@@ -42,7 +38,6 @@ driver_check_available() {
42
38
  command -v "$cli_binary" &>/dev/null
43
39
  }
44
40
 
45
- # Cursor CLI tool names
46
41
  driver_valid_tools() {
47
42
  VALID_TOOL_PATTERNS=(
48
43
  "file_edit"
@@ -53,10 +48,17 @@ driver_valid_tools() {
53
48
  )
54
49
  }
55
50
 
56
- # Build Cursor CLI command
57
- # Context is prepended to the prompt (same pattern as Codex/Copilot drivers).
58
- # Uses --print for headless mode, --force for autonomous execution,
59
- # --output-format stream-json for NDJSON streaming.
51
+ driver_supports_tool_allowlist() {
52
+ return 1
53
+ }
54
+
55
+ driver_permission_denial_help() {
56
+ echo " - $DRIVER_DISPLAY_NAME uses its native permission model."
57
+ echo " - ALLOWED_TOOLS in $RALPHRC_FILE is ignored for this driver."
58
+ echo " - Ralph already runs Cursor with --force."
59
+ echo " - Review Cursor permissions or approval settings, then restart the loop."
60
+ }
61
+
60
62
  driver_build_command() {
61
63
  local prompt_file=$1
62
64
  local loop_context=$2
@@ -76,16 +78,12 @@ driver_build_command() {
76
78
  CLAUDE_CMD_ARGS+=("$cli_binary")
77
79
  fi
78
80
 
79
- # Headless mode
80
- CLAUDE_CMD_ARGS+=("--print")
81
-
82
- # Autonomous execution
83
- CLAUDE_CMD_ARGS+=("--force")
81
+ CLAUDE_CMD_ARGS+=("-p" "--force" "--output-format" "json")
84
82
 
85
- # NDJSON streaming output
86
- CLAUDE_CMD_ARGS+=("--output-format" "stream-json")
83
+ if [[ "$CLAUDE_USE_CONTINUE" == "true" && -n "$session_id" ]]; then
84
+ CLAUDE_CMD_ARGS+=("--resume" "$session_id")
85
+ fi
87
86
 
88
- # Build prompt with context prepended
89
87
  local prompt_content
90
88
  if driver_running_on_windows; then
91
89
  prompt_content=$(driver_build_windows_bootstrap_prompt "$loop_context" "$prompt_file")
@@ -102,20 +100,43 @@ $prompt_content"
102
100
  }
103
101
 
104
102
  driver_supports_sessions() {
105
- return 1 # false — session IDs are not capturable from current NDJSON output
103
+ return 0
106
104
  }
107
105
 
108
106
  driver_supports_live_output() {
109
- return 0 # true
107
+ return 0
110
108
  }
111
109
 
112
110
  driver_prepare_live_command() {
113
- LIVE_CMD_ARGS=("${CLAUDE_CMD_ARGS[@]}")
111
+ LIVE_CMD_ARGS=()
112
+ local skip_next=false
113
+
114
+ for arg in "${CLAUDE_CMD_ARGS[@]}"; do
115
+ if [[ "$skip_next" == "true" ]]; then
116
+ LIVE_CMD_ARGS+=("stream-json")
117
+ skip_next=false
118
+ elif [[ "$arg" == "--output-format" ]]; then
119
+ LIVE_CMD_ARGS+=("$arg")
120
+ skip_next=true
121
+ else
122
+ LIVE_CMD_ARGS+=("$arg")
123
+ fi
124
+ done
125
+
126
+ if [[ "$skip_next" == "true" ]]; then
127
+ return 1
128
+ fi
114
129
  }
115
130
 
116
- # Cursor CLI outputs NDJSON events
117
131
  driver_stream_filter() {
118
- echo 'select(.type == "text") | .content // empty'
132
+ echo '
133
+ if .type == "assistant" then
134
+ [(.message.content[]? | select(.type == "text") | .text)] | join("\n")
135
+ elif .type == "tool_call" then
136
+ "\n\n⚡ [" + (.tool_call.name // .name // "tool_call") + "]\n"
137
+ else
138
+ empty
139
+ end'
119
140
  }
120
141
 
121
142
  driver_running_on_windows() {
@@ -208,10 +229,18 @@ driver_localappdata_cli_binary() {
208
229
  local_app_data=$(cygpath -u "$local_app_data")
209
230
  fi
210
231
 
211
- local candidate="$local_app_data/cursor-agent/agent.cmd"
212
- if [[ -f "$candidate" ]]; then
213
- echo "$candidate"
214
- fi
232
+ local candidates=(
233
+ "$local_app_data/cursor-agent/cursor-agent.cmd"
234
+ "$local_app_data/cursor-agent/agent.cmd"
235
+ )
236
+
237
+ local candidate
238
+ for candidate in "${candidates[@]}"; do
239
+ if [[ -f "$candidate" ]]; then
240
+ echo "$candidate"
241
+ return 0
242
+ fi
243
+ done
215
244
  }
216
245
 
217
246
  driver_wrapper_path() {
@@ -203,7 +203,7 @@ record_loop_result() {
203
203
  has_permission_denials=$(jq -r '.analysis.has_permission_denials // false' "$response_analysis_file" 2>/dev/null || echo "false")
204
204
  fi
205
205
 
206
- if [[ "$has_permission_denials" == "true" ]]; then
206
+ if [[ "${PERMISSION_DENIAL_MODE:-halt}" == "threshold" && "$has_permission_denials" == "true" ]]; then
207
207
  consecutive_permission_denials=$((consecutive_permission_denials + 1))
208
208
  else
209
209
  consecutive_permission_denials=0
@@ -248,7 +248,7 @@ record_loop_result() {
248
248
  # Permission denials take highest priority (Issue #101)
249
249
  if [[ $consecutive_permission_denials -ge $CB_PERMISSION_DENIAL_THRESHOLD ]]; then
250
250
  new_state="$CB_STATE_OPEN"
251
- reason="Permission denied in $consecutive_permission_denials consecutive loops - update ALLOWED_TOOLS in .ralphrc"
251
+ reason="Permission denied in $consecutive_permission_denials consecutive loops"
252
252
  elif [[ $consecutive_no_progress -ge $CB_NO_PROGRESS_THRESHOLD ]]; then
253
253
  new_state="$CB_STATE_OPEN"
254
254
  reason="No progress detected in $consecutive_no_progress consecutive loops"
@@ -266,7 +266,7 @@ record_loop_result() {
266
266
  # Permission denials take highest priority (Issue #101)
267
267
  if [[ $consecutive_permission_denials -ge $CB_PERMISSION_DENIAL_THRESHOLD ]]; then
268
268
  new_state="$CB_STATE_OPEN"
269
- reason="Permission denied in $consecutive_permission_denials consecutive loops - update ALLOWED_TOOLS in .ralphrc"
269
+ reason="Permission denied in $consecutive_permission_denials consecutive loops"
270
270
  elif [[ "$has_progress" == "true" ]]; then
271
271
  new_state="$CB_STATE_CLOSED"
272
272
  reason="Progress detected, circuit recovered"
@@ -49,35 +49,37 @@ get_epoch_seconds() {
49
49
  # Convert ISO 8601 timestamp to Unix epoch seconds
50
50
  # Input: ISO timestamp (e.g., "2025-01-15T10:30:00+00:00")
51
51
  # Returns: Unix epoch seconds on stdout
52
- # Falls back to current epoch on parse failure (safe default)
53
- parse_iso_to_epoch() {
52
+ # Returns non-zero on parse failure.
53
+ parse_iso_to_epoch_strict() {
54
54
  local iso_timestamp=$1
55
55
 
56
56
  if [[ -z "$iso_timestamp" || "$iso_timestamp" == "null" ]]; then
57
- date +%s
58
- return
57
+ return 1
59
58
  fi
60
59
 
60
+ local normalized_iso
61
+ normalized_iso=$(printf '%s' "$iso_timestamp" | sed -E 's/\.([0-9]+)(Z|[+-][0-9]{2}:[0-9]{2})$/\2/')
62
+
61
63
  # Try GNU date -d (Linux, macOS with Homebrew coreutils)
62
64
  local result
63
65
  if result=$(date -d "$iso_timestamp" +%s 2>/dev/null) && [[ "$result" =~ ^[0-9]+$ ]]; then
64
66
  echo "$result"
65
- return
67
+ return 0
66
68
  fi
67
69
 
68
70
  # Try BSD date -j (native macOS)
69
71
  # Normalize timezone for BSD parsing (Z → +0000, ±HH:MM → ±HHMM)
70
72
  local tz_fixed
71
- tz_fixed=$(echo "$iso_timestamp" | sed -E 's/Z$/+0000/; s/([+-][0-9]{2}):([0-9]{2})$/\1\2/')
73
+ tz_fixed=$(printf '%s' "$normalized_iso" | sed -E 's/Z$/+0000/; s/([+-][0-9]{2}):([0-9]{2})$/\1\2/')
72
74
  if result=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$tz_fixed" +%s 2>/dev/null) && [[ "$result" =~ ^[0-9]+$ ]]; then
73
75
  echo "$result"
74
- return
76
+ return 0
75
77
  fi
76
78
 
77
79
  # Fallback: manual epoch arithmetic from ISO components
78
80
  # Parse: YYYY-MM-DDTHH:MM:SS (ignore timezone, assume UTC)
79
81
  local year month day hour minute second
80
- if [[ "$iso_timestamp" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
82
+ if [[ "$normalized_iso" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
81
83
  year="${BASH_REMATCH[1]}"
82
84
  month="${BASH_REMATCH[2]}"
83
85
  day="${BASH_REMATCH[3]}"
@@ -88,10 +90,26 @@ parse_iso_to_epoch() {
88
90
  # Use date with explicit components if available
89
91
  if result=$(date -u -d "${year}-${month}-${day} ${hour}:${minute}:${second}" +%s 2>/dev/null) && [[ "$result" =~ ^[0-9]+$ ]]; then
90
92
  echo "$result"
91
- return
93
+ return 0
92
94
  fi
93
95
  fi
94
96
 
97
+ return 1
98
+ }
99
+
100
+ # Convert ISO 8601 timestamp to Unix epoch seconds
101
+ # Input: ISO timestamp (e.g., "2025-01-15T10:30:00+00:00")
102
+ # Returns: Unix epoch seconds on stdout
103
+ # Falls back to current epoch on parse failure (safe default)
104
+ parse_iso_to_epoch() {
105
+ local iso_timestamp=$1
106
+ local result
107
+
108
+ if result=$(parse_iso_to_epoch_strict "$iso_timestamp"); then
109
+ echo "$result"
110
+ return 0
111
+ fi
112
+
95
113
  # Ultimate fallback: return current epoch (safe default)
96
114
  date +%s
97
115
  }
@@ -101,4 +119,5 @@ export -f get_iso_timestamp
101
119
  export -f get_next_hour_time
102
120
  export -f get_basic_timestamp
103
121
  export -f get_epoch_seconds
122
+ export -f parse_iso_to_epoch_strict
104
123
  export -f parse_iso_to_epoch
@@ -22,6 +22,103 @@ RALPH_DIR="${RALPH_DIR:-.ralph}"
22
22
  COMPLETION_KEYWORDS=("done" "complete" "finished" "all tasks complete" "project complete" "ready for review")
23
23
  TEST_ONLY_PATTERNS=("npm test" "bats" "pytest" "jest" "cargo test" "go test" "running tests")
24
24
  NO_WORK_PATTERNS=("nothing to do" "no changes" "already implemented" "up to date")
25
+ PERMISSION_DENIAL_INLINE_PATTERNS=(
26
+ "requires approval before it can run"
27
+ "requires approval before it can proceed"
28
+ "not allowed to use tool"
29
+ "not permitted to use tool"
30
+ )
31
+
32
+ extract_permission_signal_text() {
33
+ local text=$1
34
+
35
+ if [[ -z "$text" ]]; then
36
+ echo ""
37
+ return 0
38
+ fi
39
+
40
+ # Only inspect the response preamble for tool refusals. Later paragraphs and
41
+ # copied logs often contain old permission errors that should not halt Ralph.
42
+ local signal_source="${text//$'\r'/}"
43
+ if [[ "$signal_source" == *"---RALPH_STATUS---"* ]]; then
44
+ signal_source="${signal_source%%---RALPH_STATUS---*}"
45
+ fi
46
+
47
+ local signal_text=""
48
+ local non_empty_lines=0
49
+ local trimmed=""
50
+ local line=""
51
+
52
+ while IFS= read -r line; do
53
+ trimmed="${line#"${line%%[![:space:]]*}"}"
54
+ trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
55
+
56
+ if [[ -z "$trimmed" ]]; then
57
+ if [[ $non_empty_lines -gt 0 ]]; then
58
+ break
59
+ fi
60
+ continue
61
+ fi
62
+
63
+ signal_text+="$trimmed"$'\n'
64
+ ((non_empty_lines += 1))
65
+ if [[ $non_empty_lines -ge 5 ]]; then
66
+ break
67
+ fi
68
+ done <<< "$signal_source"
69
+
70
+ printf '%s' "$signal_text"
71
+ }
72
+
73
+ permission_denial_line_matches() {
74
+ local normalized=$1
75
+
76
+ case "$normalized" in
77
+ permission\ denied:*|denied\ permission:*)
78
+ [[ "$normalized" == *approval* || "$normalized" == *tool* || "$normalized" == *command* || "$normalized" == *blocked* || "$normalized" == *"not allowed"* || "$normalized" == *"not permitted"* ]]
79
+ return
80
+ ;;
81
+ approval\ required:*)
82
+ [[ "$normalized" == *run* || "$normalized" == *proceed* || "$normalized" == *tool* || "$normalized" == *command* || "$normalized" == *blocked* ]]
83
+ return
84
+ ;;
85
+ esac
86
+
87
+ return 1
88
+ }
89
+
90
+ contains_permission_denial_signal() {
91
+ local signal_text=$1
92
+
93
+ if [[ -z "$signal_text" ]]; then
94
+ return 1
95
+ fi
96
+
97
+ local line
98
+ while IFS= read -r line; do
99
+ local trimmed="${line#"${line%%[![:space:]]*}"}"
100
+ local normalized="${trimmed,,}"
101
+
102
+ if permission_denial_line_matches "$normalized"; then
103
+ return 0
104
+ fi
105
+
106
+ local pattern
107
+ for pattern in "${PERMISSION_DENIAL_INLINE_PATTERNS[@]}"; do
108
+ if [[ "$normalized" == *"$pattern"* ]]; then
109
+ return 0
110
+ fi
111
+ done
112
+ done <<< "$signal_text"
113
+
114
+ return 1
115
+ }
116
+
117
+ contains_permission_denial_text() {
118
+ local signal_text
119
+ signal_text=$(extract_permission_signal_text "$1")
120
+ contains_permission_denial_signal "$signal_text"
121
+ }
25
122
 
26
123
  # =============================================================================
27
124
  # JSON OUTPUT FORMAT DETECTION AND PARSING
@@ -97,6 +194,37 @@ normalize_codex_jsonl_response() {
97
194
  ' "$output_file" > "$normalized_file"
98
195
  }
99
196
 
197
+ # Normalize Cursor stream-json event output into the object shape expected downstream.
198
+ normalize_cursor_stream_json_response() {
199
+ local output_file=$1
200
+ local normalized_file=$2
201
+
202
+ jq -rs '
203
+ def assistant_text($item):
204
+ [($item.message.content // [])[]? | select(.type == "text") | .text]
205
+ | join("\n");
206
+
207
+ (map(select(.type == "result")) | last // {}) as $result_event
208
+ | {
209
+ result: (
210
+ $result_event.result
211
+ // (
212
+ map(select(.type == "assistant"))
213
+ | map(assistant_text(.))
214
+ | map(select(length > 0))
215
+ | join("\n")
216
+ )
217
+ ),
218
+ sessionId: (
219
+ $result_event.session_id
220
+ // (map(select(.type == "system" and .subtype == "init") | .session_id // empty) | first)
221
+ // ""
222
+ ),
223
+ metadata: {}
224
+ }
225
+ ' "$output_file" > "$normalized_file"
226
+ }
227
+
100
228
  # Detect whether a multi-document stream matches Codex JSONL events.
101
229
  is_codex_jsonl_output() {
102
230
  local output_file=$1
@@ -112,6 +240,24 @@ is_codex_jsonl_output() {
112
240
  ' < "$output_file" 2>/dev/null
113
241
  }
114
242
 
243
+ # Detect whether a multi-document stream matches Cursor stream-json events.
244
+ is_cursor_stream_json_output() {
245
+ local output_file=$1
246
+
247
+ jq -n -j '
248
+ reduce inputs as $item (
249
+ false;
250
+ . or (
251
+ $item.type == "system" or
252
+ $item.type == "user" or
253
+ $item.type == "assistant" or
254
+ $item.type == "tool_call" or
255
+ $item.type == "result"
256
+ )
257
+ )
258
+ ' < "$output_file" 2>/dev/null
259
+ }
260
+
115
261
  # Normalize structured output to a single object when downstream parsing expects one.
116
262
  normalize_json_output() {
117
263
  local output_file=$1
@@ -129,6 +275,14 @@ normalize_json_output() {
129
275
  return $?
130
276
  fi
131
277
 
278
+ local is_cursor_stream_json
279
+ is_cursor_stream_json=$(is_cursor_stream_json_output "$output_file") || return 1
280
+
281
+ if [[ "$is_cursor_stream_json" == "true" ]]; then
282
+ normalize_cursor_stream_json_response "$output_file" "$normalized_file"
283
+ return $?
284
+ fi
285
+
132
286
  return 1
133
287
  fi
134
288
 
@@ -215,25 +369,45 @@ detect_output_format() {
215
369
 
216
370
  if [[ "$is_codex_jsonl" == "true" ]]; then
217
371
  echo "json"
218
- else
372
+ return
373
+ fi
374
+
375
+ local is_cursor_stream_json
376
+ is_cursor_stream_json=$(is_cursor_stream_json_output "$output_file") || {
219
377
  echo "text"
378
+ return
379
+ }
380
+
381
+ if [[ "$is_cursor_stream_json" == "true" ]]; then
382
+ echo "json"
383
+ return
220
384
  fi
221
- return
222
385
  fi
223
386
 
224
387
  echo "text"
225
388
  }
226
389
 
390
+ trim_shell_whitespace() {
391
+ local value="${1//$'\r'/}"
392
+
393
+ value="${value#"${value%%[![:space:]]*}"}"
394
+ value="${value%"${value##*[![:space:]]}"}"
395
+
396
+ printf '%s' "$value"
397
+ }
398
+
227
399
  # Parse JSON response and extract structured fields
228
400
  # Creates .ralph/.json_parse_result with normalized analysis data
229
- # Supports FOUR JSON formats:
401
+ # Supports FIVE JSON formats:
230
402
  # 1. Flat format: { status, exit_signal, work_type, files_modified, ... }
231
403
  # 2. Claude CLI object format: { result, sessionId, metadata: { files_changed, has_errors, completion_status, ... } }
232
404
  # 3. Claude CLI array format: [ {type: "system", ...}, {type: "assistant", ...}, {type: "result", ...} ]
233
405
  # 4. Codex JSONL format: {"type":"thread.started",...}\n{"type":"item.completed","item":{...}}
406
+ # 5. Cursor stream-json format: {"type":"assistant",...}\n{"type":"result",...}
234
407
  parse_json_response() {
235
408
  local output_file=$1
236
409
  local result_file="${2:-$RALPH_DIR/.json_parse_result}"
410
+ local original_output_file=$output_file
237
411
  local normalized_file=""
238
412
  local json_document_count=""
239
413
  local response_shape="object"
@@ -267,7 +441,11 @@ parse_json_response() {
267
441
  output_file="$normalized_file"
268
442
 
269
443
  if [[ "$response_shape" == "jsonl" ]]; then
270
- response_shape="codex_jsonl"
444
+ if is_codex_jsonl_output "$original_output_file" >/dev/null 2>&1; then
445
+ response_shape="codex_jsonl"
446
+ else
447
+ response_shape="cursor_stream_jsonl"
448
+ fi
271
449
  fi
272
450
  fi
273
451
 
@@ -296,7 +474,7 @@ parse_json_response() {
296
474
  if [[ -n "$result_text" ]] && echo "$result_text" | grep -q -- "---RALPH_STATUS---"; then
297
475
  # Extract EXIT_SIGNAL value from RALPH_STATUS block within result text
298
476
  local embedded_exit_sig
299
- embedded_exit_sig=$(echo "$result_text" | grep "EXIT_SIGNAL:" | cut -d: -f2 | tr -d '\r' | xargs)
477
+ embedded_exit_sig=$(trim_shell_whitespace "$(printf '%s\n' "$result_text" | grep "EXIT_SIGNAL:" | cut -d: -f2)")
300
478
  if [[ -n "$embedded_exit_sig" ]]; then
301
479
  # Explicit EXIT_SIGNAL found in RALPH_STATUS block
302
480
  explicit_exit_signal_found="true"
@@ -311,7 +489,7 @@ parse_json_response() {
311
489
  # Also check STATUS field as fallback ONLY when EXIT_SIGNAL was not specified
312
490
  # This respects explicit EXIT_SIGNAL: false which means "task complete, continue working"
313
491
  local embedded_status
314
- embedded_status=$(echo "$result_text" | grep "STATUS:" | cut -d: -f2 | tr -d '\r' | xargs)
492
+ embedded_status=$(trim_shell_whitespace "$(printf '%s\n' "$result_text" | grep "STATUS:" | cut -d: -f2)")
315
493
  if [[ "$embedded_status" == "COMPLETE" && "$explicit_exit_signal_found" != "true" ]]; then
316
494
  # STATUS: COMPLETE without any EXIT_SIGNAL field implies completion
317
495
  exit_signal="true"
@@ -341,7 +519,7 @@ parse_json_response() {
341
519
  local summary=$(jq -r -j '.result // .summary // ""' "$output_file" 2>/dev/null)
342
520
 
343
521
  # Session ID: from Claude CLI format (sessionId) OR from metadata.session_id
344
- local session_id=$(jq -r -j '.sessionId // .metadata.session_id // ""' "$output_file" 2>/dev/null)
522
+ local session_id=$(jq -r -j '.sessionId // .metadata.session_id // .session_id // ""' "$output_file" 2>/dev/null)
345
523
 
346
524
  # Loop number: from metadata
347
525
  local loop_number=$(jq -r -j '.metadata.loop_number // .loop_number // 0' "$output_file" 2>/dev/null)
@@ -371,11 +549,19 @@ parse_json_response() {
371
549
  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
550
  fi
373
551
 
552
+ # Heuristic permission-denial matching is limited to the refusal-shaped
553
+ # response preamble, not arbitrary prose or copied logs later in the body.
554
+ if [[ "$has_permission_denials" != "true" ]] && contains_permission_denial_text "$summary"; then
555
+ has_permission_denials="true"
556
+ permission_denial_count=1
557
+ denied_commands_json='["permission_denied"]'
558
+ fi
559
+
374
560
  # Apply completion heuristics to normalized summary text when explicit structured
375
561
  # completion markers are absent. This keeps JSONL analysis aligned with text mode.
376
562
  local summary_has_completion_keyword="false"
377
563
  local summary_has_no_work_pattern="false"
378
- if [[ "$response_shape" == "codex_jsonl" && "$explicit_exit_signal_found" != "true" && -n "$summary" ]]; then
564
+ if [[ "$response_shape" == "codex_jsonl" || "$response_shape" == "cursor_stream_jsonl" ]] && [[ "$explicit_exit_signal_found" != "true" && -n "$summary" ]]; then
379
565
  for keyword in "${COMPLETION_KEYWORDS[@]}"; do
380
566
  if echo "$summary" | grep -qi "$keyword"; then
381
567
  summary_has_completion_keyword="true"
@@ -639,8 +825,8 @@ analyze_response() {
639
825
  # 1. Check for explicit structured output (if Claude follows schema)
640
826
  if grep -q -- "---RALPH_STATUS---" "$output_file"; then
641
827
  # Parse structured output
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)
828
+ local status=$(trim_shell_whitespace "$(grep "STATUS:" "$output_file" | cut -d: -f2)")
829
+ local exit_sig=$(trim_shell_whitespace "$(grep "EXIT_SIGNAL:" "$output_file" | cut -d: -f2)")
644
830
 
645
831
  # If EXIT_SIGNAL is explicitly provided, respect it
646
832
  if [[ -n "$exit_sig" ]]; then
@@ -792,8 +978,18 @@ analyze_response() {
792
978
  fi
793
979
  fi
794
980
 
981
+ local has_permission_denials=false
982
+ local permission_denial_count=0
983
+ local denied_commands_json='[]'
984
+ local permission_signal_text=""
985
+ permission_signal_text=$(extract_permission_signal_text "$output_content")
986
+ if contains_permission_denial_text "$work_summary" || contains_permission_denial_signal "$permission_signal_text"; then
987
+ has_permission_denials=true
988
+ permission_denial_count=1
989
+ denied_commands_json='["permission_denied"]'
990
+ fi
991
+
795
992
  # Write analysis results to file (text parsing path) using jq for safe construction
796
- # Note: Permission denial fields default to false/0 since text output doesn't include this data
797
993
  jq -n \
798
994
  --argjson loop_number "$loop_number" \
799
995
  --arg timestamp "$(get_iso_timestamp)" \
@@ -808,6 +1004,9 @@ analyze_response() {
808
1004
  --argjson exit_signal "$exit_signal" \
809
1005
  --arg work_summary "$work_summary" \
810
1006
  --argjson output_length "$output_length" \
1007
+ --argjson has_permission_denials "$has_permission_denials" \
1008
+ --argjson permission_denial_count "$permission_denial_count" \
1009
+ --argjson denied_commands "$denied_commands_json" \
811
1010
  '{
812
1011
  loop_number: $loop_number,
813
1012
  timestamp: $timestamp,
@@ -823,9 +1022,9 @@ analyze_response() {
823
1022
  exit_signal: $exit_signal,
824
1023
  work_summary: $work_summary,
825
1024
  output_length: $output_length,
826
- has_permission_denials: false,
827
- permission_denial_count: 0,
828
- denied_commands: []
1025
+ has_permission_denials: $has_permission_denials,
1026
+ permission_denial_count: $permission_denial_count,
1027
+ denied_commands: $denied_commands
829
1028
  }
830
1029
  }' > "$analysis_result_file"
831
1030
 
@@ -849,6 +1048,7 @@ update_exit_signals() {
849
1048
  local has_completion_signal=$(jq -r -j '.analysis.has_completion_signal' "$analysis_file")
850
1049
  local loop_number=$(jq -r -j '.loop_number' "$analysis_file")
851
1050
  local has_progress=$(jq -r -j '.analysis.has_progress' "$analysis_file")
1051
+ local has_permission_denials=$(jq -r -j '.analysis.has_permission_denials // false' "$analysis_file")
852
1052
 
853
1053
  # Read current exit signals
854
1054
  local signals=$(cat "$exit_signals_file" 2>/dev/null || echo '{"test_only_loops": [], "done_signals": [], "completion_indicators": []}')
@@ -863,8 +1063,9 @@ update_exit_signals() {
863
1063
  fi
864
1064
  fi
865
1065
 
866
- # Update done_signals array
867
- if [[ "$has_completion_signal" == "true" ]]; then
1066
+ # Permission denials are handled in the same loop, so they must not become
1067
+ # completion state that can halt the next loop.
1068
+ if [[ "$has_permission_denials" != "true" && "$has_completion_signal" == "true" ]]; then
868
1069
  signals=$(echo "$signals" | jq ".done_signals += [$loop_number]")
869
1070
  fi
870
1071
 
@@ -873,7 +1074,7 @@ update_exit_signals() {
873
1074
  # due to deterministic scoring (+50 for JSON format, +20 for result field).
874
1075
  # This caused premature exits after 5 loops. Now we respect Claude's explicit intent.
875
1076
  local exit_signal=$(jq -r -j '.analysis.exit_signal // false' "$analysis_file")
876
- if [[ "$exit_signal" == "true" ]]; then
1077
+ if [[ "$has_permission_denials" != "true" && "$exit_signal" == "true" ]]; then
877
1078
  signals=$(echo "$signals" | jq ".completion_indicators += [$loop_number]")
878
1079
  fi
879
1080
 
@@ -1093,7 +1294,9 @@ export -f detect_output_format
1093
1294
  export -f count_json_documents
1094
1295
  export -f normalize_cli_array_response
1095
1296
  export -f normalize_codex_jsonl_response
1297
+ export -f normalize_cursor_stream_json_response
1096
1298
  export -f is_codex_jsonl_output
1299
+ export -f is_cursor_stream_json_output
1097
1300
  export -f normalize_json_output
1098
1301
  export -f extract_session_id_from_output
1099
1302
  export -f parse_json_response