Skip to content

Commit 3a474b4

Browse files
authored
Merge pull request #62 from frankbria/feature/json-output-parsing
feat(analyzer): add Claude CLI JSON format support and session management
2 parents 3d91d92 + a2e7e93 commit 3a474b4

4 files changed

Lines changed: 498 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
66

77
This is the Ralph for Claude Code repository - an autonomous AI development loop system that enables continuous development cycles with intelligent exit detection and rate limiting.
88

9-
**Version**: v0.9.5 | **Tests**: 223 passing (100% pass rate) | **CI/CD**: GitHub Actions
9+
**Version**: v0.9.6 | **Tests**: 239 passing (100% pass rate) | **CI/CD**: GitHub Actions
1010

1111
## Core Architecture
1212

@@ -33,7 +33,10 @@ The system uses a modular architecture with reusable components in the `lib/` di
3333
2. **lib/response_analyzer.sh** - Intelligent response analysis
3434
- Analyzes Claude Code output for completion signals
3535
- **JSON output format detection and parsing** (with text fallback)
36+
- Supports both flat JSON format and Claude CLI format (`result`, `sessionId`, `metadata`)
3637
- Extracts structured fields: status, exit_signal, work_type, files_modified
38+
- **Session management**: `store_session_id()`, `get_last_session_id()`, `should_resume_session()`
39+
- Automatic session persistence to `.claude_session_id` file with 24-hour expiration
3740
- Detects test-only loops and stuck error patterns
3841
- Two-stage error filtering to eliminate false positives
3942
- Multi-line error matching for accurate stuck loop detection
@@ -272,13 +275,13 @@ Ralph uses advanced error detection with two-stage filtering to eliminate false
272275

273276
## Test Suite
274277

275-
### Test Files (223 tests total)
278+
### Test Files (239 tests total)
276279

277280
| File | Tests | Description |
278281
|------|-------|-------------|
279282
| `test_cli_parsing.bats` | 27 | CLI argument parsing for all 12 flags |
280283
| `test_cli_modern.bats` | 29 | Modern CLI commands (Phase 1.1) + build_claude_command fix |
281-
| `test_json_parsing.bats` | 20 | JSON output format parsing |
284+
| `test_json_parsing.bats` | 36 | JSON output format parsing + Claude CLI format + session management |
282285
| `test_exit_detection.bats` | 20 | Exit signal detection |
283286
| `test_rate_limiting.bats` | 15 | Rate limiting behavior |
284287
| `test_loop_execution.bats` | 20 | Integration tests |
@@ -301,6 +304,20 @@ bats tests/unit/test_cli_parsing.bats
301304

302305
## Recent Improvements
303306

307+
### JSON Output & Session Management (v0.9.6)
308+
- Extended `parse_json_response()` to support Claude Code CLI JSON format
309+
- Supports `result`, `sessionId`, and `metadata` fields alongside existing flat format
310+
- Extracts `metadata.files_changed`, `metadata.has_errors`, `metadata.completion_status`
311+
- Parses `metadata.progress_indicators` array for confidence boosting
312+
- Added session management functions for continuity tracking:
313+
- `store_session_id()` - Persists session with timestamp
314+
- `get_last_session_id()` - Retrieves stored session ID
315+
- `should_resume_session()` - Checks session validity (24-hour expiration)
316+
- Added `get_epoch_seconds()` to date_utils.sh for cross-platform epoch time
317+
- Auto-persists sessionId to `.claude_session_id` file during response analysis
318+
- Added 16 new tests covering Claude CLI format and session management
319+
- Test count: 239 (up from 223)
320+
304321
### PRD Import Tests (v0.9.5)
305322
- Added 22 comprehensive tests for `ralph_import.sh` PRD conversion script
306323
- Tests cover: file format support (.md, .txt, .json), output file creation, project naming

lib/date_utils.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,15 @@ get_next_hour_time() {
3939
get_basic_timestamp() {
4040
date '+%Y-%m-%d %H:%M:%S'
4141
}
42+
43+
# Get current Unix epoch time in seconds
44+
# Returns: Integer seconds since 1970-01-01 00:00:00 UTC
45+
get_epoch_seconds() {
46+
date +%s
47+
}
48+
49+
# Export functions for use in other scripts
50+
export -f get_iso_timestamp
51+
export -f get_next_hour_time
52+
export -f get_basic_timestamp
53+
export -f get_epoch_seconds

lib/response_analyzer.sh

Lines changed: 170 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ detect_output_format() {
5252

5353
# Parse JSON response and extract structured fields
5454
# Creates .json_parse_result with normalized analysis data
55+
# Supports TWO JSON formats:
56+
# 1. Flat format: { status, exit_signal, work_type, files_modified, ... }
57+
# 2. Claude CLI format: { result, sessionId, metadata: { files_changed, has_errors, completion_status, ... } }
5558
parse_json_response() {
5659
local output_file=$1
5760
local result_file="${2:-.json_parse_result}"
@@ -67,22 +70,57 @@ parse_json_response() {
6770
return 1
6871
fi
6972

70-
# Extract fields with defaults
73+
# Detect JSON format by checking for Claude CLI fields
74+
local has_result_field=$(jq -r 'has("result")' "$output_file" 2>/dev/null)
75+
76+
# Extract fields - support both flat format and Claude CLI format
77+
# Priority: Claude CLI fields first, then flat format fields
78+
79+
# Status: from flat format OR derived from metadata.completion_status
7180
local status=$(jq -r '.status // "UNKNOWN"' "$output_file" 2>/dev/null)
81+
local completion_status=$(jq -r '.metadata.completion_status // ""' "$output_file" 2>/dev/null)
82+
if [[ "$completion_status" == "complete" || "$completion_status" == "COMPLETE" ]]; then
83+
status="COMPLETE"
84+
fi
85+
86+
# Exit signal: from flat format OR derived from completion_status
7287
local exit_signal=$(jq -r '.exit_signal // false' "$output_file" 2>/dev/null)
88+
89+
# Work type: from flat format
7390
local work_type=$(jq -r '.work_type // "UNKNOWN"' "$output_file" 2>/dev/null)
74-
local files_modified=$(jq -r '.files_modified // 0' "$output_file" 2>/dev/null)
91+
92+
# Files modified: from flat format OR from metadata.files_changed
93+
local files_modified=$(jq -r '.metadata.files_changed // .files_modified // 0' "$output_file" 2>/dev/null)
94+
95+
# Error count: from flat format OR derived from metadata.has_errors
96+
# Note: When only has_errors=true is present (without explicit error_count),
97+
# we set error_count=1 as a minimum. This is defensive programming since
98+
# the stuck detection threshold is >5 errors, so 1 error won't trigger it.
99+
# Actual error count may be higher, but precise count isn't critical for our logic.
75100
local error_count=$(jq -r '.error_count // 0' "$output_file" 2>/dev/null)
76-
local summary=$(jq -r '.summary // ""' "$output_file" 2>/dev/null)
101+
local has_errors=$(jq -r '.metadata.has_errors // false' "$output_file" 2>/dev/null)
102+
if [[ "$has_errors" == "true" && "$error_count" == "0" ]]; then
103+
error_count=1 # At least one error if has_errors is true
104+
fi
77105

78-
# Extract nested metadata if present
106+
# Summary: from flat format OR from result field (Claude CLI format)
107+
local summary=$(jq -r '.result // .summary // ""' "$output_file" 2>/dev/null)
108+
109+
# Session ID: from Claude CLI format (sessionId) OR from metadata.session_id
110+
local session_id=$(jq -r '.sessionId // .metadata.session_id // ""' "$output_file" 2>/dev/null)
111+
112+
# Loop number: from metadata
79113
local loop_number=$(jq -r '.metadata.loop_number // .loop_number // 0' "$output_file" 2>/dev/null)
80-
local session_id=$(jq -r '.metadata.session_id // ""' "$output_file" 2>/dev/null)
114+
115+
# Confidence: from flat format
81116
local confidence=$(jq -r '.confidence // 0' "$output_file" 2>/dev/null)
82117

118+
# Progress indicators: from Claude CLI metadata (optional)
119+
local progress_count=$(jq -r '.metadata.progress_indicators | if . then length else 0 end' "$output_file" 2>/dev/null)
120+
83121
# Normalize values
84122
# Convert exit_signal to boolean string
85-
if [[ "$exit_signal" == "true" || "$status" == "COMPLETE" ]]; then
123+
if [[ "$exit_signal" == "true" || "$status" == "COMPLETE" || "$completion_status" == "complete" || "$completion_status" == "COMPLETE" ]]; then
86124
exit_signal="true"
87125
else
88126
exit_signal="false"
@@ -94,7 +132,7 @@ parse_json_response() {
94132
is_test_only="true"
95133
fi
96134

97-
# Determine is_stuck from error_count
135+
# Determine is_stuck from error_count (threshold >5)
98136
local is_stuck="false"
99137
error_count=$((error_count + 0)) # Ensure integer
100138
if [[ $error_count -gt 5 ]]; then
@@ -104,12 +142,23 @@ parse_json_response() {
104142
# Ensure files_modified is integer
105143
files_modified=$((files_modified + 0))
106144

145+
# Ensure progress_count is integer
146+
progress_count=$((progress_count + 0))
147+
107148
# Calculate has_completion_signal
108149
local has_completion_signal="false"
109150
if [[ "$status" == "COMPLETE" || "$exit_signal" == "true" ]]; then
110151
has_completion_signal="true"
111152
fi
112153

154+
# Boost confidence based on structured data availability
155+
if [[ "$has_result_field" == "true" ]]; then
156+
confidence=$((confidence + 20)) # Structured response boost
157+
fi
158+
if [[ $progress_count -gt 0 ]]; then
159+
confidence=$((confidence + progress_count * 5)) # Progress indicators boost
160+
fi
161+
113162
# Write normalized result using jq for safe JSON construction
114163
# String fields use --arg (auto-escapes), numeric/boolean use --argjson
115164
jq -n \
@@ -184,6 +233,13 @@ analyze_response() {
184233
work_summary=$(jq -r '.summary' .json_parse_result 2>/dev/null || echo "")
185234
files_modified=$(jq -r '.files_modified' .json_parse_result 2>/dev/null || echo "0")
186235
local json_confidence=$(jq -r '.confidence' .json_parse_result 2>/dev/null || echo "0")
236+
local session_id=$(jq -r '.session_id' .json_parse_result 2>/dev/null || echo "")
237+
238+
# Persist session ID if present (for session continuity across loop iterations)
239+
if [[ -n "$session_id" && "$session_id" != "null" ]]; then
240+
store_session_id "$session_id"
241+
[[ "${VERBOSE_PROGRESS:-}" == "true" ]] && echo "DEBUG: Persisted session ID: $session_id" >&2
242+
fi
187243

188244
# JSON parsing provides high confidence
189245
if [[ "$exit_signal" == "true" ]]; then
@@ -511,10 +567,117 @@ detect_stuck_loop() {
511567
fi
512568
}
513569

570+
# =============================================================================
571+
# SESSION MANAGEMENT FUNCTIONS
572+
# =============================================================================
573+
574+
# Session file location - standardized across ralph_loop.sh and response_analyzer.sh
575+
SESSION_FILE=".claude_session_id"
576+
# Session expiration time in seconds (24 hours)
577+
SESSION_EXPIRATION_SECONDS=86400
578+
579+
# Store session ID to file with timestamp
580+
# Usage: store_session_id "session-uuid-123"
581+
store_session_id() {
582+
local session_id=$1
583+
584+
if [[ -z "$session_id" ]]; then
585+
return 1
586+
fi
587+
588+
# Write session with timestamp using jq for safe JSON construction
589+
jq -n \
590+
--arg session_id "$session_id" \
591+
--arg timestamp "$(get_iso_timestamp)" \
592+
'{
593+
session_id: $session_id,
594+
timestamp: $timestamp
595+
}' > "$SESSION_FILE"
596+
597+
return 0
598+
}
599+
600+
# Get the last stored session ID
601+
# Returns: session ID string or empty if not found
602+
get_last_session_id() {
603+
if [[ ! -f "$SESSION_FILE" ]]; then
604+
echo ""
605+
return 0
606+
fi
607+
608+
# Extract session_id from JSON file
609+
local session_id=$(jq -r '.session_id // ""' "$SESSION_FILE" 2>/dev/null)
610+
echo "$session_id"
611+
return 0
612+
}
613+
614+
# Check if the stored session should be resumed
615+
# Returns: 0 (true) if session is valid and recent, 1 (false) otherwise
616+
should_resume_session() {
617+
if [[ ! -f "$SESSION_FILE" ]]; then
618+
echo "false"
619+
return 1
620+
fi
621+
622+
# Get session timestamp
623+
local timestamp=$(jq -r '.timestamp // ""' "$SESSION_FILE" 2>/dev/null)
624+
625+
if [[ -z "$timestamp" ]]; then
626+
echo "false"
627+
return 1
628+
fi
629+
630+
# Calculate session age using date utilities
631+
local now=$(get_epoch_seconds)
632+
local session_time
633+
634+
# Parse ISO timestamp to epoch - try multiple formats for cross-platform compatibility
635+
# Strip milliseconds if present (e.g., 2026-01-09T10:30:00.123+00:00 → 2026-01-09T10:30:00+00:00)
636+
local clean_timestamp="${timestamp}"
637+
if [[ "$timestamp" =~ \.[0-9]+[+-Z] ]]; then
638+
clean_timestamp=$(echo "$timestamp" | sed 's/\.[0-9]*\([+-Z]\)/\1/')
639+
fi
640+
641+
if command -v gdate &>/dev/null; then
642+
# macOS with coreutils
643+
session_time=$(gdate -d "$clean_timestamp" +%s 2>/dev/null)
644+
elif date --version 2>&1 | grep -q GNU; then
645+
# GNU date (Linux)
646+
session_time=$(date -d "$clean_timestamp" +%s 2>/dev/null)
647+
else
648+
# BSD date (macOS without coreutils) - try parsing ISO format
649+
# Format: 2026-01-09T10:30:00+00:00 or 2026-01-09T10:30:00Z
650+
# Strip timezone suffix for BSD date parsing
651+
local date_only="${clean_timestamp%[+-Z]*}"
652+
session_time=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$date_only" +%s 2>/dev/null)
653+
fi
654+
655+
# If we couldn't parse the timestamp, consider session expired
656+
if [[ -z "$session_time" || ! "$session_time" =~ ^[0-9]+$ ]]; then
657+
echo "false"
658+
return 1
659+
fi
660+
661+
# Calculate age in seconds
662+
local age=$((now - session_time))
663+
664+
# Check if session is still valid (less than expiration time)
665+
if [[ $age -lt $SESSION_EXPIRATION_SECONDS ]]; then
666+
echo "true"
667+
return 0
668+
else
669+
echo "false"
670+
return 1
671+
fi
672+
}
673+
514674
# Export functions for use in ralph_loop.sh
515675
export -f detect_output_format
516676
export -f parse_json_response
517677
export -f analyze_response
518678
export -f update_exit_signals
519679
export -f log_analysis_summary
520680
export -f detect_stuck_loop
681+
export -f store_session_id
682+
export -f get_last_session_id
683+
export -f should_resume_session

0 commit comments

Comments
 (0)