claude-self-reflect 3.3.0 โ†’ 4.0.0

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,511 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Claude Self-Reflect Status for CC Statusline
4
+ Standalone script that doesn't require venv activation.
5
+ """
6
+
7
+ import json
8
+ import time
9
+ from pathlib import Path
10
+ from datetime import datetime, timedelta
11
+ import sys
12
+
13
+ # Configuration
14
+ CYCLE_FILE = Path.home() / ".claude-self-reflect" / "statusline_cycle.json"
15
+ CYCLE_INTERVAL = 5 # seconds between cycles
16
+
17
+
18
+ def get_import_status():
19
+ """Get current import/indexing status."""
20
+ state_file = Path.home() / ".claude-self-reflect" / "config" / "imported-files.json"
21
+
22
+ if not state_file.exists():
23
+ return "๐Ÿ“š CSR: Not configured"
24
+
25
+ try:
26
+ with open(state_file, 'r') as f:
27
+ state = json.load(f)
28
+
29
+ imported = len(state.get("imported_files", {}))
30
+
31
+ # Count total JSONL files
32
+ claude_dir = Path.home() / ".claude" / "projects"
33
+ total = 0
34
+ if claude_dir.exists():
35
+ for project_dir in claude_dir.iterdir():
36
+ if project_dir.is_dir():
37
+ total += len(list(project_dir.glob("*.jsonl")))
38
+
39
+ if total == 0:
40
+ return "๐Ÿ“š CSR: No files"
41
+
42
+ percent = min(100, (imported / total * 100))
43
+
44
+ # Color coding
45
+ if percent >= 95:
46
+ emoji = "โœ…"
47
+ elif percent >= 50:
48
+ emoji = "๐Ÿ”„"
49
+ else:
50
+ emoji = "โณ"
51
+
52
+ return f"{emoji} CSR: {percent:.0f}% indexed"
53
+
54
+ except Exception:
55
+ return "๐Ÿ“š CSR: Error"
56
+
57
+
58
+ def categorize_issues(file_reports):
59
+ """
60
+ Categorize issues from AST analysis into critical/medium/low.
61
+ """
62
+ critical = 0
63
+ medium = 0
64
+ low = 0
65
+
66
+ for file_path, report in file_reports.items():
67
+ # Only use top_issues for accurate counting (avoid double-counting from recommendations)
68
+ for issue in report.get('top_issues', []):
69
+ severity = issue.get('severity', 'medium')
70
+ count = issue.get('count', 0)
71
+ issue_id = issue.get('id', '').lower()
72
+
73
+ if severity == 'high' or severity == 'critical':
74
+ critical += count
75
+ elif severity == 'medium':
76
+ # Console.log and print statements are low severity
77
+ if 'print' in issue_id or 'console' in issue_id:
78
+ low += count
79
+ else:
80
+ medium += count
81
+ else:
82
+ low += count
83
+
84
+ return critical, medium, low
85
+
86
+
87
+ def get_quality_icon(critical=0, medium=0, low=0):
88
+ """
89
+ Determine quality icon based on issue severity counts.
90
+ """
91
+ # Icon selection based on highest severity present
92
+ if critical > 0:
93
+ if critical >= 10:
94
+ return "๐Ÿ”ด" # Red circle - Critical issues need immediate attention
95
+ else:
96
+ return "๐ŸŸ " # Orange circle - Some critical issues
97
+ elif medium > 0:
98
+ if medium >= 50:
99
+ return "๐ŸŸก" # Yellow circle - Many medium issues
100
+ else:
101
+ return "๐ŸŸข" # Green circle - Few medium issues
102
+ elif low > 0:
103
+ if low >= 100:
104
+ return "โšช" # White circle - Many minor issues (prints)
105
+ else:
106
+ return "โœ…" # Check mark - Only minor issues
107
+ else:
108
+ return "โœจ" # Sparkles - Perfect, no issues
109
+
110
+
111
+ def format_statusline_quality(critical=0, medium=0, low=0):
112
+ """
113
+ Format statusline with colored dot and labeled numbers.
114
+ """
115
+ import os
116
+ icon = get_quality_icon(critical, medium, low)
117
+
118
+ # Check if we should use colors (when in a TTY)
119
+ use_colors = os.isatty(sys.stdout.fileno()) if hasattr(sys.stdout, 'fileno') else False
120
+
121
+ # Build count display with colors if supported
122
+ counts = []
123
+ if critical > 0:
124
+ if use_colors:
125
+ # Use bright red for critical
126
+ counts.append(f"\033[1;31mC:{critical}\033[0m")
127
+ else:
128
+ counts.append(f"C:{critical}")
129
+ if medium > 0:
130
+ if use_colors:
131
+ # Use bright yellow for medium
132
+ counts.append(f"\033[1;33mM:{medium}\033[0m")
133
+ else:
134
+ counts.append(f"M:{medium}")
135
+ if low > 0:
136
+ if use_colors:
137
+ # Use bright white/gray for low
138
+ counts.append(f"\033[1;37mL:{low}\033[0m")
139
+ else:
140
+ counts.append(f"L:{low}")
141
+
142
+ if counts:
143
+ return f"{icon} {' '.join(counts)}"
144
+ else:
145
+ return f"{icon}" # Perfect - no counts needed
146
+
147
+
148
+ def get_session_health():
149
+ """Get cached session health with icon-based quality display."""
150
+ # Check for session edit tracker to show appropriate label
151
+ tracker_file = Path.home() / ".claude-self-reflect" / "current_session_edits.json"
152
+
153
+ # Get quality cache file for current project
154
+ project_name = Path.cwd().name
155
+ cache_file = Path.home() / ".claude-self-reflect" / "quality_cache" / f"{project_name}.json"
156
+
157
+ # Default label prefix
158
+ label_prefix = ""
159
+
160
+ # Check if we have a session tracker with edited files
161
+ if tracker_file.exists():
162
+ try:
163
+ with open(tracker_file, 'r') as f:
164
+ tracker_data = json.load(f)
165
+ edited_files = tracker_data.get('edited_files', [])
166
+ if edited_files:
167
+ # Show session label with file count
168
+ file_count = len(edited_files)
169
+ label_prefix = f"Session ({file_count} file{'s' if file_count > 1 else ''}): "
170
+ except:
171
+ pass
172
+
173
+ if not cache_file.exists():
174
+ # Fall back to import status if no health data
175
+ return get_import_status()
176
+
177
+ try:
178
+ # Check cache age
179
+ mtime = datetime.fromtimestamp(cache_file.stat().st_mtime)
180
+ age = datetime.now() - mtime
181
+
182
+ if age > timedelta(minutes=5):
183
+ # Fall back to import status if stale
184
+ return get_import_status()
185
+
186
+ with open(cache_file, 'r') as f:
187
+ data = json.load(f)
188
+
189
+ if data.get('status') != 'success':
190
+ # Fall back to import status if no session
191
+ return get_import_status()
192
+
193
+ # Extract issue counts by severity
194
+ file_reports = data.get('file_reports', {})
195
+ critical, medium, low = categorize_issues(file_reports)
196
+
197
+ # Use the icon-based display with optional label
198
+ quality_display = format_statusline_quality(critical, medium, low)
199
+
200
+ # Add session label if we have one
201
+ if data.get('scope_label') == 'Session':
202
+ # For session scope, always show the label with counts
203
+ if label_prefix:
204
+ if critical == 0 and medium == 0 and low == 0:
205
+ return f"{label_prefix}0 0 0 {quality_display}"
206
+ else:
207
+ return f"{label_prefix}{critical} {medium} {low} {quality_display}"
208
+ else:
209
+ # Fallback if no tracker file
210
+ return f"Session: {critical} {medium} {low} {quality_display}"
211
+
212
+ return quality_display
213
+
214
+ except Exception:
215
+ return get_import_status()
216
+
217
+
218
+ def get_current_cycle():
219
+ """Determine which metric to show based on cycle."""
220
+ # Read or create cycle state
221
+ cycle_state = {"last_update": 0, "current": "import"}
222
+
223
+ if CYCLE_FILE.exists():
224
+ try:
225
+ with open(CYCLE_FILE, 'r') as f:
226
+ cycle_state = json.load(f)
227
+ except:
228
+ pass
229
+
230
+ # Check if it's time to cycle
231
+ now = time.time()
232
+ if now - cycle_state["last_update"] >= CYCLE_INTERVAL:
233
+ # Toggle between import and health
234
+ cycle_state["current"] = "health" if cycle_state["current"] == "import" else "import"
235
+ cycle_state["last_update"] = now
236
+
237
+ # Save state
238
+ CYCLE_FILE.parent.mkdir(exist_ok=True)
239
+ with open(CYCLE_FILE, 'w') as f:
240
+ json.dump(cycle_state, f)
241
+
242
+ return cycle_state["current"]
243
+
244
+
245
+ def get_compact_status():
246
+ """Get both import and quality in compact format: [100%][๐ŸŸข:A+]"""
247
+ import subprocess
248
+ import os
249
+ import re
250
+ import shutil
251
+
252
+ # Get project-specific status using claude-self-reflect status command
253
+ import_pct = "?"
254
+ time_behind = ""
255
+
256
+ try:
257
+ # Get current working directory to determine project
258
+ cwd = os.getcwd()
259
+ project_name = os.path.basename(cwd)
260
+
261
+ # Get status from claude-self-reflect with secure path
262
+ import shutil
263
+ csr_binary = shutil.which("claude-self-reflect")
264
+ if not csr_binary or not os.path.isfile(csr_binary):
265
+ # Fallback if binary not found
266
+ import_pct = "?"
267
+ return f"[{import_pct}]"
268
+
269
+ result = subprocess.run(
270
+ [csr_binary, "status"],
271
+ capture_output=True,
272
+ text=True,
273
+ timeout=2
274
+ )
275
+
276
+ if result.returncode == 0:
277
+ status_data = json.loads(result.stdout)
278
+
279
+ # Try to find project-specific percentage
280
+ project_pct = None
281
+ encoded_path = None
282
+
283
+ # Try exact project name FIRST
284
+ if project_name in status_data.get("projects", {}):
285
+ project_pct = status_data["projects"][project_name].get("percentage")
286
+ encoded_path = project_name # Use project name for file lookup
287
+
288
+ # Only try encoded path if exact match not found
289
+ elif project_pct is None:
290
+ # Convert path to encoded format
291
+ encoded_path = cwd.replace("/", "-")
292
+ if encoded_path.startswith("-"):
293
+ encoded_path = encoded_path[1:] # Remove leading dash
294
+
295
+ if encoded_path in status_data.get("projects", {}):
296
+ project_pct = status_data["projects"][encoded_path].get("percentage")
297
+
298
+ # Use project percentage if found, otherwise use overall
299
+ if project_pct is not None:
300
+ pct = int(project_pct)
301
+ else:
302
+ pct = int(status_data.get("overall", {}).get("percentage", 0))
303
+
304
+ import_pct = f"{pct}%"
305
+
306
+ # Only show time behind if NOT at 100%
307
+ # This indicates how old the unindexed files are
308
+ if pct < 100:
309
+ # Check for newest unindexed file
310
+ state_file = Path.home() / ".claude-self-reflect" / "config" / "imported-files.json"
311
+ if state_file.exists():
312
+ with open(state_file, 'r') as f:
313
+ state = json.load(f)
314
+
315
+ # Find project directory
316
+ claude_dir = Path.home() / ".claude" / "projects"
317
+ if encoded_path:
318
+ project_dir = claude_dir / encoded_path
319
+ if not project_dir.exists() and not encoded_path.startswith("-"):
320
+ project_dir = claude_dir / f"-{encoded_path}"
321
+
322
+ if project_dir.exists():
323
+ # Find the newest UNINDEXED file
324
+ newest_unindexed_time = None
325
+ for jsonl_file in project_dir.glob("*.jsonl"):
326
+ file_key = str(jsonl_file)
327
+ # Only check unindexed files
328
+ if file_key not in state.get("imported_files", {}):
329
+ file_time = datetime.fromtimestamp(jsonl_file.stat().st_mtime)
330
+ if newest_unindexed_time is None or file_time > newest_unindexed_time:
331
+ newest_unindexed_time = file_time
332
+
333
+ # Calculate how behind we are
334
+ if newest_unindexed_time:
335
+ age = datetime.now() - newest_unindexed_time
336
+ if age < timedelta(minutes=5):
337
+ time_behind = " <5m"
338
+ elif age < timedelta(hours=1):
339
+ time_behind = f" {int(age.total_seconds() / 60)}m"
340
+ elif age < timedelta(days=1):
341
+ time_behind = f" {int(age.total_seconds() / 3600)}h"
342
+ else:
343
+ time_behind = f" {int(age.days)}d"
344
+ except:
345
+ # Fallback to simple file counting
346
+ state_file = Path.home() / ".claude-self-reflect" / "config" / "imported-files.json"
347
+ if state_file.exists():
348
+ try:
349
+ with open(state_file, 'r') as f:
350
+ state = json.load(f)
351
+ imported = len(state.get("imported_files", {}))
352
+
353
+ claude_dir = Path.home() / ".claude" / "projects"
354
+ total = 0
355
+ if claude_dir.exists():
356
+ for project_dir in claude_dir.iterdir():
357
+ if project_dir.is_dir():
358
+ total += len(list(project_dir.glob("*.jsonl")))
359
+
360
+ if total > 0:
361
+ pct = min(100, int(imported / total * 100))
362
+ import_pct = f"{pct}%"
363
+ except:
364
+ pass
365
+
366
+ # Get quality grade - PER PROJECT cache
367
+ # BUG FIX: Cache must be per-project, not global!
368
+ project_name = os.path.basename(os.getcwd())
369
+ # Secure sanitization with whitelist approach
370
+ import re
371
+ safe_project_name = re.sub(r'[^a-zA-Z0-9_-]', '_', project_name)[:100]
372
+ cache_dir = Path.home() / ".claude-self-reflect" / "quality_cache"
373
+ cache_file = cache_dir / f"{safe_project_name}.json"
374
+
375
+ # If the exact cache file doesn't exist, try to find one that ends with this project name
376
+ # This handles cases like "metafora-Atlas-gold.json" for project "Atlas-gold"
377
+ if not cache_file.exists():
378
+ # Look for files ending with the project name
379
+ possible_files = list(cache_dir.glob(f"*-{safe_project_name}.json"))
380
+ if possible_files:
381
+ cache_file = possible_files[0] # Use the first match
382
+
383
+ # Validate cache file path stays within cache directory
384
+ if cache_file.exists() and not str(cache_file.resolve()).startswith(str(cache_dir.resolve())):
385
+ # Security issue - return placeholder
386
+ grade_str = "[...]"
387
+ else:
388
+ cache_file.parent.mkdir(exist_ok=True, parents=True)
389
+ grade_str = ""
390
+
391
+ # Try to get quality data (regenerate if too old or missing)
392
+ quality_valid = False
393
+
394
+ if cache_file.exists():
395
+ try:
396
+ mtime = datetime.fromtimestamp(cache_file.stat().st_mtime)
397
+ age = datetime.now() - mtime
398
+
399
+ # Use quality data up to 30 minutes old for fresher results
400
+ if age < timedelta(minutes=30):
401
+ with open(cache_file, 'r') as f:
402
+ data = json.load(f)
403
+
404
+ if data.get('status') == 'non-code':
405
+ # Non-code project - show documentation indicator
406
+ grade_str = "[๐Ÿ“š:Docs]"
407
+ quality_valid = True
408
+ elif data.get('status') == 'success':
409
+ # Extract issue counts by severity for icon display
410
+ file_reports = data.get('file_reports', {})
411
+ critical, medium, low = categorize_issues(file_reports)
412
+
413
+ # Get icon based on severity
414
+ icon = get_quality_icon(critical, medium, low)
415
+
416
+ # Build compact display with ANSI colors for each severity level
417
+ colored_parts = []
418
+ if critical > 0:
419
+ colored_parts.append(f"\033[31m{critical}\033[0m") # Standard red for critical
420
+ if medium > 0:
421
+ colored_parts.append(f"\033[33m{medium}\033[0m") # Standard yellow for medium
422
+ if low > 0:
423
+ colored_parts.append(f"\033[37m{low}\033[0m") # White/light gray for low
424
+
425
+ # Join with middle dot separator
426
+ if colored_parts:
427
+ grade_str = f"[{icon}:{'ยท'.join(colored_parts)}]"
428
+ else:
429
+ grade_str = f"[{icon}]"
430
+
431
+ quality_valid = True
432
+ except:
433
+ pass
434
+
435
+ # If no valid quality data, show last known value or placeholder
436
+ if not quality_valid and not grade_str:
437
+ # Try to use last known value from cache even if expired
438
+ try:
439
+ if cache_file.exists():
440
+ with open(cache_file, 'r') as f:
441
+ old_data = json.load(f)
442
+ if old_data.get('status') == 'non-code':
443
+ # Non-code project - show documentation indicator
444
+ grade_str = "[๐Ÿ“š:Docs]"
445
+ elif old_data.get('status') == 'success':
446
+ # Extract issue counts by severity for icon display
447
+ file_reports = old_data.get('file_reports', {})
448
+ critical, medium, low = categorize_issues(file_reports)
449
+
450
+ # Get icon based on severity
451
+ icon = get_quality_icon(critical, medium, low)
452
+
453
+ # Build compact display with ANSI colors for each severity level
454
+ colored_parts = []
455
+ if critical > 0:
456
+ colored_parts.append(f"\033[31m{critical}\033[0m") # Standard red for critical
457
+ if medium > 0:
458
+ colored_parts.append(f"\033[33m{medium}\033[0m") # Standard yellow for medium
459
+ if low > 0:
460
+ colored_parts.append(f"\033[37m{low}\033[0m") # White/light gray for low
461
+
462
+ # Join with middle dot separator
463
+ if colored_parts:
464
+ grade_str = f"[{icon}:{'ยท'.join(colored_parts)}]"
465
+ else:
466
+ grade_str = f"[{icon}]"
467
+ else:
468
+ grade_str = "[...]"
469
+ else:
470
+ grade_str = "[...]"
471
+ except:
472
+ grade_str = "[...]"
473
+
474
+ # Add mini progress bar if not 100%
475
+ bar_str = ""
476
+ if import_pct != "?" and import_pct != "100%":
477
+ pct_num = int(import_pct.rstrip('%'))
478
+ filled = int(pct_num * 4 / 100) # 4-char mini bar
479
+ empty = 4 - filled
480
+ bar_str = "โ–ˆ" * filled + "โ–‘" * empty + " "
481
+
482
+ # Return compact format with bar, percentage, time behind, and grade
483
+ return f"[{bar_str}{import_pct}{time_behind}]{grade_str}"
484
+
485
+ def main():
486
+ """Main entry point for CC statusline."""
487
+ # Check for forced mode
488
+ if len(sys.argv) > 1:
489
+ if sys.argv[1] == "--import":
490
+ print(get_import_status())
491
+ elif sys.argv[1] == "--health":
492
+ print(get_session_health())
493
+ elif sys.argv[1] == "--quality-only":
494
+ # Only show quality, not import (to avoid duplication with MCP status)
495
+ health = get_session_health()
496
+ # Only show if it's actual quality data, not fallback to import
497
+ if "Code:" in health:
498
+ print(health)
499
+ elif sys.argv[1] == "--compact":
500
+ print(get_compact_status())
501
+ else:
502
+ # Default to compact mode
503
+ print(get_compact_status())
504
+ return
505
+
506
+ # Default to compact format (no cycling)
507
+ print(get_compact_status())
508
+
509
+
510
+ if __name__ == "__main__":
511
+ main()