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