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,254 @@
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
+ # IMPORTANT: __csr_hook_auto__ is a distinct signature that identifies
145
+ # hook-stored reflections vs manually-stored ones during development
146
+ tags = [
147
+ "__csr_hook_auto__", # Hook signature - don't manually use this tag!
148
+ "ralph_session",
149
+ f"session_{state.session_id}",
150
+ f"outcome_{outcome.lower()}",
151
+ f"iterations_{state.iteration}",
152
+ f"work_type_{work_type.lower()}"
153
+ ]
154
+
155
+ # Rich metadata for better search filtering
156
+ metadata = {
157
+ "outcome": outcome,
158
+ "iterations": state.iteration,
159
+ "work_type": work_type,
160
+ "exit_confidence": exit_confidence,
161
+ "output_declining": output_declining,
162
+ "error_signatures": list(error_signatures.keys()),
163
+ "failed_approaches": state.failed_approaches,
164
+ "successful_strategies": state.successful_strategies,
165
+ "learnings": state.learnings,
166
+ "files_modified": state.files_modified,
167
+ }
168
+
169
+ # Note: CSR store_reflection may not support metadata yet,
170
+ # but we include the rich info in the narrative for searchability
171
+ # Use hook-specific collection so random agents don't pollute it
172
+ client.store_reflection(
173
+ content=narrative,
174
+ tags=tags,
175
+ collection="csr_hook_sessions_local" # Separate from user reflections
176
+ )
177
+
178
+ logger.info(f"Stored session narrative: {outcome}, {state.iteration} iterations, confidence={exit_confidence}%")
179
+
180
+ # If successful, also store the winning strategy separately
181
+ if outcome == "COMPLETED" and state.successful_strategies:
182
+ success_summary = f"""Successful Ralph approach for '{state.task[:100]}':
183
+ Approach: {state.current_approach}
184
+ Key strategies: {', '.join(state.successful_strategies[:5])}
185
+ Exit confidence: {exit_confidence}%
186
+ Iterations: {state.iteration}
187
+ """
188
+ client.store_reflection(
189
+ content=success_summary,
190
+ tags=["__csr_hook_auto__", "ralph_success", "winning_strategy", f"work_type_{work_type.lower()}"],
191
+ collection="csr_hook_sessions_local"
192
+ )
193
+
194
+ return True
195
+
196
+ except ImportError:
197
+ logger.warning("CSR standalone client not available")
198
+ return False
199
+ except Exception as e:
200
+ logger.error(f"Error storing narrative: {e}")
201
+ return False
202
+
203
+
204
+ def cleanup_session_files():
205
+ """Clean up temporary session files."""
206
+ files_to_remove = [
207
+ Path('.ralph_past_sessions.md'),
208
+ Path('.ralph_memories.md')
209
+ ]
210
+
211
+ for f in files_to_remove:
212
+ if f.exists():
213
+ try:
214
+ f.unlink()
215
+ logger.info(f"Cleaned up: {f}")
216
+ except Exception as e:
217
+ logger.warning(f"Could not remove {f}: {e}")
218
+
219
+
220
+ def main():
221
+ """Main hook entry point."""
222
+ # Read hook input from stdin
223
+ try:
224
+ input_data = json.load(sys.stdin)
225
+ except (json.JSONDecodeError, EOFError):
226
+ input_data = {}
227
+
228
+ session_id = input_data.get('session_id', 'unknown')
229
+ reason = input_data.get('reason', 'other')
230
+
231
+ logger.info(f"SessionEnd hook triggered: reason={reason}")
232
+
233
+ # Check if this is a Ralph session
234
+ if not is_ralph_session():
235
+ sys.exit(0)
236
+
237
+ # Load state (supports both ralph-wiggum and custom formats)
238
+ state = load_ralph_session_state()
239
+ if not state:
240
+ logger.warning("Could not load Ralph state for narrative storage")
241
+ sys.exit(0)
242
+
243
+ # Store narrative to CSR
244
+ store_session_narrative(state, session_id, reason)
245
+
246
+ # Note: Don't clean up .ralph_state.md - it may be needed for resume
247
+ # Only clean up helper files
248
+ cleanup_session_files()
249
+
250
+ sys.exit(0)
251
+
252
+
253
+ if __name__ == '__main__':
254
+ main()
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Ralph SessionStart Hook - Searches CSR for relevant past sessions.
4
+
5
+ Triggered at session start. Uses existing CSR infrastructure to search
6
+ for relevant past Ralph sessions and injects context.
7
+
8
+ Enhanced Features (v7.1+):
9
+ - Error-centric search (find solutions to current blockers)
10
+ - Anti-pattern injection (surface failed approaches first)
11
+ - Winning strategy prioritization
12
+ - Multi-signal search (task + errors + patterns)
13
+
14
+ Input (stdin): JSON with session_id, transcript_path, source
15
+ Output (stdout): Context message for Claude
16
+ Exit code: 0 = success, 2 = blocking error
17
+
18
+ Attribution:
19
+ Search patterns inspired by https://github.com/frankbria/ralph-claude-code
20
+ """
21
+
22
+ import sys
23
+ import json
24
+ import logging
25
+ from pathlib import Path
26
+ from datetime import datetime
27
+
28
+ # Add project root to path
29
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
30
+
31
+ from src.runtime.hooks.ralph_state import (
32
+ load_state,
33
+ save_state,
34
+ RalphState,
35
+ is_ralph_session,
36
+ load_ralph_session_state,
37
+ )
38
+
39
+ logging.basicConfig(level=logging.INFO)
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def get_project_root() -> Path:
44
+ """Dynamically determine project root (works for any installation)."""
45
+ # This file is at: <project_root>/src/runtime/hooks/session_start_hook.py
46
+ return Path(__file__).parent.parent.parent.parent
47
+
48
+
49
+ def _error_signature(error: str) -> str:
50
+ """Extract error signature for deduplication (matches RalphState method)."""
51
+ import re
52
+ sig = re.sub(r'line \d+', 'line N', error)
53
+ sig = re.sub(r'/[\w/.-]+/', '/.../', sig)
54
+ sig = re.sub(r'\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}', 'TIMESTAMP', sig)
55
+ return sig[:100]
56
+
57
+
58
+ def search_past_sessions(task: str, errors: list = None, limit: int = 3) -> dict:
59
+ """Enhanced search: task + errors + anti-patterns."""
60
+ results = {
61
+ 'similar_tasks': [],
62
+ 'similar_errors': [],
63
+ 'anti_patterns': [],
64
+ 'winning_strategies': []
65
+ }
66
+
67
+ try:
68
+ # Import CSR standalone client (dynamic path for any installation)
69
+ project_root = get_project_root()
70
+ mcp_server_path = project_root / "mcp-server" / "src"
71
+ if str(mcp_server_path) not in sys.path:
72
+ sys.path.insert(0, str(mcp_server_path))
73
+ from standalone_client import CSRStandaloneClient
74
+ client = CSRStandaloneClient()
75
+
76
+ # 1. Task-based search (existing behavior)
77
+ task_results = client.search(
78
+ query=f"ralph session: {task}",
79
+ limit=2,
80
+ min_score=0.5
81
+ )
82
+ results['similar_tasks'] = task_results
83
+
84
+ # 2. NEW: Error-based search (if we have current errors)
85
+ if errors:
86
+ for error in errors[:2]: # Top 2 errors only
87
+ sig = _error_signature(error)
88
+ error_results = client.search(
89
+ query=f"error blocked solved: {sig}",
90
+ limit=1,
91
+ min_score=0.6
92
+ )
93
+ results['similar_errors'].extend(error_results)
94
+
95
+ # 3. NEW: Anti-patterns search (failed approaches from incomplete sessions)
96
+ anti_results = client.search(
97
+ query=f"failed approach don't retry: {task}",
98
+ limit=2,
99
+ min_score=0.5
100
+ )
101
+ # Filter for incomplete/abandoned sessions
102
+ results['anti_patterns'] = [
103
+ r for r in anti_results
104
+ if r.get('metadata', {}).get('outcome') in ('INCOMPLETE', 'ABANDONED')
105
+ ]
106
+
107
+ # 4. NEW: Winning strategies (successful sessions)
108
+ winners = client.search(
109
+ query=f"successful solution completed: {task}",
110
+ limit=1,
111
+ min_score=0.6
112
+ )
113
+ results['winning_strategies'] = [
114
+ r for r in winners
115
+ if r.get('metadata', {}).get('outcome') == 'COMPLETED'
116
+ ]
117
+
118
+ return results
119
+
120
+ except ImportError:
121
+ logger.warning("CSR standalone client not available, skipping memory search")
122
+ return results
123
+ except Exception as e:
124
+ logger.error(f"Error searching CSR: {e}")
125
+ return results
126
+
127
+
128
+ def format_past_sessions(results: dict) -> str:
129
+ """Format search results with anti-patterns FIRST for fast loop efficiency."""
130
+ # Handle legacy list format
131
+ if isinstance(results, list):
132
+ results = {'similar_tasks': results}
133
+
134
+ has_content = any([
135
+ results.get('anti_patterns'),
136
+ results.get('winning_strategies'),
137
+ results.get('similar_errors'),
138
+ results.get('similar_tasks')
139
+ ])
140
+
141
+ if not has_content:
142
+ return ""
143
+
144
+ output = ["# Ralph Memory (from CSR)\n"]
145
+ output.append("Use these insights to avoid repeating mistakes.\n")
146
+
147
+ # 1. ANTI-PATTERNS FIRST (most important for fast loops)
148
+ if results.get('anti_patterns'):
149
+ output.append("## DON'T RETRY THESE")
150
+ for r in results['anti_patterns']:
151
+ # Extract failed approaches from metadata
152
+ failed = r.get('metadata', {}).get('failed_approaches', [])
153
+ if failed:
154
+ for f in failed[:3]:
155
+ output.append(f"- {f}")
156
+ else:
157
+ # Fallback to content preview
158
+ content = r.get('content', r.get('preview', ''))[:150]
159
+ output.append(f"- {content}...")
160
+
161
+ # 2. WINNING STRATEGIES (proven approaches)
162
+ if results.get('winning_strategies'):
163
+ output.append("\n## PROVEN APPROACHES")
164
+ for r in results['winning_strategies']:
165
+ strategies = r.get('metadata', {}).get('successful_strategies', [])
166
+ if strategies:
167
+ for s in strategies[:3]:
168
+ output.append(f"- {s}")
169
+ else:
170
+ content = r.get('content', r.get('preview', ''))[:150]
171
+ output.append(f"- {content}...")
172
+
173
+ # 3. PAST ERROR SOLUTIONS
174
+ if results.get('similar_errors'):
175
+ output.append("\n## PAST ERROR SOLUTIONS")
176
+ for r in results['similar_errors']:
177
+ score = r.get('score', 0)
178
+ content = r.get('content', r.get('preview', ''))[:200]
179
+ output.append(f"- (score: {score:.2f}) {content}...")
180
+
181
+ # 4. SIMILAR TASKS (context, less actionable)
182
+ if results.get('similar_tasks'):
183
+ output.append("\n## RELATED SESSIONS")
184
+ for i, r in enumerate(results['similar_tasks'][:2], 1):
185
+ score = r.get('score', 0)
186
+ outcome = r.get('metadata', {}).get('outcome', 'Unknown')
187
+ content = r.get('content', r.get('preview', ''))[:150]
188
+ output.append(f"- [{outcome}] (score: {score:.2f}) {content}...")
189
+
190
+ # Extract key learnings
191
+ if learnings := r.get('metadata', {}).get('learnings'):
192
+ for learning in learnings[:2]:
193
+ output.append(f" - Learning: {learning}")
194
+
195
+ return "\n".join(output)
196
+
197
+
198
+ def main():
199
+ """Main hook entry point."""
200
+ # Read hook input from stdin (official protocol)
201
+ try:
202
+ input_data = json.load(sys.stdin)
203
+ except (json.JSONDecodeError, EOFError):
204
+ input_data = {}
205
+
206
+ session_id = input_data.get('session_id', '')
207
+ source = input_data.get('source', 'startup')
208
+
209
+ logger.info(f"SessionStart hook triggered: source={source}, session_id={session_id[:20] if session_id else 'none'}...")
210
+
211
+ # Check if this is a Ralph session
212
+ if not is_ralph_session():
213
+ logger.info("No Ralph session detected (checked .claude/ralph-loop.local.md and .ralph_state.md)")
214
+ sys.exit(0)
215
+
216
+ # Load current state (supports both ralph-wiggum and custom formats)
217
+ state = load_ralph_session_state()
218
+ if not state:
219
+ logger.warning("Could not load Ralph state")
220
+ sys.exit(0)
221
+
222
+ # Search for past sessions with similar task AND current errors
223
+ logger.info(f"Searching CSR for past sessions related to: {state.task[:50]}...")
224
+ results = search_past_sessions(
225
+ task=state.task,
226
+ errors=state.blocking_errors if hasattr(state, 'blocking_errors') else None
227
+ )
228
+
229
+ # Count total results
230
+ total_results = sum([
231
+ len(results.get('similar_tasks', [])),
232
+ len(results.get('similar_errors', [])),
233
+ len(results.get('anti_patterns', [])),
234
+ len(results.get('winning_strategies', []))
235
+ ])
236
+
237
+ if total_results > 0:
238
+ # Write context file for Claude to read
239
+ context = format_past_sessions(results)
240
+ past_sessions_path = Path('.ralph_past_sessions.md')
241
+ past_sessions_path.write_text(context)
242
+
243
+ # Log breakdown
244
+ logger.info(f"Found {total_results} relevant results:")
245
+ logger.info(f" - Anti-patterns: {len(results.get('anti_patterns', []))}")
246
+ logger.info(f" - Winning strategies: {len(results.get('winning_strategies', []))}")
247
+ logger.info(f" - Error matches: {len(results.get('similar_errors', []))}")
248
+ logger.info(f" - Similar tasks: {len(results.get('similar_tasks', []))}")
249
+
250
+ print(f"# Loaded {total_results} relevant past sessions")
251
+ print(f"# See .ralph_past_sessions.md for details")
252
+ else:
253
+ logger.info("No relevant past sessions found")
254
+
255
+ sys.exit(0)
256
+
257
+
258
+ if __name__ == '__main__':
259
+ main()
@@ -2,9 +2,28 @@
2
2
  # PreCompact hook for Claude Self-Reflect
3
3
  # Place this in ~/.claude/hooks/precompact or source it from there
4
4
 
5
- # Configuration
6
- CLAUDE_REFLECT_DIR="${CLAUDE_REFLECT_DIR:-$HOME/claude-self-reflect}"
7
- VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/.venv}"
5
+ # Determine project root dynamically from this script's location
6
+ # This file is at: <project_root>/src/runtime/precompact-hook.sh
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ DEFAULT_PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
9
+
10
+ # Configuration (allows override via environment)
11
+ CLAUDE_REFLECT_DIR="${CLAUDE_REFLECT_DIR:-$DEFAULT_PROJECT_ROOT}"
12
+
13
+ # SECURITY: Validate CLAUDE_REFLECT_DIR to prevent directory traversal
14
+ if [[ "$CLAUDE_REFLECT_DIR" =~ \.\. ]]; then
15
+ echo "Error: CLAUDE_REFLECT_DIR contains directory traversal" >&2
16
+ exit 0 # Exit gracefully, don't block compacting
17
+ fi
18
+
19
+ # Support both .venv and venv directories
20
+ if [ -d "$CLAUDE_REFLECT_DIR/.venv" ]; then
21
+ VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/.venv}"
22
+ elif [ -d "$CLAUDE_REFLECT_DIR/venv" ]; then
23
+ VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/venv}"
24
+ else
25
+ VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/.venv}"
26
+ fi
8
27
  IMPORT_TIMEOUT="${IMPORT_TIMEOUT:-30}"
9
28
 
10
29
  # Check if Claude Self-Reflect is installed
@@ -19,6 +38,17 @@ if [ ! -d "$VENV_PATH" ]; then
19
38
  exit 0 # Exit gracefully
20
39
  fi
21
40
 
41
+ # SECURITY: Validate VENV_PATH to prevent command injection
42
+ if [[ ! "$VENV_PATH" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then
43
+ echo "Error: Invalid VENV_PATH contains unsafe characters" >&2
44
+ exit 0 # Exit gracefully, don't block compacting
45
+ fi
46
+
47
+ if [ ! -x "$VENV_PATH/bin/python3" ]; then
48
+ echo "Error: Python not found at $VENV_PATH/bin/python3" >&2
49
+ exit 0 # Exit gracefully
50
+ fi
51
+
22
52
  # Run quick import with timeout
23
53
  echo "Updating conversation memory..." >&2
24
54
  timeout $IMPORT_TIMEOUT bash -c "
@@ -29,5 +59,54 @@ timeout $IMPORT_TIMEOUT bash -c "
29
59
  echo "Quick import timed out after ${IMPORT_TIMEOUT}s" >&2
30
60
  }
31
61
 
62
+ # ============================================================
63
+ # RALPH MEMORY INTEGRATION (Added for Memory-Augmented Ralph)
64
+ # ============================================================
65
+
66
+ # If Ralph session, backup state to CSR before compaction
67
+ # Check both ralph-wiggum format and custom format
68
+ RALPH_STATE_FILE=""
69
+ if [ -f ".claude/ralph-loop.local.md" ]; then
70
+ RALPH_STATE_FILE=".claude/ralph-loop.local.md"
71
+ elif [ -f ".ralph_state.md" ]; then
72
+ RALPH_STATE_FILE=".ralph_state.md"
73
+ fi
74
+
75
+ if [ -n "$RALPH_STATE_FILE" ]; then
76
+ echo "📝 Backing up Ralph state to CSR..." >&2
77
+
78
+ RALPH_STATE_FILE="$RALPH_STATE_FILE" CLAUDE_REFLECT_DIR="$CLAUDE_REFLECT_DIR" "$VENV_PATH/bin/python3" << 'PYTHON' || echo "Warning: Could not backup Ralph state" >&2
79
+ import sys
80
+ import os
81
+ from pathlib import Path
82
+ from datetime import datetime
83
+
84
+ # Dynamic path resolution
85
+ project_root = os.environ.get('CLAUDE_REFLECT_DIR', str(Path.home() / 'claude-self-reflect'))
86
+ sys.path.insert(0, str(Path(project_root) / 'mcp-server' / 'src'))
87
+
88
+ try:
89
+ import re
90
+ from standalone_client import CSRStandaloneClient
91
+
92
+ ralph_state_file = os.environ.get('RALPH_STATE_FILE', '.ralph_state.md')
93
+ state_content = Path(ralph_state_file).read_text()
94
+ # SECURITY: Sanitize project name to prevent injection
95
+ project_name = re.sub(r'[^a-zA-Z0-9_-]', '_', Path.cwd().name)
96
+ client = CSRStandaloneClient()
97
+
98
+ client.store_reflection(
99
+ content=f"Pre-compaction Ralph state backup ({datetime.now().isoformat()}):\n\nProject: {project_name}\n\n{state_content}",
100
+ tags=["__csr_hook_auto__", f"project_{project_name}", "ralph_state", "pre_compact_backup"],
101
+ collection="csr_hook_sessions_local"
102
+ )
103
+ print(f"✅ Ralph state ({project_name}) backed up to CSR", file=sys.stderr)
104
+ except ImportError:
105
+ print("CSR client not available, skipping backup", file=sys.stderr)
106
+ except Exception as e:
107
+ print(f"Backup failed: {e}", file=sys.stderr)
108
+ PYTHON
109
+ fi
110
+
32
111
  # Always exit successfully to not block compacting
33
112
  exit 0
@@ -336,6 +336,10 @@ class UnifiedStateManager:
336
336
  except Exception as e:
337
337
  raise ValueError(f"Invalid path: {file_path}: {e}")
338
338
 
339
+ # Detect if running in Docker by checking for Docker-mounted paths
340
+ docker_paths = ["/logs", "/config", "/app/data"]
341
+ in_docker = any(Path(dp).exists() for dp in docker_paths)
342
+
339
343
  # Docker to local path mappings
340
344
  path_mappings = [
341
345
  ("/logs/", "/.claude/projects/"),
@@ -343,8 +347,15 @@ class UnifiedStateManager:
343
347
  ("/app/data/", "/.claude/projects/")
344
348
  ]
345
349
 
346
- # Apply Docker mappings if needed
350
+ # In Docker: validate against Docker paths BEFORE transformation
351
+ # This ensures security validation uses paths that actually exist
347
352
  path_str = str(resolved)
353
+ original_path_str = path_str # Keep original for Docker validation
354
+
355
+ # Check if this is a Docker path that we should validate directly
356
+ is_docker_path = in_docker and any(path_str.startswith(dp) for dp in docker_paths)
357
+
358
+ # Apply Docker mappings for storage consistency (not for validation)
348
359
  for docker_path, local_path in path_mappings:
349
360
  if path_str.startswith(docker_path):
350
361
  home = str(Path.home())
@@ -359,21 +370,35 @@ class UnifiedStateManager:
359
370
  ]
360
371
 
361
372
  # Add Docker paths if they exist
362
- for docker_path in ["/logs", "/config", "/app/data"]:
373
+ for docker_path in docker_paths:
363
374
  docker_base = Path(docker_path)
364
375
  if docker_base.exists():
365
376
  allowed_bases.append(docker_base)
366
377
 
367
378
  # Check if path is within allowed directories
368
379
  path_allowed = False
369
- for base in allowed_bases:
370
- try:
371
- if base.exists():
372
- resolved.relative_to(base)
373
- path_allowed = True
374
- break
375
- except ValueError:
376
- continue
380
+
381
+ # In Docker: validate original path against Docker mounts
382
+ if is_docker_path:
383
+ original_resolved = Path(original_path_str).resolve()
384
+ for base in allowed_bases:
385
+ try:
386
+ if base.exists():
387
+ original_resolved.relative_to(base)
388
+ path_allowed = True
389
+ break
390
+ except ValueError:
391
+ continue
392
+ else:
393
+ # Local environment: validate transformed path
394
+ for base in allowed_bases:
395
+ try:
396
+ if base.exists():
397
+ resolved.relative_to(base)
398
+ path_allowed = True
399
+ break
400
+ except ValueError:
401
+ continue
377
402
 
378
403
  # Allow test paths when running tests
379
404
  if not path_allowed: