claude-self-reflect 3.3.1 → 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.
- package/.claude/agents/claude-self-reflect-test.md +107 -8
- package/.claude/agents/quality-fixer.md +314 -0
- package/.claude/agents/reflection-specialist.md +40 -1
- package/mcp-server/run-mcp.sh +20 -6
- package/mcp-server/src/code_reload_tool.py +271 -0
- package/mcp-server/src/embedding_manager.py +60 -26
- package/mcp-server/src/enhanced_tool_registry.py +407 -0
- package/mcp-server/src/mode_switch_tool.py +181 -0
- package/mcp-server/src/parallel_search.py +8 -3
- package/mcp-server/src/project_resolver.py +20 -2
- package/mcp-server/src/reflection_tools.py +50 -8
- package/mcp-server/src/rich_formatting.py +103 -0
- package/mcp-server/src/search_tools.py +90 -37
- package/mcp-server/src/security_patches.py +555 -0
- package/mcp-server/src/server.py +318 -240
- package/mcp-server/src/status.py +13 -8
- package/mcp-server/src/test_quality.py +153 -0
- package/package.json +1 -1
- package/scripts/ast_grep_final_analyzer.py +5 -2
- package/scripts/ast_grep_unified_registry.py +170 -16
- package/scripts/csr-status +190 -45
- package/scripts/import-conversations-unified.py +10 -5
- package/scripts/session_quality_tracker.py +221 -41
package/mcp-server/src/status.py
CHANGED
|
@@ -135,13 +135,15 @@ def get_status() -> dict:
|
|
|
135
135
|
# The actual structure has imported_files at the top level
|
|
136
136
|
imported_files = data.get("imported_files", {})
|
|
137
137
|
|
|
138
|
-
# Count all files in imported_files object (they
|
|
138
|
+
# Count all files in imported_files object (only if they still exist on disk)
|
|
139
139
|
for file_path in imported_files.keys():
|
|
140
140
|
normalized_path = normalize_file_path(file_path)
|
|
141
141
|
if normalized_path in file_to_project and normalized_path not in counted_files:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
# Verify file actually exists before counting it as indexed
|
|
143
|
+
if Path(normalized_path).exists():
|
|
144
|
+
project_name = file_to_project[normalized_path]
|
|
145
|
+
project_stats[project_name]["indexed"] += 1
|
|
146
|
+
counted_files.add(normalized_path)
|
|
145
147
|
|
|
146
148
|
# Also check file_metadata for partially imported files
|
|
147
149
|
file_metadata = data.get("file_metadata", {})
|
|
@@ -180,14 +182,17 @@ def get_status() -> dict:
|
|
|
180
182
|
with open(watcher_state_file, 'r') as f:
|
|
181
183
|
watcher_data = json.load(f)
|
|
182
184
|
|
|
183
|
-
# Count files imported by the watcher
|
|
185
|
+
# Count files imported by the watcher (only if they still exist on disk)
|
|
184
186
|
watcher_imports = watcher_data.get("imported_files", {})
|
|
185
187
|
for file_path in watcher_imports.keys():
|
|
186
188
|
normalized_path = normalize_file_path(file_path)
|
|
189
|
+
# CRITICAL: Only count if file exists on disk AND is in our project list
|
|
187
190
|
if normalized_path in file_to_project and normalized_path not in counted_files:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
+
# Verify file actually exists before counting it as indexed
|
|
192
|
+
if Path(normalized_path).exists():
|
|
193
|
+
project_name = file_to_project[normalized_path]
|
|
194
|
+
project_stats[project_name]["indexed"] += 1
|
|
195
|
+
counted_files.add(normalized_path)
|
|
191
196
|
except (json.JSONDecodeError, KeyError, OSError):
|
|
192
197
|
# If watcher file is corrupted or unreadable, continue
|
|
193
198
|
pass
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Test file with intentional quality issues for testing quality-fixer agent.
|
|
4
|
+
This file contains patterns that should be fixed:
|
|
5
|
+
- sync file operations that should be async
|
|
6
|
+
- global variables
|
|
7
|
+
- print statements
|
|
8
|
+
- long functions
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import json
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import aiofiles
|
|
16
|
+
from typing import List, Dict, Any
|
|
17
|
+
|
|
18
|
+
# Set up logger instead of print statements
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Configuration management class instead of global variables
|
|
22
|
+
class ConfigManager:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.config = None
|
|
25
|
+
self.counter = 0
|
|
26
|
+
|
|
27
|
+
async def load_config(config_manager: ConfigManager) -> Dict[str, Any]:
|
|
28
|
+
"""Load config using async file operations."""
|
|
29
|
+
# Async file operation using aiofiles
|
|
30
|
+
async with aiofiles.open("config.json", "r") as f:
|
|
31
|
+
content = await f.read()
|
|
32
|
+
config_manager.config = json.loads(content)
|
|
33
|
+
|
|
34
|
+
logger.info(f"Config loaded: {config_manager.config}")
|
|
35
|
+
return config_manager.config
|
|
36
|
+
|
|
37
|
+
async def save_data(data: Dict[str, Any], config_manager: ConfigManager) -> None:
|
|
38
|
+
"""Save data using async operations."""
|
|
39
|
+
config_manager.counter += 1
|
|
40
|
+
|
|
41
|
+
# Async file operation using aiofiles
|
|
42
|
+
async with aiofiles.open("data.json", "w") as f:
|
|
43
|
+
await f.write(json.dumps(data))
|
|
44
|
+
|
|
45
|
+
logger.info(f"Data saved, counter: {config_manager.counter}")
|
|
46
|
+
|
|
47
|
+
def validate_items(items: List[str]) -> List[str]:
|
|
48
|
+
"""Validate input items."""
|
|
49
|
+
valid_items = []
|
|
50
|
+
for item in items:
|
|
51
|
+
if not item:
|
|
52
|
+
logger.warning(f"Invalid item: {item}")
|
|
53
|
+
continue
|
|
54
|
+
valid_items.append(item)
|
|
55
|
+
return valid_items
|
|
56
|
+
|
|
57
|
+
def process_items(items: List[str]) -> List[str]:
|
|
58
|
+
"""Process each item."""
|
|
59
|
+
return [item.upper() for item in items]
|
|
60
|
+
|
|
61
|
+
def filter_results(results: List[str]) -> List[str]:
|
|
62
|
+
"""Filter results by length."""
|
|
63
|
+
return [result for result in results if len(result) > 3]
|
|
64
|
+
|
|
65
|
+
def create_summary(items: List[str], results: List[str], filtered: List[str]) -> Dict[str, int]:
|
|
66
|
+
"""Create processing summary."""
|
|
67
|
+
return {
|
|
68
|
+
"total": len(items),
|
|
69
|
+
"processed": len(results),
|
|
70
|
+
"filtered": len(filtered)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async def save_results(filtered: List[str]) -> None:
|
|
74
|
+
"""Save results to file asynchronously."""
|
|
75
|
+
async with aiofiles.open("results.txt", "w") as f:
|
|
76
|
+
for item in filtered:
|
|
77
|
+
await f.write(f"{item}\n")
|
|
78
|
+
|
|
79
|
+
async def process_items_improved(items: List[str], config_manager: ConfigManager) -> Dict[str, Any]:
|
|
80
|
+
"""Improved function broken down into smaller functions."""
|
|
81
|
+
# Step 1: Validate items
|
|
82
|
+
valid_items = validate_items(items)
|
|
83
|
+
|
|
84
|
+
# Step 2: Process each item
|
|
85
|
+
results = process_items(valid_items)
|
|
86
|
+
|
|
87
|
+
# Step 3: Filter results
|
|
88
|
+
filtered = filter_results(results)
|
|
89
|
+
|
|
90
|
+
# Step 4: Sort results
|
|
91
|
+
filtered.sort()
|
|
92
|
+
|
|
93
|
+
# Step 5: Create summary
|
|
94
|
+
summary = create_summary(items, results, filtered)
|
|
95
|
+
|
|
96
|
+
# Step 6: Log summary
|
|
97
|
+
logger.info(f"Processing complete: {summary}")
|
|
98
|
+
|
|
99
|
+
# Step 7: Save results asynchronously
|
|
100
|
+
await save_results(filtered)
|
|
101
|
+
|
|
102
|
+
# Step 8: Update counter
|
|
103
|
+
config_manager.counter += len(filtered)
|
|
104
|
+
|
|
105
|
+
# Step 9: Create report
|
|
106
|
+
report = {
|
|
107
|
+
"summary": summary,
|
|
108
|
+
"results": filtered,
|
|
109
|
+
"counter": config_manager.counter
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return report
|
|
113
|
+
|
|
114
|
+
async def debug_function() -> None:
|
|
115
|
+
"""Function with debug statements."""
|
|
116
|
+
logger.debug("Debug: Starting function")
|
|
117
|
+
|
|
118
|
+
# Reading file asynchronously
|
|
119
|
+
if os.path.exists("debug.log"):
|
|
120
|
+
async with aiofiles.open("debug.log", "r") as f:
|
|
121
|
+
log_data = await f.read()
|
|
122
|
+
logger.debug(f"Log data: {log_data}")
|
|
123
|
+
|
|
124
|
+
logger.debug("Debug: Function complete")
|
|
125
|
+
|
|
126
|
+
# Using var instead of let/const (for JS patterns if analyzed)
|
|
127
|
+
var_example = "This would be flagged in JS"
|
|
128
|
+
|
|
129
|
+
async def main() -> None:
|
|
130
|
+
"""Main execution function."""
|
|
131
|
+
# Set up logging
|
|
132
|
+
logging.basicConfig(level=logging.INFO)
|
|
133
|
+
|
|
134
|
+
# Initialize config manager
|
|
135
|
+
config_manager = ConfigManager()
|
|
136
|
+
|
|
137
|
+
logger.info("Starting application...")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
# Note: These operations would fail without actual files, but structure is correct
|
|
141
|
+
await load_config(config_manager)
|
|
142
|
+
await process_items_improved(["test", "data", "example"], config_manager)
|
|
143
|
+
await debug_function()
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
logger.warning("Required files not found - this is expected in test context")
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(f"Application error: {e}")
|
|
148
|
+
|
|
149
|
+
logger.info("Application complete!")
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
# Run async main function
|
|
153
|
+
asyncio.run(main())
|
package/package.json
CHANGED
|
@@ -50,6 +50,9 @@ class FinalASTGrepAnalyzer:
|
|
|
50
50
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
51
51
|
content = f.read()
|
|
52
52
|
|
|
53
|
+
# Count lines of code for normalization
|
|
54
|
+
lines_of_code = len(content.splitlines())
|
|
55
|
+
|
|
53
56
|
# Create SgRoot for the detected language
|
|
54
57
|
sg_language = self._get_sg_language(language)
|
|
55
58
|
root = sg.SgRoot(content, sg_language)
|
|
@@ -105,8 +108,8 @@ class FinalASTGrepAnalyzer:
|
|
|
105
108
|
'error': str(e)[:200]
|
|
106
109
|
})
|
|
107
110
|
|
|
108
|
-
# Calculate quality score
|
|
109
|
-
quality_score = self.registry.calculate_quality_score(all_matches)
|
|
111
|
+
# Calculate quality score with LOC normalization
|
|
112
|
+
quality_score = self.registry.calculate_quality_score(all_matches, loc=lines_of_code)
|
|
110
113
|
|
|
111
114
|
# Count good vs bad patterns
|
|
112
115
|
good_matches = [m for m in all_matches if m['quality'] == 'good']
|
|
@@ -59,6 +59,9 @@ class UnifiedASTGrepRegistry:
|
|
|
59
59
|
# JavaScript patterns (shared with TS)
|
|
60
60
|
patterns.update(self._load_javascript_patterns())
|
|
61
61
|
|
|
62
|
+
# Shell script patterns
|
|
63
|
+
patterns.update(self._load_shell_patterns())
|
|
64
|
+
|
|
62
65
|
return patterns
|
|
63
66
|
|
|
64
67
|
def _load_python_patterns(self) -> Dict[str, List[Dict[str, Any]]]:
|
|
@@ -224,6 +227,41 @@ class UnifiedASTGrepRegistry:
|
|
|
224
227
|
"quality": "bad",
|
|
225
228
|
"weight": -4,
|
|
226
229
|
"language": "python"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"id": "sync-voyage-embed",
|
|
233
|
+
"pattern": "$CLIENT.embed($$$)",
|
|
234
|
+
"description": "Blocking Voyage embed in async context",
|
|
235
|
+
"quality": "bad",
|
|
236
|
+
"weight": -5,
|
|
237
|
+
"language": "python",
|
|
238
|
+
"inside": "async def $FUNC($$$): $$$"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"id": "thread-join-async",
|
|
242
|
+
"pattern": "$THREAD.join($$$)",
|
|
243
|
+
"description": "Thread join blocking async context",
|
|
244
|
+
"quality": "bad",
|
|
245
|
+
"weight": -5,
|
|
246
|
+
"language": "python",
|
|
247
|
+
"inside": "async def $FUNC($$$): $$$"
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
"id": "invalid-env-var-hyphen",
|
|
251
|
+
"pattern": "os.getenv('$VAR')",
|
|
252
|
+
"description": "Environment variable with hyphen (invalid in shells)",
|
|
253
|
+
"quality": "bad",
|
|
254
|
+
"weight": -3,
|
|
255
|
+
"language": "python",
|
|
256
|
+
"constraint": "$VAR matches .*-.*"
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
"id": "dotenv-override-runtime",
|
|
260
|
+
"pattern": "load_dotenv($$$, override=True)",
|
|
261
|
+
"description": "Runtime environment mutation in MCP",
|
|
262
|
+
"quality": "bad",
|
|
263
|
+
"weight": -3,
|
|
264
|
+
"language": "python"
|
|
227
265
|
}
|
|
228
266
|
],
|
|
229
267
|
"python_qdrant": [
|
|
@@ -268,6 +306,50 @@ class UnifiedASTGrepRegistry:
|
|
|
268
306
|
"quality": "good",
|
|
269
307
|
"weight": 5,
|
|
270
308
|
"language": "python"
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
"id": "missing-embedding-guard",
|
|
312
|
+
"pattern": "query_embedding = await $MGR.generate_embedding($$$)\n$$$\nawait $CLIENT.search($$$, query_vector=query_embedding, $$$)",
|
|
313
|
+
"description": "Missing None check after embedding generation",
|
|
314
|
+
"quality": "bad",
|
|
315
|
+
"weight": -4,
|
|
316
|
+
"language": "python"
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
"id": "attr-vs-api",
|
|
320
|
+
"pattern": "$MGR.model_name",
|
|
321
|
+
"description": "Accessing non-existent attribute instead of API",
|
|
322
|
+
"quality": "bad",
|
|
323
|
+
"weight": -3,
|
|
324
|
+
"language": "python",
|
|
325
|
+
"note": "Use get_model_info() instead"
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
"id": "duplicate-import",
|
|
329
|
+
"pattern": "import $MODULE\n$$$\ndef $FUNC($$$):\n $$$\n import $MODULE",
|
|
330
|
+
"description": "Duplicate import inside function",
|
|
331
|
+
"quality": "bad",
|
|
332
|
+
"weight": -2,
|
|
333
|
+
"language": "python"
|
|
334
|
+
}
|
|
335
|
+
],
|
|
336
|
+
"python_runtime_modification": [
|
|
337
|
+
{
|
|
338
|
+
"id": "singleton-state-change",
|
|
339
|
+
"pattern": "$SINGLETON.$ATTR = $VALUE",
|
|
340
|
+
"description": "Runtime singleton state modification",
|
|
341
|
+
"quality": "neutral",
|
|
342
|
+
"weight": 0,
|
|
343
|
+
"language": "python",
|
|
344
|
+
"note": "Can be good for mode switching, bad if uncontrolled"
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
"id": "public-init-exposure",
|
|
348
|
+
"pattern": "def try_initialize_$TYPE(self): $$$",
|
|
349
|
+
"description": "Public initialization method for runtime config",
|
|
350
|
+
"quality": "neutral",
|
|
351
|
+
"weight": 0,
|
|
352
|
+
"language": "python"
|
|
271
353
|
}
|
|
272
354
|
]
|
|
273
355
|
}
|
|
@@ -386,6 +468,48 @@ class UnifiedASTGrepRegistry:
|
|
|
386
468
|
]
|
|
387
469
|
}
|
|
388
470
|
|
|
471
|
+
def _load_shell_patterns(self) -> Dict[str, List[Dict[str, Any]]]:
|
|
472
|
+
"""Shell script patterns."""
|
|
473
|
+
return {
|
|
474
|
+
"shell_env_handling": [
|
|
475
|
+
{
|
|
476
|
+
"id": "unused-shell-var",
|
|
477
|
+
"pattern": "$VAR=\"$VALUE\"",
|
|
478
|
+
"description": "Assigned but never referenced variable",
|
|
479
|
+
"quality": "bad",
|
|
480
|
+
"weight": -2,
|
|
481
|
+
"language": "bash",
|
|
482
|
+
"note": "Check if variable is used later"
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
"id": "unsafe-var-check",
|
|
486
|
+
"pattern": "[ ! -z \"$VAR\" ]",
|
|
487
|
+
"description": "Unsafe variable check (breaks with set -u)",
|
|
488
|
+
"quality": "bad",
|
|
489
|
+
"weight": -3,
|
|
490
|
+
"language": "bash",
|
|
491
|
+
"fix": "[ -n \"${VAR:-}\" ]"
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
"id": "redundant-export",
|
|
495
|
+
"pattern": "export $VAR=\"$VAR\"",
|
|
496
|
+
"description": "Redundant export of same value",
|
|
497
|
+
"quality": "bad",
|
|
498
|
+
"weight": -2,
|
|
499
|
+
"language": "bash"
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
"id": "missing-safety-flags",
|
|
503
|
+
"pattern": "#!/bin/bash",
|
|
504
|
+
"description": "Missing safety flags",
|
|
505
|
+
"quality": "bad",
|
|
506
|
+
"weight": -3,
|
|
507
|
+
"language": "bash",
|
|
508
|
+
"note": "Add 'set -euo pipefail' after shebang"
|
|
509
|
+
}
|
|
510
|
+
]
|
|
511
|
+
}
|
|
512
|
+
|
|
389
513
|
def _load_javascript_patterns(self) -> Dict[str, List[Dict[str, Any]]]:
|
|
390
514
|
"""JavaScript patterns (subset of TypeScript)."""
|
|
391
515
|
return {
|
|
@@ -466,26 +590,56 @@ class UnifiedASTGrepRegistry:
|
|
|
466
590
|
"""Get only bad quality patterns (anti-patterns)."""
|
|
467
591
|
return [p for p in self.get_all_patterns() if p.get('quality') == 'bad']
|
|
468
592
|
|
|
469
|
-
def calculate_quality_score(self, matches: List[Dict]) -> float:
|
|
593
|
+
def calculate_quality_score(self, matches: List[Dict], loc: int = 1000) -> float:
|
|
470
594
|
"""
|
|
471
|
-
Calculate quality score based
|
|
472
|
-
|
|
595
|
+
Calculate quality score using penalty-based approach.
|
|
596
|
+
Issues dominate the score; good patterns provide minimal bonus.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
matches: List of pattern matches with weight and count
|
|
600
|
+
loc: Lines of code (for normalization)
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
Score from 0.0 to 1.0
|
|
473
604
|
"""
|
|
474
|
-
|
|
475
|
-
|
|
605
|
+
import math
|
|
606
|
+
|
|
607
|
+
# Normalize to KLOC (thousands of lines)
|
|
608
|
+
kloc = max(1.0, loc / 1000.0)
|
|
609
|
+
|
|
610
|
+
# Separate issues (bad) from good patterns
|
|
611
|
+
issues = [m for m in matches if m.get('quality') == 'bad']
|
|
612
|
+
good_patterns = [m for m in matches if m.get('quality') == 'good']
|
|
613
|
+
|
|
614
|
+
# Calculate severity-weighted issue density
|
|
615
|
+
total_issues = 0
|
|
616
|
+
for issue in issues:
|
|
617
|
+
severity = abs(issue.get('weight', 1)) # Use weight as severity
|
|
618
|
+
count = issue.get('count', 0)
|
|
619
|
+
total_issues += severity * count
|
|
620
|
+
|
|
621
|
+
issues_per_kloc = total_issues / kloc
|
|
622
|
+
|
|
623
|
+
# Penalty calculation (logarithmic to avoid linear dominance)
|
|
624
|
+
# Calibrated so 50 issues/KLOC = ~50% penalty
|
|
625
|
+
penalty = min(0.7, 0.15 * math.log1p(issues_per_kloc))
|
|
626
|
+
|
|
627
|
+
# Small bonus for good patterns (capped at 5%)
|
|
628
|
+
good_score = 0
|
|
629
|
+
if good_patterns:
|
|
630
|
+
for pattern in good_patterns:
|
|
631
|
+
weight = pattern.get('weight', 1)
|
|
632
|
+
count = pattern.get('count', 0)
|
|
633
|
+
# Cap contribution per pattern type
|
|
634
|
+
normalized_count = min(count / kloc, 50) # Max 50 per KLOC
|
|
635
|
+
good_score += weight * normalized_count / 1000
|
|
476
636
|
|
|
477
|
-
|
|
478
|
-
weight = match.get('weight', 0)
|
|
479
|
-
count = match.get('count', 0)
|
|
480
|
-
total_weight += weight * count
|
|
481
|
-
total_count += abs(weight) * count
|
|
637
|
+
bonus = min(0.05, good_score) # Cap at 5% bonus
|
|
482
638
|
|
|
483
|
-
|
|
484
|
-
|
|
639
|
+
# Final score: start at 100%, subtract penalty, add small bonus
|
|
640
|
+
score = max(0.0, min(1.0, 1.0 - penalty + bonus))
|
|
485
641
|
|
|
486
|
-
|
|
487
|
-
normalized = (total_weight + 100) / 200
|
|
488
|
-
return max(0.0, min(1.0, normalized))
|
|
642
|
+
return score
|
|
489
643
|
|
|
490
644
|
def export_to_json(self, path: str):
|
|
491
645
|
"""Export registry to JSON file."""
|
|
@@ -545,7 +699,7 @@ if __name__ == "__main__":
|
|
|
545
699
|
print(f" - {category}: {count} patterns")
|
|
546
700
|
|
|
547
701
|
# Export to JSON
|
|
548
|
-
export_path = "
|
|
702
|
+
export_path = Path(__file__).parent / "unified_registry.json"
|
|
549
703
|
registry.export_to_json(export_path)
|
|
550
704
|
print(f"\n✅ Exported unified registry to {export_path}")
|
|
551
705
|
|