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