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.
- package/README.md +87 -34
- package/dist/commands/doctor-checks.js +5 -4
- package/dist/commands/doctor-checks.js.map +1 -1
- package/dist/commands/doctor-runtime-checks.js +104 -86
- package/dist/commands/doctor-runtime-checks.js.map +1 -1
- package/dist/commands/run.js +4 -0
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/status.js +12 -3
- package/dist/commands/status.js.map +1 -1
- package/dist/installer/bmad-assets.js +182 -0
- package/dist/installer/bmad-assets.js.map +1 -0
- package/dist/installer/commands.js +324 -0
- package/dist/installer/commands.js.map +1 -0
- package/dist/installer/install.js +42 -0
- package/dist/installer/install.js.map +1 -0
- package/dist/installer/metadata.js +56 -0
- package/dist/installer/metadata.js.map +1 -0
- package/dist/installer/project-files.js +169 -0
- package/dist/installer/project-files.js.map +1 -0
- package/dist/installer/ralph-assets.js +91 -0
- package/dist/installer/ralph-assets.js.map +1 -0
- package/dist/installer/template-files.js +168 -0
- package/dist/installer/template-files.js.map +1 -0
- package/dist/installer/types.js +2 -0
- package/dist/installer/types.js.map +1 -0
- package/dist/installer.js +5 -790
- package/dist/installer.js.map +1 -1
- package/dist/platform/cursor-runtime-checks.js +81 -0
- package/dist/platform/cursor-runtime-checks.js.map +1 -0
- package/dist/platform/cursor.js +4 -3
- package/dist/platform/cursor.js.map +1 -1
- package/dist/platform/detect.js +28 -5
- package/dist/platform/detect.js.map +1 -1
- package/dist/platform/instructions-snippet.js +18 -0
- package/dist/platform/instructions-snippet.js.map +1 -1
- package/dist/platform/resolve.js +23 -5
- package/dist/platform/resolve.js.map +1 -1
- package/dist/run/ralph-process.js +84 -15
- package/dist/run/ralph-process.js.map +1 -1
- package/dist/transition/artifact-loading.js +91 -0
- package/dist/transition/artifact-loading.js.map +1 -0
- package/dist/transition/artifact-scan.js +15 -3
- package/dist/transition/artifact-scan.js.map +1 -1
- package/dist/transition/context-output.js +85 -0
- package/dist/transition/context-output.js.map +1 -0
- package/dist/transition/fix-plan-sync.js +119 -0
- package/dist/transition/fix-plan-sync.js.map +1 -0
- package/dist/transition/orchestration.js +25 -362
- package/dist/transition/orchestration.js.map +1 -1
- package/dist/transition/specs-sync.js +78 -2
- package/dist/transition/specs-sync.js.map +1 -1
- package/dist/utils/ralph-runtime-state.js +222 -0
- package/dist/utils/ralph-runtime-state.js.map +1 -0
- package/dist/utils/state.js +17 -16
- package/dist/utils/state.js.map +1 -1
- package/dist/utils/validate.js +16 -0
- package/dist/utils/validate.js.map +1 -1
- package/dist/watch/renderer.js +48 -6
- package/dist/watch/renderer.js.map +1 -1
- package/dist/watch/state-reader.js +79 -44
- package/dist/watch/state-reader.js.map +1 -1
- package/package.json +1 -1
- package/ralph/RALPH-REFERENCE.md +60 -16
- package/ralph/drivers/claude-code.sh +25 -0
- package/ralph/drivers/codex.sh +11 -0
- package/ralph/drivers/copilot.sh +11 -0
- package/ralph/drivers/cursor.sh +58 -29
- package/ralph/lib/circuit_breaker.sh +3 -3
- package/ralph/lib/date_utils.sh +28 -9
- package/ralph/lib/response_analyzer.sh +220 -17
- package/ralph/ralph_loop.sh +464 -121
- package/ralph/templates/PROMPT.md +5 -0
- package/ralph/templates/ralphrc.template +14 -4
package/ralph/drivers/cursor.sh
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# Cursor CLI driver for Ralph
|
|
3
|
-
#
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
103
|
+
return 0
|
|
106
104
|
}
|
|
107
105
|
|
|
108
106
|
driver_supports_live_output() {
|
|
109
|
-
return 0
|
|
107
|
+
return 0
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
driver_prepare_live_command() {
|
|
113
|
-
LIVE_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 '
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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
|
|
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"
|
package/ralph/lib/date_utils.sh
CHANGED
|
@@ -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
|
-
#
|
|
53
|
-
|
|
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
|
-
|
|
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=$(
|
|
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 [[ "$
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=$(
|
|
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=$(
|
|
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
|
|
643
|
-
local exit_sig=$(grep "EXIT_SIGNAL:" "$output_file" | cut -d: -f2
|
|
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:
|
|
827
|
-
permission_denial_count:
|
|
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
|
-
#
|
|
867
|
-
|
|
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
|