claude-self-reflect 7.0.0 → 7.1.9
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/.claude/agents/csr-validator.md +87 -1
- package/.env.example +15 -0
- package/README.md +70 -1
- package/docker-compose.yaml +18 -9
- package/installer/setup-wizard-docker.js +87 -6
- package/installer/update-manager.js +88 -1
- package/mcp-server/src/reflection_tools.py +114 -2
- package/mcp-server/src/standalone_client.py +380 -0
- package/package.json +1 -1
- package/src/runtime/hooks/__init__.py +21 -0
- package/src/runtime/hooks/iteration_hook.py +196 -0
- package/src/runtime/hooks/ralph_state.py +402 -0
- package/src/runtime/hooks/session_end_hook.py +254 -0
- package/src/runtime/hooks/session_start_hook.py +259 -0
- package/src/runtime/precompact-hook.sh +82 -3
- package/src/runtime/unified_state_manager.py +35 -10
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Ralph Iteration Hook - Fires at EACH iteration boundary via Stop hook.
|
|
4
|
+
|
|
5
|
+
This is the ONLY viable hook for iteration-level memory because:
|
|
6
|
+
- Stop hook fires after EACH Claude response (iteration boundary)
|
|
7
|
+
- SessionStart/SessionEnd fire once per terminal session (useless)
|
|
8
|
+
|
|
9
|
+
When triggered:
|
|
10
|
+
1. Store learnings from THIS iteration (with session_id + iteration tag)
|
|
11
|
+
2. Retrieve learnings from PREVIOUS iterations of same session
|
|
12
|
+
3. Output context for next iteration
|
|
13
|
+
|
|
14
|
+
Input (stdin): JSON with transcript, etc.
|
|
15
|
+
Output (stdout): Context message injected into next iteration
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
# Add project root to path
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
|
27
|
+
|
|
28
|
+
from src.runtime.hooks.ralph_state import (
|
|
29
|
+
is_ralph_session,
|
|
30
|
+
load_ralph_session_state,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logging.basicConfig(level=logging.INFO)
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_project_root() -> Path:
|
|
38
|
+
return Path(__file__).parent.parent.parent.parent
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def store_iteration_learnings(state, iteration: int) -> bool:
|
|
42
|
+
"""Store learnings from current iteration to CSR."""
|
|
43
|
+
try:
|
|
44
|
+
project_root = get_project_root()
|
|
45
|
+
mcp_server_path = project_root / "mcp-server" / "src"
|
|
46
|
+
if str(mcp_server_path) not in sys.path:
|
|
47
|
+
sys.path.insert(0, str(mcp_server_path))
|
|
48
|
+
from standalone_client import CSRStandaloneClient
|
|
49
|
+
client = CSRStandaloneClient()
|
|
50
|
+
|
|
51
|
+
# Get working directory (project name)
|
|
52
|
+
cwd = Path.cwd()
|
|
53
|
+
# SECURITY: Sanitize project name to prevent injection
|
|
54
|
+
project_name = re.sub(r'[^a-zA-Z0-9_-]', '_', cwd.name)
|
|
55
|
+
|
|
56
|
+
# Get learnings (may be empty)
|
|
57
|
+
learnings = getattr(state, 'learnings', [])
|
|
58
|
+
|
|
59
|
+
# Create iteration-specific content (store even if no learnings to track hook fired)
|
|
60
|
+
content = f"""# Iteration {iteration} (Session: {state.session_id})
|
|
61
|
+
|
|
62
|
+
## Project
|
|
63
|
+
{project_name} ({cwd})
|
|
64
|
+
|
|
65
|
+
## Task
|
|
66
|
+
{state.task[:200] if state.task else '(no task)'}
|
|
67
|
+
|
|
68
|
+
## Learnings
|
|
69
|
+
{chr(10).join(f'- {l}' for l in learnings) if learnings else '(none this iteration)'}
|
|
70
|
+
|
|
71
|
+
## Current Approach
|
|
72
|
+
{state.current_approach or '(not set)'}
|
|
73
|
+
|
|
74
|
+
## Files Modified
|
|
75
|
+
{chr(10).join(f'- {f}' for f in state.files_modified) if state.files_modified else '(none)'}
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
# Store with iteration-specific tags including project
|
|
79
|
+
tags = [
|
|
80
|
+
"__csr_hook_auto__", # Hook signature
|
|
81
|
+
f"project_{project_name}",
|
|
82
|
+
f"session_{state.session_id}",
|
|
83
|
+
f"iteration_{iteration}",
|
|
84
|
+
"ralph_iteration"
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
client.store_reflection(
|
|
88
|
+
content=content,
|
|
89
|
+
tags=tags,
|
|
90
|
+
collection="csr_hook_sessions_local"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
logger.info(f"Stored iteration {iteration} for {project_name} session {state.session_id}")
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Error storing iteration learnings: {e}")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def retrieve_iteration_learnings(session_id: str, current_iteration: int) -> list:
|
|
102
|
+
"""Retrieve learnings from PREVIOUS iterations of this session."""
|
|
103
|
+
try:
|
|
104
|
+
project_root = get_project_root()
|
|
105
|
+
mcp_server_path = project_root / "mcp-server" / "src"
|
|
106
|
+
if str(mcp_server_path) not in sys.path:
|
|
107
|
+
sys.path.insert(0, str(mcp_server_path))
|
|
108
|
+
from standalone_client import CSRStandaloneClient
|
|
109
|
+
client = CSRStandaloneClient()
|
|
110
|
+
|
|
111
|
+
# Get learnings from this session (use hook collection)
|
|
112
|
+
learnings = client.get_session_learnings(
|
|
113
|
+
session_id,
|
|
114
|
+
limit=20,
|
|
115
|
+
collection="csr_hook_sessions_local"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Filter to only previous iterations
|
|
119
|
+
previous = [
|
|
120
|
+
l for l in learnings
|
|
121
|
+
if any(f"iteration_{i}" in l.get('tags', [])
|
|
122
|
+
for i in range(1, current_iteration))
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
return previous
|
|
126
|
+
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.error(f"Error retrieving iteration learnings: {e}")
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_iteration_context(learnings: list, current_iteration: int) -> str:
|
|
133
|
+
"""Format previous iteration learnings for injection."""
|
|
134
|
+
if not learnings:
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
output = [f"# Previous Iteration Learnings (for Iteration {current_iteration})"]
|
|
138
|
+
output.append("")
|
|
139
|
+
|
|
140
|
+
for l in learnings[:5]: # Max 5 previous iterations
|
|
141
|
+
content = l.get('content', '')[:500]
|
|
142
|
+
tags = l.get('tags', [])
|
|
143
|
+
iter_tag = [t for t in tags if t.startswith('iteration_')]
|
|
144
|
+
iter_num = iter_tag[0].split('_')[1] if iter_tag else '?'
|
|
145
|
+
output.append(f"## From Iteration {iter_num}")
|
|
146
|
+
output.append(content)
|
|
147
|
+
output.append("")
|
|
148
|
+
|
|
149
|
+
return "\n".join(output)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main():
|
|
153
|
+
"""Main hook entry point - fires at each iteration boundary."""
|
|
154
|
+
# Read hook input (required by Claude Code hook framework, but we use Ralph state file instead)
|
|
155
|
+
# The stdin contains transcript data, but Ralph state file is the authoritative source
|
|
156
|
+
try:
|
|
157
|
+
_ = json.load(sys.stdin) # Consume stdin as required by hook protocol
|
|
158
|
+
except (json.JSONDecodeError, EOFError):
|
|
159
|
+
pass # No input is fine - we get state from .ralph_state.md
|
|
160
|
+
|
|
161
|
+
# Check if Ralph loop active
|
|
162
|
+
if not is_ralph_session():
|
|
163
|
+
logger.info("No Ralph session - iteration hook skipped")
|
|
164
|
+
sys.exit(0)
|
|
165
|
+
|
|
166
|
+
# Load state
|
|
167
|
+
state = load_ralph_session_state()
|
|
168
|
+
if not state:
|
|
169
|
+
logger.warning("Could not load Ralph state")
|
|
170
|
+
sys.exit(0)
|
|
171
|
+
|
|
172
|
+
iteration = state.iteration
|
|
173
|
+
session_id = state.session_id
|
|
174
|
+
|
|
175
|
+
logger.info(f"Iteration hook: session={session_id}, iteration={iteration}")
|
|
176
|
+
|
|
177
|
+
# 1. Store learnings from THIS iteration
|
|
178
|
+
store_iteration_learnings(state, iteration)
|
|
179
|
+
|
|
180
|
+
# 2. Retrieve learnings from PREVIOUS iterations
|
|
181
|
+
previous_learnings = retrieve_iteration_learnings(session_id, iteration)
|
|
182
|
+
|
|
183
|
+
# 3. Output context for NEXT iteration
|
|
184
|
+
if previous_learnings:
|
|
185
|
+
context = format_iteration_context(previous_learnings, iteration + 1)
|
|
186
|
+
# Write to file for Claude to read
|
|
187
|
+
context_path = Path('.ralph_iteration_context.md')
|
|
188
|
+
context_path.write_text(context)
|
|
189
|
+
print(f"# Loaded {len(previous_learnings)} learnings from previous iterations")
|
|
190
|
+
print(f"# See .ralph_iteration_context.md for details")
|
|
191
|
+
else:
|
|
192
|
+
logger.info("No previous iteration learnings found")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
main()
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Ralph State File Manager - Schema and parsing for .ralph_state.md
|
|
4
|
+
|
|
5
|
+
This module provides:
|
|
6
|
+
1. State file schema definition
|
|
7
|
+
2. Parsing utilities to read/write state
|
|
8
|
+
3. Validation for state integrity
|
|
9
|
+
|
|
10
|
+
Enhanced Features (v7.1+):
|
|
11
|
+
- Output decline detection (circuit breaker pattern)
|
|
12
|
+
- Error signature deduplication
|
|
13
|
+
- Confidence-based exit signals
|
|
14
|
+
- Work type tracking (IMPLEMENTATION/TESTING/DEBUGGING)
|
|
15
|
+
|
|
16
|
+
Attribution:
|
|
17
|
+
Several patterns in this module were inspired by the excellent work in
|
|
18
|
+
https://github.com/frankbria/ralph-claude-code - a community implementation
|
|
19
|
+
of autonomous AI development loops. We gratefully acknowledge their
|
|
20
|
+
contributions to the Ralph loop ecosystem.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import json
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from dataclasses import dataclass, field, asdict
|
|
27
|
+
from typing import List, Dict, Optional
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RalphState:
|
|
33
|
+
"""Schema for .ralph_state.md file content."""
|
|
34
|
+
|
|
35
|
+
# Session metadata
|
|
36
|
+
session_id: str = ""
|
|
37
|
+
task: str = ""
|
|
38
|
+
iteration: int = 0
|
|
39
|
+
started_at: str = ""
|
|
40
|
+
updated_at: str = ""
|
|
41
|
+
|
|
42
|
+
# Current approach
|
|
43
|
+
current_approach: str = ""
|
|
44
|
+
|
|
45
|
+
# History tracking
|
|
46
|
+
failed_approaches: List[str] = field(default_factory=list)
|
|
47
|
+
successful_strategies: List[str] = field(default_factory=list)
|
|
48
|
+
blocking_errors: List[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
# Progress tracking
|
|
51
|
+
files_modified: List[str] = field(default_factory=list)
|
|
52
|
+
learnings: List[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
# Next action
|
|
55
|
+
next_action: str = ""
|
|
56
|
+
|
|
57
|
+
# Completion tracking
|
|
58
|
+
completion_promise: str = ""
|
|
59
|
+
completion_promise_met: bool = False
|
|
60
|
+
|
|
61
|
+
# Output tracking for decline detection (circuit breaker pattern)
|
|
62
|
+
output_lengths: List[int] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
# Confidence-based exit scoring (0-100)
|
|
65
|
+
exit_confidence: int = 0
|
|
66
|
+
|
|
67
|
+
# Work type tracking for filtering
|
|
68
|
+
work_type: str = "" # IMPLEMENTATION, TESTING, DEBUGGING, DOCUMENTATION
|
|
69
|
+
|
|
70
|
+
# Deduplicated error signatures for anti-pattern detection
|
|
71
|
+
error_signatures: Dict[str, int] = field(default_factory=dict) # sig -> count
|
|
72
|
+
|
|
73
|
+
def _error_signature(self, error: str) -> str:
|
|
74
|
+
"""Extract error signature for deduplication (removes line numbers, paths)."""
|
|
75
|
+
sig = re.sub(r'line \d+', 'line N', error)
|
|
76
|
+
sig = re.sub(r'/[\w/.-]+/', '/.../', sig)
|
|
77
|
+
sig = re.sub(r'\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}', 'TIMESTAMP', sig)
|
|
78
|
+
return sig[:100]
|
|
79
|
+
|
|
80
|
+
def add_error(self, error: str) -> None:
|
|
81
|
+
"""Add error with deduplication by signature."""
|
|
82
|
+
sig = self._error_signature(error)
|
|
83
|
+
prev_count = self.error_signatures.get(sig, 0)
|
|
84
|
+
self.error_signatures[sig] = prev_count + 1
|
|
85
|
+
# Only add to blocking_errors if this is a new signature (O(1) vs O(n²))
|
|
86
|
+
if prev_count == 0:
|
|
87
|
+
self.blocking_errors.append(error)
|
|
88
|
+
|
|
89
|
+
def track_output(self, length: int) -> None:
|
|
90
|
+
"""Track output length for decline detection."""
|
|
91
|
+
self.output_lengths.append(length)
|
|
92
|
+
if len(self.output_lengths) > 10:
|
|
93
|
+
self.output_lengths = self.output_lengths[-10:]
|
|
94
|
+
|
|
95
|
+
def output_declining(self, threshold: float = 0.7) -> bool:
|
|
96
|
+
"""Check if output is declining (circuit breaker signal)."""
|
|
97
|
+
if len(self.output_lengths) < 3:
|
|
98
|
+
return False
|
|
99
|
+
recent = self.output_lengths[-3:]
|
|
100
|
+
avg_recent = sum(recent) / len(recent)
|
|
101
|
+
earlier = self.output_lengths[:-3]
|
|
102
|
+
if not earlier:
|
|
103
|
+
return False
|
|
104
|
+
avg_earlier = sum(earlier) / len(earlier)
|
|
105
|
+
return avg_recent < (avg_earlier * threshold)
|
|
106
|
+
|
|
107
|
+
def update_confidence(self, signals: dict) -> None:
|
|
108
|
+
"""Update exit confidence based on multiple signals."""
|
|
109
|
+
score = 0
|
|
110
|
+
if signals.get('all_tasks_complete'):
|
|
111
|
+
score += 40
|
|
112
|
+
if signals.get('tests_passing'):
|
|
113
|
+
score += 20
|
|
114
|
+
if signals.get('no_errors'):
|
|
115
|
+
score += 20
|
|
116
|
+
if signals.get('done_keyword'):
|
|
117
|
+
score += 10
|
|
118
|
+
if signals.get('consecutive_test_only', 0) >= 3:
|
|
119
|
+
score += 10
|
|
120
|
+
self.exit_confidence = min(100, score)
|
|
121
|
+
|
|
122
|
+
def increment_iteration(self) -> None:
|
|
123
|
+
"""Increment iteration count and update timestamp."""
|
|
124
|
+
self.iteration += 1
|
|
125
|
+
self.updated_at = datetime.now().isoformat()
|
|
126
|
+
|
|
127
|
+
def to_markdown(self) -> str:
|
|
128
|
+
"""Convert state to markdown format for .ralph_state.md"""
|
|
129
|
+
# Format error signatures for display
|
|
130
|
+
error_sig_display = ""
|
|
131
|
+
if self.error_signatures:
|
|
132
|
+
error_sig_display = "\n".join(
|
|
133
|
+
f"- `{sig}` (x{count})" for sig, count in self.error_signatures.items()
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
error_sig_display = "- (none yet)"
|
|
137
|
+
|
|
138
|
+
return f"""# Ralph Session State
|
|
139
|
+
|
|
140
|
+
## Metadata
|
|
141
|
+
- **Session ID:** {self.session_id}
|
|
142
|
+
- **Task:** {self.task}
|
|
143
|
+
- **Iteration:** {self.iteration}
|
|
144
|
+
- **Started:** {self.started_at}
|
|
145
|
+
- **Updated:** {self.updated_at}
|
|
146
|
+
- **Work Type:** {self.work_type or 'UNKNOWN'}
|
|
147
|
+
- **Exit Confidence:** {self.exit_confidence}%
|
|
148
|
+
|
|
149
|
+
## Current Approach
|
|
150
|
+
{self.current_approach}
|
|
151
|
+
|
|
152
|
+
## Completion Promise
|
|
153
|
+
`{self.completion_promise}`
|
|
154
|
+
Met: {self.completion_promise_met}
|
|
155
|
+
|
|
156
|
+
## Failed Approaches (DO NOT RETRY)
|
|
157
|
+
{self._list_to_md(self.failed_approaches)}
|
|
158
|
+
|
|
159
|
+
## Blocking Errors
|
|
160
|
+
{self._list_to_md(self.blocking_errors)}
|
|
161
|
+
|
|
162
|
+
## Error Signatures (Deduplicated)
|
|
163
|
+
{error_sig_display}
|
|
164
|
+
|
|
165
|
+
## Successful Strategies
|
|
166
|
+
{self._list_to_md(self.successful_strategies)}
|
|
167
|
+
|
|
168
|
+
## Files Modified
|
|
169
|
+
{self._list_to_md(self.files_modified)}
|
|
170
|
+
|
|
171
|
+
## Learnings
|
|
172
|
+
{self._list_to_md(self.learnings)}
|
|
173
|
+
|
|
174
|
+
## Output Tracking
|
|
175
|
+
- Recent lengths: {self.output_lengths[-5:] if self.output_lengths else []}
|
|
176
|
+
- Declining: {self.output_declining()}
|
|
177
|
+
|
|
178
|
+
## Next Action
|
|
179
|
+
{self.next_action}
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def _list_to_md(self, items: List[str]) -> str:
|
|
183
|
+
"""Convert list to markdown bullet points."""
|
|
184
|
+
if not items:
|
|
185
|
+
return "- (none yet)"
|
|
186
|
+
return "\n".join(f"- {item}" for item in items)
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def create_new(cls, task: str, completion_promise: str, session_id: str = None) -> 'RalphState':
|
|
190
|
+
"""Create a new state for a fresh Ralph session."""
|
|
191
|
+
import uuid
|
|
192
|
+
return cls(
|
|
193
|
+
session_id=session_id or f"ralph_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}",
|
|
194
|
+
task=task,
|
|
195
|
+
iteration=1,
|
|
196
|
+
started_at=datetime.now().isoformat(),
|
|
197
|
+
updated_at=datetime.now().isoformat(),
|
|
198
|
+
completion_promise=completion_promise
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_markdown(cls, content: str) -> 'RalphState':
|
|
203
|
+
"""Parse markdown content into RalphState object."""
|
|
204
|
+
state = cls()
|
|
205
|
+
|
|
206
|
+
# Parse metadata
|
|
207
|
+
if match := re.search(r'\*\*Session ID:\*\*\s*(.+)', content):
|
|
208
|
+
state.session_id = match.group(1).strip()
|
|
209
|
+
if match := re.search(r'\*\*Task:\*\*\s*(.+)', content):
|
|
210
|
+
state.task = match.group(1).strip()
|
|
211
|
+
if match := re.search(r'\*\*Iteration:\*\*\s*(\d+)', content):
|
|
212
|
+
state.iteration = int(match.group(1))
|
|
213
|
+
if match := re.search(r'\*\*Started:\*\*\s*(.+)', content):
|
|
214
|
+
state.started_at = match.group(1).strip()
|
|
215
|
+
if match := re.search(r'\*\*Updated:\*\*\s*(.+)', content):
|
|
216
|
+
state.updated_at = match.group(1).strip()
|
|
217
|
+
|
|
218
|
+
# NEW: Parse work type and exit confidence
|
|
219
|
+
if match := re.search(r'\*\*Work Type:\*\*\s*(.+)', content):
|
|
220
|
+
state.work_type = match.group(1).strip()
|
|
221
|
+
if match := re.search(r'\*\*Exit Confidence:\*\*\s*(\d+)', content):
|
|
222
|
+
state.exit_confidence = int(match.group(1))
|
|
223
|
+
|
|
224
|
+
# Parse completion promise
|
|
225
|
+
if match := re.search(r'## Completion Promise\n`(.+)`', content):
|
|
226
|
+
state.completion_promise = match.group(1)
|
|
227
|
+
if 'Met: True' in content:
|
|
228
|
+
state.completion_promise_met = True
|
|
229
|
+
|
|
230
|
+
# Parse current approach
|
|
231
|
+
if match := re.search(r'## Current Approach\n(.+?)(?=\n##|\Z)', content, re.DOTALL):
|
|
232
|
+
state.current_approach = match.group(1).strip()
|
|
233
|
+
|
|
234
|
+
# Parse next action
|
|
235
|
+
if match := re.search(r'## Next Action\n(.+?)(?=\n##|\Z)', content, re.DOTALL):
|
|
236
|
+
state.next_action = match.group(1).strip()
|
|
237
|
+
|
|
238
|
+
# Parse lists
|
|
239
|
+
state.failed_approaches = cls._parse_list_section(content, "Failed Approaches")
|
|
240
|
+
state.blocking_errors = cls._parse_list_section(content, "Blocking Errors")
|
|
241
|
+
state.successful_strategies = cls._parse_list_section(content, "Successful Strategies")
|
|
242
|
+
state.files_modified = cls._parse_list_section(content, "Files Modified")
|
|
243
|
+
state.learnings = cls._parse_list_section(content, "Learnings")
|
|
244
|
+
|
|
245
|
+
# NEW: Parse error signatures
|
|
246
|
+
state.error_signatures = cls._parse_error_signatures(content)
|
|
247
|
+
|
|
248
|
+
# NEW: Parse output lengths
|
|
249
|
+
if match := re.search(r'Recent lengths:\s*\[([^\]]*)\]', content):
|
|
250
|
+
try:
|
|
251
|
+
lengths_str = match.group(1).strip()
|
|
252
|
+
if lengths_str:
|
|
253
|
+
state.output_lengths = [int(x.strip()) for x in lengths_str.split(',') if x.strip()]
|
|
254
|
+
except ValueError:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
return state
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _parse_error_signatures(content: str) -> Dict[str, int]:
|
|
261
|
+
"""Parse error signatures section."""
|
|
262
|
+
signatures = {}
|
|
263
|
+
pattern = r'## Error Signatures[^\n]*\n((?:- .+\n?)+)'
|
|
264
|
+
if match := re.search(pattern, content):
|
|
265
|
+
for line in match.group(1).strip().split('\n'):
|
|
266
|
+
# Format: - `signature` (xN)
|
|
267
|
+
sig_match = re.search(r'- `(.+?)` \(x(\d+)\)', line)
|
|
268
|
+
if sig_match:
|
|
269
|
+
signatures[sig_match.group(1)] = int(sig_match.group(2))
|
|
270
|
+
return signatures
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _parse_list_section(content: str, section_name: str) -> List[str]:
|
|
274
|
+
"""Parse a markdown list section."""
|
|
275
|
+
pattern = rf'## {section_name}[^\n]*\n((?:- .+\n?)+)'
|
|
276
|
+
if match := re.search(pattern, content):
|
|
277
|
+
items = []
|
|
278
|
+
for line in match.group(1).strip().split('\n'):
|
|
279
|
+
if line.startswith('- ') and line != '- (none yet)':
|
|
280
|
+
items.append(line[2:].strip())
|
|
281
|
+
return items
|
|
282
|
+
return []
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def load_state(path: Path = None) -> Optional[RalphState]:
|
|
286
|
+
"""Load state from .ralph_state.md file."""
|
|
287
|
+
path = path or Path('.ralph_state.md')
|
|
288
|
+
if not path.exists():
|
|
289
|
+
return None
|
|
290
|
+
return RalphState.from_markdown(path.read_text())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def save_state(state: RalphState, path: Path = None) -> None:
|
|
294
|
+
"""Save state to .ralph_state.md file."""
|
|
295
|
+
path = path or Path('.ralph_state.md')
|
|
296
|
+
state.updated_at = datetime.now().isoformat()
|
|
297
|
+
path.write_text(state.to_markdown())
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def is_ralph_session() -> bool:
|
|
301
|
+
"""Check if current directory has an ACTIVE Ralph session.
|
|
302
|
+
|
|
303
|
+
Checks for both:
|
|
304
|
+
- .claude/ralph-loop.local.md (ralph-wiggum plugin)
|
|
305
|
+
- .ralph_state.md (our custom state file)
|
|
306
|
+
|
|
307
|
+
Returns False if file exists but active: false.
|
|
308
|
+
"""
|
|
309
|
+
for path in [Path('.claude/ralph-loop.local.md'), Path('.ralph_state.md')]:
|
|
310
|
+
if path.exists():
|
|
311
|
+
try:
|
|
312
|
+
content = path.read_text()
|
|
313
|
+
# Check for active: false explicitly
|
|
314
|
+
if 'active: false' in content:
|
|
315
|
+
return False
|
|
316
|
+
# Check for active: true or assume active if file exists without active flag
|
|
317
|
+
if 'active: true' in content or 'active:' not in content:
|
|
318
|
+
return True
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_ralph_state_path() -> Optional[Path]:
|
|
325
|
+
"""Get the path to the active Ralph state file.
|
|
326
|
+
|
|
327
|
+
Priority:
|
|
328
|
+
1. .claude/ralph-loop.local.md (ralph-wiggum plugin)
|
|
329
|
+
2. .ralph_state.md (custom state)
|
|
330
|
+
"""
|
|
331
|
+
ralph_wiggum_path = Path('.claude/ralph-loop.local.md')
|
|
332
|
+
custom_path = Path('.ralph_state.md')
|
|
333
|
+
|
|
334
|
+
if ralph_wiggum_path.exists():
|
|
335
|
+
return ralph_wiggum_path
|
|
336
|
+
if custom_path.exists():
|
|
337
|
+
return custom_path
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def parse_ralph_wiggum_state(path: Path) -> Optional[RalphState]:
|
|
342
|
+
"""Parse ralph-wiggum's .claude/ralph-loop.local.md format.
|
|
343
|
+
|
|
344
|
+
The format is:
|
|
345
|
+
---
|
|
346
|
+
active: true
|
|
347
|
+
iteration: 1
|
|
348
|
+
max_iterations: 50
|
|
349
|
+
completion_promise: "COMPLETE"
|
|
350
|
+
started_at: "2026-01-04T04:25:46Z"
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
Task description follows...
|
|
354
|
+
"""
|
|
355
|
+
content = path.read_text()
|
|
356
|
+
|
|
357
|
+
state = RalphState()
|
|
358
|
+
|
|
359
|
+
# Parse YAML frontmatter
|
|
360
|
+
import re
|
|
361
|
+
frontmatter_match = re.search(r'^---\n(.+?)\n---\n(.+)', content, re.DOTALL)
|
|
362
|
+
if not frontmatter_match:
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
frontmatter = frontmatter_match.group(1)
|
|
366
|
+
task_content = frontmatter_match.group(2).strip()
|
|
367
|
+
|
|
368
|
+
# Parse frontmatter fields
|
|
369
|
+
if match := re.search(r'iteration:\s*(\d+)', frontmatter):
|
|
370
|
+
state.iteration = int(match.group(1))
|
|
371
|
+
if match := re.search(r'max_iterations:\s*(\d+)', frontmatter):
|
|
372
|
+
# Store for reference but not in RalphState dataclass
|
|
373
|
+
pass
|
|
374
|
+
if match := re.search(r'completion_promise:\s*["\']?(.+?)["\']?\s*$', frontmatter, re.MULTILINE):
|
|
375
|
+
state.completion_promise = match.group(1).strip('"\'')
|
|
376
|
+
if match := re.search(r'started_at:\s*["\']?(.+?)["\']?\s*$', frontmatter, re.MULTILINE):
|
|
377
|
+
state.started_at = match.group(1).strip('"\'')
|
|
378
|
+
|
|
379
|
+
# Task is the content after frontmatter
|
|
380
|
+
state.task = task_content[:500] # First 500 chars as task summary
|
|
381
|
+
|
|
382
|
+
# Generate session ID from file
|
|
383
|
+
state.session_id = f"ralph_wiggum_{state.started_at.replace(':', '').replace('-', '')[:15]}"
|
|
384
|
+
|
|
385
|
+
return state
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def load_ralph_session_state() -> Optional[RalphState]:
|
|
389
|
+
"""Load Ralph state from whichever format is available.
|
|
390
|
+
|
|
391
|
+
Automatically detects and parses:
|
|
392
|
+
- .claude/ralph-loop.local.md (ralph-wiggum format)
|
|
393
|
+
- .ralph_state.md (our custom format)
|
|
394
|
+
"""
|
|
395
|
+
path = get_ralph_state_path()
|
|
396
|
+
if not path:
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
if path.name == 'ralph-loop.local.md':
|
|
400
|
+
return parse_ralph_wiggum_state(path)
|
|
401
|
+
else:
|
|
402
|
+
return load_state(path)
|