claude-self-reflect 7.0.0 → 7.1.8
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 +59 -0
- 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/standalone_client.py +314 -0
- package/package.json +1 -1
- package/src/runtime/hooks/__init__.py +21 -0
- package/src/runtime/hooks/ralph_state.py +397 -0
- package/src/runtime/hooks/session_end_hook.py +245 -0
- package/src/runtime/hooks/session_start_hook.py +259 -0
- package/src/runtime/precompact-hook.sh +60 -3
- package/src/runtime/unified_state_manager.py +35 -10
|
@@ -0,0 +1,397 @@
|
|
|
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 to_markdown(self) -> str:
|
|
123
|
+
"""Convert state to markdown format for .ralph_state.md"""
|
|
124
|
+
# Format error signatures for display
|
|
125
|
+
error_sig_display = ""
|
|
126
|
+
if self.error_signatures:
|
|
127
|
+
error_sig_display = "\n".join(
|
|
128
|
+
f"- `{sig}` (x{count})" for sig, count in self.error_signatures.items()
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
error_sig_display = "- (none yet)"
|
|
132
|
+
|
|
133
|
+
return f"""# Ralph Session State
|
|
134
|
+
|
|
135
|
+
## Metadata
|
|
136
|
+
- **Session ID:** {self.session_id}
|
|
137
|
+
- **Task:** {self.task}
|
|
138
|
+
- **Iteration:** {self.iteration}
|
|
139
|
+
- **Started:** {self.started_at}
|
|
140
|
+
- **Updated:** {self.updated_at}
|
|
141
|
+
- **Work Type:** {self.work_type or 'UNKNOWN'}
|
|
142
|
+
- **Exit Confidence:** {self.exit_confidence}%
|
|
143
|
+
|
|
144
|
+
## Current Approach
|
|
145
|
+
{self.current_approach}
|
|
146
|
+
|
|
147
|
+
## Completion Promise
|
|
148
|
+
`{self.completion_promise}`
|
|
149
|
+
Met: {self.completion_promise_met}
|
|
150
|
+
|
|
151
|
+
## Failed Approaches (DO NOT RETRY)
|
|
152
|
+
{self._list_to_md(self.failed_approaches)}
|
|
153
|
+
|
|
154
|
+
## Blocking Errors
|
|
155
|
+
{self._list_to_md(self.blocking_errors)}
|
|
156
|
+
|
|
157
|
+
## Error Signatures (Deduplicated)
|
|
158
|
+
{error_sig_display}
|
|
159
|
+
|
|
160
|
+
## Successful Strategies
|
|
161
|
+
{self._list_to_md(self.successful_strategies)}
|
|
162
|
+
|
|
163
|
+
## Files Modified
|
|
164
|
+
{self._list_to_md(self.files_modified)}
|
|
165
|
+
|
|
166
|
+
## Learnings
|
|
167
|
+
{self._list_to_md(self.learnings)}
|
|
168
|
+
|
|
169
|
+
## Output Tracking
|
|
170
|
+
- Recent lengths: {self.output_lengths[-5:] if self.output_lengths else []}
|
|
171
|
+
- Declining: {self.output_declining()}
|
|
172
|
+
|
|
173
|
+
## Next Action
|
|
174
|
+
{self.next_action}
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def _list_to_md(self, items: List[str]) -> str:
|
|
178
|
+
"""Convert list to markdown bullet points."""
|
|
179
|
+
if not items:
|
|
180
|
+
return "- (none yet)"
|
|
181
|
+
return "\n".join(f"- {item}" for item in items)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def create_new(cls, task: str, completion_promise: str, session_id: str = None) -> 'RalphState':
|
|
185
|
+
"""Create a new state for a fresh Ralph session."""
|
|
186
|
+
import uuid
|
|
187
|
+
return cls(
|
|
188
|
+
session_id=session_id or f"ralph_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}",
|
|
189
|
+
task=task,
|
|
190
|
+
iteration=1,
|
|
191
|
+
started_at=datetime.now().isoformat(),
|
|
192
|
+
updated_at=datetime.now().isoformat(),
|
|
193
|
+
completion_promise=completion_promise
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_markdown(cls, content: str) -> 'RalphState':
|
|
198
|
+
"""Parse markdown content into RalphState object."""
|
|
199
|
+
state = cls()
|
|
200
|
+
|
|
201
|
+
# Parse metadata
|
|
202
|
+
if match := re.search(r'\*\*Session ID:\*\*\s*(.+)', content):
|
|
203
|
+
state.session_id = match.group(1).strip()
|
|
204
|
+
if match := re.search(r'\*\*Task:\*\*\s*(.+)', content):
|
|
205
|
+
state.task = match.group(1).strip()
|
|
206
|
+
if match := re.search(r'\*\*Iteration:\*\*\s*(\d+)', content):
|
|
207
|
+
state.iteration = int(match.group(1))
|
|
208
|
+
if match := re.search(r'\*\*Started:\*\*\s*(.+)', content):
|
|
209
|
+
state.started_at = match.group(1).strip()
|
|
210
|
+
if match := re.search(r'\*\*Updated:\*\*\s*(.+)', content):
|
|
211
|
+
state.updated_at = match.group(1).strip()
|
|
212
|
+
|
|
213
|
+
# NEW: Parse work type and exit confidence
|
|
214
|
+
if match := re.search(r'\*\*Work Type:\*\*\s*(.+)', content):
|
|
215
|
+
state.work_type = match.group(1).strip()
|
|
216
|
+
if match := re.search(r'\*\*Exit Confidence:\*\*\s*(\d+)', content):
|
|
217
|
+
state.exit_confidence = int(match.group(1))
|
|
218
|
+
|
|
219
|
+
# Parse completion promise
|
|
220
|
+
if match := re.search(r'## Completion Promise\n`(.+)`', content):
|
|
221
|
+
state.completion_promise = match.group(1)
|
|
222
|
+
if 'Met: True' in content:
|
|
223
|
+
state.completion_promise_met = True
|
|
224
|
+
|
|
225
|
+
# Parse current approach
|
|
226
|
+
if match := re.search(r'## Current Approach\n(.+?)(?=\n##|\Z)', content, re.DOTALL):
|
|
227
|
+
state.current_approach = match.group(1).strip()
|
|
228
|
+
|
|
229
|
+
# Parse next action
|
|
230
|
+
if match := re.search(r'## Next Action\n(.+?)(?=\n##|\Z)', content, re.DOTALL):
|
|
231
|
+
state.next_action = match.group(1).strip()
|
|
232
|
+
|
|
233
|
+
# Parse lists
|
|
234
|
+
state.failed_approaches = cls._parse_list_section(content, "Failed Approaches")
|
|
235
|
+
state.blocking_errors = cls._parse_list_section(content, "Blocking Errors")
|
|
236
|
+
state.successful_strategies = cls._parse_list_section(content, "Successful Strategies")
|
|
237
|
+
state.files_modified = cls._parse_list_section(content, "Files Modified")
|
|
238
|
+
state.learnings = cls._parse_list_section(content, "Learnings")
|
|
239
|
+
|
|
240
|
+
# NEW: Parse error signatures
|
|
241
|
+
state.error_signatures = cls._parse_error_signatures(content)
|
|
242
|
+
|
|
243
|
+
# NEW: Parse output lengths
|
|
244
|
+
if match := re.search(r'Recent lengths:\s*\[([^\]]*)\]', content):
|
|
245
|
+
try:
|
|
246
|
+
lengths_str = match.group(1).strip()
|
|
247
|
+
if lengths_str:
|
|
248
|
+
state.output_lengths = [int(x.strip()) for x in lengths_str.split(',') if x.strip()]
|
|
249
|
+
except ValueError:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
return state
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _parse_error_signatures(content: str) -> Dict[str, int]:
|
|
256
|
+
"""Parse error signatures section."""
|
|
257
|
+
signatures = {}
|
|
258
|
+
pattern = r'## Error Signatures[^\n]*\n((?:- .+\n?)+)'
|
|
259
|
+
if match := re.search(pattern, content):
|
|
260
|
+
for line in match.group(1).strip().split('\n'):
|
|
261
|
+
# Format: - `signature` (xN)
|
|
262
|
+
sig_match = re.search(r'- `(.+?)` \(x(\d+)\)', line)
|
|
263
|
+
if sig_match:
|
|
264
|
+
signatures[sig_match.group(1)] = int(sig_match.group(2))
|
|
265
|
+
return signatures
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _parse_list_section(content: str, section_name: str) -> List[str]:
|
|
269
|
+
"""Parse a markdown list section."""
|
|
270
|
+
pattern = rf'## {section_name}[^\n]*\n((?:- .+\n?)+)'
|
|
271
|
+
if match := re.search(pattern, content):
|
|
272
|
+
items = []
|
|
273
|
+
for line in match.group(1).strip().split('\n'):
|
|
274
|
+
if line.startswith('- ') and line != '- (none yet)':
|
|
275
|
+
items.append(line[2:].strip())
|
|
276
|
+
return items
|
|
277
|
+
return []
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def load_state(path: Path = None) -> Optional[RalphState]:
|
|
281
|
+
"""Load state from .ralph_state.md file."""
|
|
282
|
+
path = path or Path('.ralph_state.md')
|
|
283
|
+
if not path.exists():
|
|
284
|
+
return None
|
|
285
|
+
return RalphState.from_markdown(path.read_text())
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def save_state(state: RalphState, path: Path = None) -> None:
|
|
289
|
+
"""Save state to .ralph_state.md file."""
|
|
290
|
+
path = path or Path('.ralph_state.md')
|
|
291
|
+
state.updated_at = datetime.now().isoformat()
|
|
292
|
+
path.write_text(state.to_markdown())
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def is_ralph_session() -> bool:
|
|
296
|
+
"""Check if current directory has an ACTIVE Ralph session.
|
|
297
|
+
|
|
298
|
+
Checks for both:
|
|
299
|
+
- .claude/ralph-loop.local.md (ralph-wiggum plugin)
|
|
300
|
+
- .ralph_state.md (our custom state file)
|
|
301
|
+
|
|
302
|
+
Returns False if file exists but active: false.
|
|
303
|
+
"""
|
|
304
|
+
for path in [Path('.claude/ralph-loop.local.md'), Path('.ralph_state.md')]:
|
|
305
|
+
if path.exists():
|
|
306
|
+
try:
|
|
307
|
+
content = path.read_text()
|
|
308
|
+
# Check for active: false explicitly
|
|
309
|
+
if 'active: false' in content:
|
|
310
|
+
return False
|
|
311
|
+
# Check for active: true or assume active if file exists without active flag
|
|
312
|
+
if 'active: true' in content or 'active:' not in content:
|
|
313
|
+
return True
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def get_ralph_state_path() -> Optional[Path]:
|
|
320
|
+
"""Get the path to the active Ralph state file.
|
|
321
|
+
|
|
322
|
+
Priority:
|
|
323
|
+
1. .claude/ralph-loop.local.md (ralph-wiggum plugin)
|
|
324
|
+
2. .ralph_state.md (custom state)
|
|
325
|
+
"""
|
|
326
|
+
ralph_wiggum_path = Path('.claude/ralph-loop.local.md')
|
|
327
|
+
custom_path = Path('.ralph_state.md')
|
|
328
|
+
|
|
329
|
+
if ralph_wiggum_path.exists():
|
|
330
|
+
return ralph_wiggum_path
|
|
331
|
+
if custom_path.exists():
|
|
332
|
+
return custom_path
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def parse_ralph_wiggum_state(path: Path) -> Optional[RalphState]:
|
|
337
|
+
"""Parse ralph-wiggum's .claude/ralph-loop.local.md format.
|
|
338
|
+
|
|
339
|
+
The format is:
|
|
340
|
+
---
|
|
341
|
+
active: true
|
|
342
|
+
iteration: 1
|
|
343
|
+
max_iterations: 50
|
|
344
|
+
completion_promise: "COMPLETE"
|
|
345
|
+
started_at: "2026-01-04T04:25:46Z"
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
Task description follows...
|
|
349
|
+
"""
|
|
350
|
+
content = path.read_text()
|
|
351
|
+
|
|
352
|
+
state = RalphState()
|
|
353
|
+
|
|
354
|
+
# Parse YAML frontmatter
|
|
355
|
+
import re
|
|
356
|
+
frontmatter_match = re.search(r'^---\n(.+?)\n---\n(.+)', content, re.DOTALL)
|
|
357
|
+
if not frontmatter_match:
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
frontmatter = frontmatter_match.group(1)
|
|
361
|
+
task_content = frontmatter_match.group(2).strip()
|
|
362
|
+
|
|
363
|
+
# Parse frontmatter fields
|
|
364
|
+
if match := re.search(r'iteration:\s*(\d+)', frontmatter):
|
|
365
|
+
state.iteration = int(match.group(1))
|
|
366
|
+
if match := re.search(r'max_iterations:\s*(\d+)', frontmatter):
|
|
367
|
+
# Store for reference but not in RalphState dataclass
|
|
368
|
+
pass
|
|
369
|
+
if match := re.search(r'completion_promise:\s*["\']?(.+?)["\']?\s*$', frontmatter, re.MULTILINE):
|
|
370
|
+
state.completion_promise = match.group(1).strip('"\'')
|
|
371
|
+
if match := re.search(r'started_at:\s*["\']?(.+?)["\']?\s*$', frontmatter, re.MULTILINE):
|
|
372
|
+
state.started_at = match.group(1).strip('"\'')
|
|
373
|
+
|
|
374
|
+
# Task is the content after frontmatter
|
|
375
|
+
state.task = task_content[:500] # First 500 chars as task summary
|
|
376
|
+
|
|
377
|
+
# Generate session ID from file
|
|
378
|
+
state.session_id = f"ralph_wiggum_{state.started_at.replace(':', '').replace('-', '')[:15]}"
|
|
379
|
+
|
|
380
|
+
return state
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def load_ralph_session_state() -> Optional[RalphState]:
|
|
384
|
+
"""Load Ralph state from whichever format is available.
|
|
385
|
+
|
|
386
|
+
Automatically detects and parses:
|
|
387
|
+
- .claude/ralph-loop.local.md (ralph-wiggum format)
|
|
388
|
+
- .ralph_state.md (our custom format)
|
|
389
|
+
"""
|
|
390
|
+
path = get_ralph_state_path()
|
|
391
|
+
if not path:
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
if path.name == 'ralph-loop.local.md':
|
|
395
|
+
return parse_ralph_wiggum_state(path)
|
|
396
|
+
else:
|
|
397
|
+
return load_state(path)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Ralph SessionEnd Hook - Stores session narrative to CSR.
|
|
4
|
+
|
|
5
|
+
Triggered at session end. Parses .ralph_state.md, determines outcome,
|
|
6
|
+
and stores narrative with metadata for future sessions.
|
|
7
|
+
|
|
8
|
+
Enhanced Features (v7.1+):
|
|
9
|
+
- Structured status block extraction
|
|
10
|
+
- Rich metadata storage (work type, confidence, error signatures)
|
|
11
|
+
- Output trend tracking for circuit breaker patterns
|
|
12
|
+
|
|
13
|
+
Input (stdin): JSON with session_id, transcript_path, reason
|
|
14
|
+
Output: None (cannot block session end)
|
|
15
|
+
|
|
16
|
+
Attribution:
|
|
17
|
+
Status block format inspired by https://github.com/frankbria/ralph-claude-code
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
|
|
26
|
+
# Add project root to path
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
|
28
|
+
|
|
29
|
+
from src.runtime.hooks.ralph_state import load_state, is_ralph_session, load_ralph_session_state
|
|
30
|
+
|
|
31
|
+
logging.basicConfig(level=logging.INFO)
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_status_block(content: str) -> dict:
|
|
36
|
+
"""Extract ---RALPH_STATUS--- block if present.
|
|
37
|
+
|
|
38
|
+
Format:
|
|
39
|
+
---RALPH_STATUS---
|
|
40
|
+
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
|
|
41
|
+
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION
|
|
42
|
+
EXIT_SIGNAL: true | false
|
|
43
|
+
---END_RALPH_STATUS---
|
|
44
|
+
"""
|
|
45
|
+
import re
|
|
46
|
+
match = re.search(
|
|
47
|
+
r'---RALPH_STATUS---\n(.+?)\n---END_RALPH_STATUS---',
|
|
48
|
+
content, re.DOTALL
|
|
49
|
+
)
|
|
50
|
+
if not match:
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
status = {}
|
|
54
|
+
for line in match.group(1).strip().split('\n'):
|
|
55
|
+
if ':' in line:
|
|
56
|
+
key, val = line.split(':', 1)
|
|
57
|
+
key = key.strip().lower().replace(' ', '_')
|
|
58
|
+
val = val.strip()
|
|
59
|
+
# Convert boolean strings
|
|
60
|
+
if val.lower() in ('true', 'false'):
|
|
61
|
+
val = val.lower() == 'true'
|
|
62
|
+
# Convert numeric strings
|
|
63
|
+
elif val.isdigit():
|
|
64
|
+
val = int(val)
|
|
65
|
+
status[key] = val
|
|
66
|
+
return status
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_project_root() -> Path:
|
|
70
|
+
"""Dynamically determine project root (works for any installation)."""
|
|
71
|
+
# This file is at: <project_root>/src/runtime/hooks/session_end_hook.py
|
|
72
|
+
return Path(__file__).parent.parent.parent.parent
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def store_session_narrative(state, session_id: str, reason: str) -> bool:
|
|
76
|
+
"""Store session narrative to CSR with rich metadata."""
|
|
77
|
+
try:
|
|
78
|
+
# Import CSR standalone client (dynamic path for any installation)
|
|
79
|
+
project_root = get_project_root()
|
|
80
|
+
mcp_server_path = project_root / "mcp-server" / "src"
|
|
81
|
+
if str(mcp_server_path) not in sys.path:
|
|
82
|
+
sys.path.insert(0, str(mcp_server_path))
|
|
83
|
+
from standalone_client import CSRStandaloneClient
|
|
84
|
+
client = CSRStandaloneClient()
|
|
85
|
+
|
|
86
|
+
# Determine outcome
|
|
87
|
+
if state.completion_promise_met:
|
|
88
|
+
outcome = "COMPLETED"
|
|
89
|
+
elif reason in ('clear', 'logout'):
|
|
90
|
+
outcome = "ABANDONED"
|
|
91
|
+
else:
|
|
92
|
+
outcome = "INCOMPLETE"
|
|
93
|
+
|
|
94
|
+
# Get enhanced fields if available (new RalphState fields)
|
|
95
|
+
work_type = getattr(state, 'work_type', '') or 'UNKNOWN'
|
|
96
|
+
exit_confidence = getattr(state, 'exit_confidence', 0)
|
|
97
|
+
error_signatures = getattr(state, 'error_signatures', {})
|
|
98
|
+
# Handle output_declining as either method or boolean safely
|
|
99
|
+
output_declining_attr = getattr(state, 'output_declining', None)
|
|
100
|
+
output_declining = output_declining_attr() if callable(output_declining_attr) else False
|
|
101
|
+
|
|
102
|
+
# Generate narrative with enhanced metadata
|
|
103
|
+
narrative = f"""# Ralph Session Complete
|
|
104
|
+
|
|
105
|
+
## Metadata
|
|
106
|
+
- Session ID: {state.session_id}
|
|
107
|
+
- End Reason: {reason}
|
|
108
|
+
- Timestamp: {datetime.now().isoformat()}
|
|
109
|
+
- Total Iterations: {state.iteration}
|
|
110
|
+
- Work Type: {work_type}
|
|
111
|
+
- Exit Confidence: {exit_confidence}%
|
|
112
|
+
- Output Trend: {"DECLINING" if output_declining else "STABLE"}
|
|
113
|
+
|
|
114
|
+
## Task
|
|
115
|
+
{state.task}
|
|
116
|
+
|
|
117
|
+
## Outcome: {outcome}
|
|
118
|
+
Completion Promise: `{state.completion_promise}`
|
|
119
|
+
Promise Met: {state.completion_promise_met}
|
|
120
|
+
|
|
121
|
+
## Final Approach
|
|
122
|
+
{state.current_approach}
|
|
123
|
+
|
|
124
|
+
## What Worked
|
|
125
|
+
{chr(10).join(f'- {s}' for s in state.successful_strategies) or '- (none recorded)'}
|
|
126
|
+
|
|
127
|
+
## What Failed (Don't Retry These)
|
|
128
|
+
{chr(10).join(f'- {f}' for f in state.failed_approaches) or '- (none recorded)'}
|
|
129
|
+
|
|
130
|
+
## Blocking Errors Encountered
|
|
131
|
+
{chr(10).join(f'- {e}' for e in state.blocking_errors) or '- (none recorded)'}
|
|
132
|
+
|
|
133
|
+
## Error Signatures (Deduplicated)
|
|
134
|
+
{chr(10).join(f'- `{sig}` (x{count})' for sig, count in error_signatures.items()) or '- (none)'}
|
|
135
|
+
|
|
136
|
+
## Key Learnings
|
|
137
|
+
{chr(10).join(f'- {l}' for l in state.learnings) or '- (none recorded)'}
|
|
138
|
+
|
|
139
|
+
## Files Modified
|
|
140
|
+
{chr(10).join(f'- {f}' for f in state.files_modified) or '- (none recorded)'}
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
# Store with outcome-aware tags
|
|
144
|
+
tags = [
|
|
145
|
+
"ralph_session",
|
|
146
|
+
f"session_{state.session_id}",
|
|
147
|
+
f"outcome_{outcome.lower()}",
|
|
148
|
+
f"iterations_{state.iteration}",
|
|
149
|
+
f"work_type_{work_type.lower()}"
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
# Rich metadata for better search filtering
|
|
153
|
+
metadata = {
|
|
154
|
+
"outcome": outcome,
|
|
155
|
+
"iterations": state.iteration,
|
|
156
|
+
"work_type": work_type,
|
|
157
|
+
"exit_confidence": exit_confidence,
|
|
158
|
+
"output_declining": output_declining,
|
|
159
|
+
"error_signatures": list(error_signatures.keys()),
|
|
160
|
+
"failed_approaches": state.failed_approaches,
|
|
161
|
+
"successful_strategies": state.successful_strategies,
|
|
162
|
+
"learnings": state.learnings,
|
|
163
|
+
"files_modified": state.files_modified,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# Note: CSR store_reflection may not support metadata yet,
|
|
167
|
+
# but we include the rich info in the narrative for searchability
|
|
168
|
+
client.store_reflection(content=narrative, tags=tags)
|
|
169
|
+
|
|
170
|
+
logger.info(f"Stored session narrative: {outcome}, {state.iteration} iterations, confidence={exit_confidence}%")
|
|
171
|
+
|
|
172
|
+
# If successful, also store the winning strategy separately
|
|
173
|
+
if outcome == "COMPLETED" and state.successful_strategies:
|
|
174
|
+
success_summary = f"""Successful Ralph approach for '{state.task[:100]}':
|
|
175
|
+
Approach: {state.current_approach}
|
|
176
|
+
Key strategies: {', '.join(state.successful_strategies[:5])}
|
|
177
|
+
Exit confidence: {exit_confidence}%
|
|
178
|
+
Iterations: {state.iteration}
|
|
179
|
+
"""
|
|
180
|
+
client.store_reflection(
|
|
181
|
+
content=success_summary,
|
|
182
|
+
tags=["ralph_success", "winning_strategy", f"work_type_{work_type.lower()}"]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
except ImportError:
|
|
188
|
+
logger.warning("CSR standalone client not available")
|
|
189
|
+
return False
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error(f"Error storing narrative: {e}")
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cleanup_session_files():
|
|
196
|
+
"""Clean up temporary session files."""
|
|
197
|
+
files_to_remove = [
|
|
198
|
+
Path('.ralph_past_sessions.md'),
|
|
199
|
+
Path('.ralph_memories.md')
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
for f in files_to_remove:
|
|
203
|
+
if f.exists():
|
|
204
|
+
try:
|
|
205
|
+
f.unlink()
|
|
206
|
+
logger.info(f"Cleaned up: {f}")
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.warning(f"Could not remove {f}: {e}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main():
|
|
212
|
+
"""Main hook entry point."""
|
|
213
|
+
# Read hook input from stdin
|
|
214
|
+
try:
|
|
215
|
+
input_data = json.load(sys.stdin)
|
|
216
|
+
except (json.JSONDecodeError, EOFError):
|
|
217
|
+
input_data = {}
|
|
218
|
+
|
|
219
|
+
session_id = input_data.get('session_id', 'unknown')
|
|
220
|
+
reason = input_data.get('reason', 'other')
|
|
221
|
+
|
|
222
|
+
logger.info(f"SessionEnd hook triggered: reason={reason}")
|
|
223
|
+
|
|
224
|
+
# Check if this is a Ralph session
|
|
225
|
+
if not is_ralph_session():
|
|
226
|
+
sys.exit(0)
|
|
227
|
+
|
|
228
|
+
# Load state (supports both ralph-wiggum and custom formats)
|
|
229
|
+
state = load_ralph_session_state()
|
|
230
|
+
if not state:
|
|
231
|
+
logger.warning("Could not load Ralph state for narrative storage")
|
|
232
|
+
sys.exit(0)
|
|
233
|
+
|
|
234
|
+
# Store narrative to CSR
|
|
235
|
+
store_session_narrative(state, session_id, reason)
|
|
236
|
+
|
|
237
|
+
# Note: Don't clean up .ralph_state.md - it may be needed for resume
|
|
238
|
+
# Only clean up helper files
|
|
239
|
+
cleanup_session_files()
|
|
240
|
+
|
|
241
|
+
sys.exit(0)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == '__main__':
|
|
245
|
+
main()
|