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.
@@ -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()