claude-mpm 4.1.7__py3-none-any.whl → 4.1.10__py3-none-any.whl

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.
Files changed (109) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/INSTRUCTIONS.md +26 -1
  3. claude_mpm/agents/OUTPUT_STYLE.md +73 -0
  4. claude_mpm/agents/agents_metadata.py +57 -0
  5. claude_mpm/agents/templates/.claude-mpm/memories/README.md +17 -0
  6. claude_mpm/agents/templates/.claude-mpm/memories/engineer_memories.md +3 -0
  7. claude_mpm/agents/templates/agent-manager.json +263 -17
  8. claude_mpm/agents/templates/agent-manager.md +248 -10
  9. claude_mpm/agents/templates/agentic_coder_optimizer.json +222 -0
  10. claude_mpm/agents/templates/code_analyzer.json +18 -8
  11. claude_mpm/agents/templates/engineer.json +1 -1
  12. claude_mpm/agents/templates/logs/prompts/agent_engineer_20250826_014258_728.md +39 -0
  13. claude_mpm/agents/templates/qa.json +1 -1
  14. claude_mpm/agents/templates/research.json +1 -1
  15. claude_mpm/cli/__init__.py +4 -0
  16. claude_mpm/cli/commands/__init__.py +6 -0
  17. claude_mpm/cli/commands/analyze.py +547 -0
  18. claude_mpm/cli/commands/analyze_code.py +524 -0
  19. claude_mpm/cli/commands/configure.py +223 -25
  20. claude_mpm/cli/commands/configure_tui.py +65 -61
  21. claude_mpm/cli/commands/debug.py +1387 -0
  22. claude_mpm/cli/parsers/analyze_code_parser.py +170 -0
  23. claude_mpm/cli/parsers/analyze_parser.py +135 -0
  24. claude_mpm/cli/parsers/base_parser.py +29 -0
  25. claude_mpm/cli/parsers/configure_parser.py +23 -0
  26. claude_mpm/cli/parsers/debug_parser.py +319 -0
  27. claude_mpm/config/socketio_config.py +21 -21
  28. claude_mpm/constants.py +3 -1
  29. claude_mpm/core/framework_loader.py +148 -6
  30. claude_mpm/core/log_manager.py +16 -13
  31. claude_mpm/core/logger.py +1 -1
  32. claude_mpm/core/unified_agent_registry.py +1 -1
  33. claude_mpm/dashboard/.claude-mpm/socketio-instances.json +1 -0
  34. claude_mpm/dashboard/analysis_runner.py +428 -0
  35. claude_mpm/dashboard/static/built/components/activity-tree.js +2 -0
  36. claude_mpm/dashboard/static/built/components/agent-inference.js +1 -1
  37. claude_mpm/dashboard/static/built/components/event-viewer.js +1 -1
  38. claude_mpm/dashboard/static/built/components/file-tool-tracker.js +1 -1
  39. claude_mpm/dashboard/static/built/components/module-viewer.js +1 -1
  40. claude_mpm/dashboard/static/built/components/session-manager.js +1 -1
  41. claude_mpm/dashboard/static/built/components/working-directory.js +1 -1
  42. claude_mpm/dashboard/static/built/dashboard.js +1 -1
  43. claude_mpm/dashboard/static/built/socket-client.js +1 -1
  44. claude_mpm/dashboard/static/css/activity.css +549 -0
  45. claude_mpm/dashboard/static/css/code-tree.css +846 -0
  46. claude_mpm/dashboard/static/css/dashboard.css +245 -0
  47. claude_mpm/dashboard/static/dist/components/activity-tree.js +2 -0
  48. claude_mpm/dashboard/static/dist/components/code-tree.js +2 -0
  49. claude_mpm/dashboard/static/dist/components/code-viewer.js +2 -0
  50. claude_mpm/dashboard/static/dist/components/event-viewer.js +1 -1
  51. claude_mpm/dashboard/static/dist/components/session-manager.js +1 -1
  52. claude_mpm/dashboard/static/dist/components/working-directory.js +1 -1
  53. claude_mpm/dashboard/static/dist/dashboard.js +1 -1
  54. claude_mpm/dashboard/static/dist/socket-client.js +1 -1
  55. claude_mpm/dashboard/static/js/components/activity-tree.js +1139 -0
  56. claude_mpm/dashboard/static/js/components/code-tree.js +1357 -0
  57. claude_mpm/dashboard/static/js/components/code-viewer.js +480 -0
  58. claude_mpm/dashboard/static/js/components/event-viewer.js +11 -0
  59. claude_mpm/dashboard/static/js/components/session-manager.js +40 -4
  60. claude_mpm/dashboard/static/js/components/socket-manager.js +12 -0
  61. claude_mpm/dashboard/static/js/components/ui-state-manager.js +4 -0
  62. claude_mpm/dashboard/static/js/components/working-directory.js +17 -1
  63. claude_mpm/dashboard/static/js/dashboard.js +39 -0
  64. claude_mpm/dashboard/static/js/socket-client.js +414 -20
  65. claude_mpm/dashboard/templates/index.html +184 -4
  66. claude_mpm/hooks/claude_hooks/hook_handler.py +182 -5
  67. claude_mpm/hooks/claude_hooks/installer.py +728 -0
  68. claude_mpm/scripts/claude-hook-handler.sh +161 -0
  69. claude_mpm/scripts/socketio_daemon.py +121 -8
  70. claude_mpm/services/agents/deployment/agent_config_provider.py +127 -27
  71. claude_mpm/services/agents/deployment/agent_lifecycle_manager_refactored.py +2 -2
  72. claude_mpm/services/agents/deployment/agent_record_service.py +1 -2
  73. claude_mpm/services/agents/memory/memory_format_service.py +1 -5
  74. claude_mpm/services/cli/agent_cleanup_service.py +1 -2
  75. claude_mpm/services/cli/agent_dependency_service.py +1 -1
  76. claude_mpm/services/cli/agent_validation_service.py +3 -4
  77. claude_mpm/services/cli/dashboard_launcher.py +2 -3
  78. claude_mpm/services/cli/startup_checker.py +0 -10
  79. claude_mpm/services/core/cache_manager.py +1 -2
  80. claude_mpm/services/core/path_resolver.py +1 -4
  81. claude_mpm/services/core/service_container.py +2 -2
  82. claude_mpm/services/diagnostics/checks/instructions_check.py +2 -5
  83. claude_mpm/services/event_bus/direct_relay.py +98 -20
  84. claude_mpm/services/infrastructure/monitoring/__init__.py +11 -11
  85. claude_mpm/services/infrastructure/monitoring.py +11 -11
  86. claude_mpm/services/project/architecture_analyzer.py +1 -1
  87. claude_mpm/services/project/dependency_analyzer.py +4 -4
  88. claude_mpm/services/project/language_analyzer.py +3 -3
  89. claude_mpm/services/project/metrics_collector.py +3 -6
  90. claude_mpm/services/socketio/handlers/__init__.py +2 -0
  91. claude_mpm/services/socketio/handlers/code_analysis.py +170 -0
  92. claude_mpm/services/socketio/handlers/registry.py +2 -0
  93. claude_mpm/services/socketio/server/connection_manager.py +95 -65
  94. claude_mpm/services/socketio/server/core.py +125 -17
  95. claude_mpm/services/socketio/server/main.py +44 -5
  96. claude_mpm/services/visualization/__init__.py +19 -0
  97. claude_mpm/services/visualization/mermaid_generator.py +938 -0
  98. claude_mpm/tools/__main__.py +208 -0
  99. claude_mpm/tools/code_tree_analyzer.py +778 -0
  100. claude_mpm/tools/code_tree_builder.py +632 -0
  101. claude_mpm/tools/code_tree_events.py +318 -0
  102. claude_mpm/tools/socketio_debug.py +671 -0
  103. {claude_mpm-4.1.7.dist-info → claude_mpm-4.1.10.dist-info}/METADATA +1 -1
  104. {claude_mpm-4.1.7.dist-info → claude_mpm-4.1.10.dist-info}/RECORD +108 -77
  105. claude_mpm/agents/schema/agent_schema.json +0 -314
  106. {claude_mpm-4.1.7.dist-info → claude_mpm-4.1.10.dist-info}/WHEEL +0 -0
  107. {claude_mpm-4.1.7.dist-info → claude_mpm-4.1.10.dist-info}/entry_points.txt +0 -0
  108. {claude_mpm-4.1.7.dist-info → claude_mpm-4.1.10.dist-info}/licenses/LICENSE +0 -0
  109. {claude_mpm-4.1.7.dist-info → claude_mpm-4.1.10.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,632 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Code Tree Builder
4
+ =================
5
+
6
+ WHY: Builds hierarchical file system structure with intelligent filtering
7
+ and incremental processing support for large codebases.
8
+
9
+ DESIGN DECISIONS:
10
+ - Support .gitignore patterns for filtering
11
+ - Incremental processing with resume capability
12
+ - Efficient directory traversal with progress tracking
13
+ - Cache file metadata to detect changes
14
+ """
15
+
16
+ import fnmatch
17
+ import hashlib
18
+ import json
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Set
23
+
24
+ from ..core.logging_config import get_logger
25
+
26
+
27
+ @dataclass
28
+ class FileMetadata:
29
+ """Metadata for a file in the tree."""
30
+
31
+ path: str
32
+ size: int
33
+ modified: float
34
+ hash: Optional[str] = None
35
+ language: Optional[str] = None
36
+
37
+
38
+ @dataclass
39
+ class TreeNode:
40
+ """Node in the file tree."""
41
+
42
+ name: str
43
+ path: str
44
+ type: str # 'file' or 'directory'
45
+ children: List["TreeNode"] = field(default_factory=list)
46
+ metadata: Optional[FileMetadata] = None
47
+
48
+ def to_dict(self) -> Dict[str, Any]:
49
+ """Convert to dictionary representation."""
50
+ result = {"name": self.name, "path": self.path, "type": self.type}
51
+
52
+ if self.metadata:
53
+ result["metadata"] = {
54
+ "size": self.metadata.size,
55
+ "modified": self.metadata.modified,
56
+ "language": self.metadata.language,
57
+ }
58
+
59
+ if self.children:
60
+ result["children"] = [child.to_dict() for child in self.children]
61
+
62
+ return result
63
+
64
+
65
+ class GitignoreParser:
66
+ """Parser for .gitignore patterns.
67
+
68
+ WHY: Respecting .gitignore patterns ensures we don't analyze files
69
+ that shouldn't be included in the codebase analysis.
70
+ """
71
+
72
+ def __init__(self, root_dir: Path):
73
+ self.root_dir = root_dir
74
+ self.patterns = []
75
+ self.logger = get_logger(__name__)
76
+ self._load_patterns()
77
+
78
+ def _load_patterns(self):
79
+ """Load .gitignore patterns from file."""
80
+ gitignore_path = self.root_dir / ".gitignore"
81
+
82
+ if gitignore_path.exists():
83
+ try:
84
+ with open(gitignore_path) as f:
85
+ for line in f:
86
+ line = line.strip()
87
+ # Skip comments and empty lines
88
+ if line and not line.startswith("#"):
89
+ self.patterns.append(line)
90
+
91
+ self.logger.debug(f"Loaded {len(self.patterns)} .gitignore patterns")
92
+ except Exception as e:
93
+ self.logger.warning(f"Failed to load .gitignore: {e}")
94
+
95
+ def should_ignore(self, path: Path) -> bool:
96
+ """Check if path should be ignored based on patterns.
97
+
98
+ Args:
99
+ path: Path to check
100
+
101
+ Returns:
102
+ True if path should be ignored
103
+ """
104
+ # Get relative path from root
105
+ try:
106
+ rel_path = path.relative_to(self.root_dir)
107
+ except ValueError:
108
+ return False
109
+
110
+ rel_str = str(rel_path)
111
+
112
+ for pattern in self.patterns:
113
+ # Handle directory patterns
114
+ if pattern.endswith("/"):
115
+ if path.is_dir() and fnmatch.fnmatch(rel_str, pattern[:-1]):
116
+ return True
117
+ # Handle negation patterns
118
+ elif pattern.startswith("!"):
119
+ if fnmatch.fnmatch(rel_str, pattern[1:]):
120
+ return False
121
+ # Regular patterns
122
+ else:
123
+ if fnmatch.fnmatch(rel_str, pattern):
124
+ return True
125
+ # Also check if any parent directory matches
126
+ for parent in rel_path.parents:
127
+ if fnmatch.fnmatch(str(parent), pattern):
128
+ return True
129
+
130
+ return False
131
+
132
+
133
+ class CodeTreeBuilder:
134
+ """Builds hierarchical code tree with filtering and caching.
135
+
136
+ WHY: Efficient tree building is crucial for large codebases. This class
137
+ handles incremental processing, caching, and intelligent filtering.
138
+ """
139
+
140
+ # Default ignore patterns
141
+ DEFAULT_IGNORE = [
142
+ "__pycache__",
143
+ ".git",
144
+ "node_modules",
145
+ ".venv",
146
+ "venv",
147
+ "env",
148
+ "dist",
149
+ "build",
150
+ ".pytest_cache",
151
+ ".mypy_cache",
152
+ ".tox",
153
+ ".eggs",
154
+ "*.egg-info",
155
+ ".coverage",
156
+ "htmlcov",
157
+ ".hypothesis",
158
+ ".ruff_cache",
159
+ ".DS_Store",
160
+ "Thumbs.db",
161
+ "*.pyc",
162
+ "*.pyo",
163
+ "*.pyd",
164
+ ".Python",
165
+ "*.so",
166
+ "*.dll",
167
+ "*.dylib",
168
+ ".idea",
169
+ ".vscode",
170
+ "*.swp",
171
+ "*.swo",
172
+ "*~",
173
+ ".env",
174
+ ".env.local",
175
+ ]
176
+
177
+ # Language detection by extension
178
+ LANGUAGE_MAP = {
179
+ ".py": "python",
180
+ ".pyw": "python",
181
+ ".pyx": "python",
182
+ ".pxd": "python",
183
+ ".pyi": "python",
184
+ ".js": "javascript",
185
+ ".jsx": "javascript",
186
+ ".ts": "typescript",
187
+ ".tsx": "typescript",
188
+ ".mjs": "javascript",
189
+ ".cjs": "javascript",
190
+ ".java": "java",
191
+ ".kt": "kotlin",
192
+ ".scala": "scala",
193
+ ".go": "go",
194
+ ".rs": "rust",
195
+ ".c": "c",
196
+ ".h": "c",
197
+ ".cpp": "cpp",
198
+ ".cc": "cpp",
199
+ ".cxx": "cpp",
200
+ ".hpp": "cpp",
201
+ ".cs": "csharp",
202
+ ".rb": "ruby",
203
+ ".php": "php",
204
+ ".swift": "swift",
205
+ ".m": "objc",
206
+ ".mm": "objc",
207
+ ".r": "r",
208
+ ".R": "r",
209
+ ".lua": "lua",
210
+ ".pl": "perl",
211
+ ".pm": "perl",
212
+ ".sh": "bash",
213
+ ".bash": "bash",
214
+ ".zsh": "zsh",
215
+ ".fish": "fish",
216
+ ".ps1": "powershell",
217
+ ".psm1": "powershell",
218
+ ".vim": "vim",
219
+ ".el": "elisp",
220
+ ".clj": "clojure",
221
+ ".cljs": "clojure",
222
+ ".ex": "elixir",
223
+ ".exs": "elixir",
224
+ ".erl": "erlang",
225
+ ".hrl": "erlang",
226
+ ".fs": "fsharp",
227
+ ".fsx": "fsharp",
228
+ ".ml": "ocaml",
229
+ ".mli": "ocaml",
230
+ ".dart": "dart",
231
+ ".nim": "nim",
232
+ ".nims": "nim",
233
+ ".zig": "zig",
234
+ ".v": "v",
235
+ ".vv": "v",
236
+ ".sql": "sql",
237
+ ".md": "markdown",
238
+ ".rst": "restructuredtext",
239
+ ".tex": "latex",
240
+ ".json": "json",
241
+ ".xml": "xml",
242
+ ".yaml": "yaml",
243
+ ".yml": "yaml",
244
+ ".toml": "toml",
245
+ ".ini": "ini",
246
+ ".cfg": "ini",
247
+ ".conf": "conf",
248
+ ".dockerfile": "dockerfile",
249
+ ".Dockerfile": "dockerfile",
250
+ ".html": "html",
251
+ ".htm": "html",
252
+ ".css": "css",
253
+ ".scss": "scss",
254
+ ".sass": "sass",
255
+ ".less": "less",
256
+ }
257
+
258
+ def __init__(self, cache_dir: Optional[Path] = None):
259
+ """Initialize tree builder.
260
+
261
+ Args:
262
+ cache_dir: Directory for caching tree data
263
+ """
264
+ self.logger = get_logger(__name__)
265
+ self.cache_dir = cache_dir or Path.home() / ".claude-mpm" / "tree-cache"
266
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
267
+
268
+ # Statistics
269
+ self.stats = {
270
+ "directories_scanned": 0,
271
+ "files_found": 0,
272
+ "files_ignored": 0,
273
+ "total_size": 0,
274
+ "languages": set(),
275
+ }
276
+
277
+ def build_tree(
278
+ self,
279
+ root_path: Path,
280
+ file_extensions: Optional[List[str]] = None,
281
+ ignore_patterns: Optional[List[str]] = None,
282
+ max_depth: Optional[int] = None,
283
+ use_gitignore: bool = True,
284
+ calculate_hashes: bool = False,
285
+ progress_callback: Optional[callable] = None,
286
+ ) -> TreeNode:
287
+ """Build file tree from directory.
288
+
289
+ Args:
290
+ root_path: Root directory to scan
291
+ file_extensions: File extensions to include (None for all)
292
+ ignore_patterns: Additional ignore patterns
293
+ max_depth: Maximum directory depth to traverse
294
+ use_gitignore: Whether to use .gitignore patterns
295
+ calculate_hashes: Whether to calculate file hashes
296
+ progress_callback: Callback for progress updates
297
+
298
+ Returns:
299
+ Root TreeNode of the built tree
300
+ """
301
+ self.stats = {
302
+ "directories_scanned": 0,
303
+ "files_found": 0,
304
+ "files_ignored": 0,
305
+ "total_size": 0,
306
+ "languages": set(),
307
+ }
308
+
309
+ # Load gitignore patterns if requested
310
+ gitignore_parser = GitignoreParser(root_path) if use_gitignore else None
311
+
312
+ # Combine ignore patterns
313
+ all_ignore_patterns = set(self.DEFAULT_IGNORE)
314
+ if ignore_patterns:
315
+ all_ignore_patterns.update(ignore_patterns)
316
+
317
+ # Build the tree
318
+ return self._build_node(
319
+ root_path,
320
+ root_path,
321
+ file_extensions,
322
+ all_ignore_patterns,
323
+ gitignore_parser,
324
+ max_depth,
325
+ 0,
326
+ calculate_hashes,
327
+ progress_callback,
328
+ )
329
+
330
+
331
+ def _build_node(
332
+ self,
333
+ path: Path,
334
+ root_path: Path,
335
+ file_extensions: Optional[List[str]],
336
+ ignore_patterns: Set[str],
337
+ gitignore_parser: Optional[GitignoreParser],
338
+ max_depth: Optional[int],
339
+ current_depth: int,
340
+ calculate_hashes: bool,
341
+ progress_callback: Optional[callable],
342
+ ) -> Optional[TreeNode]:
343
+ """Recursively build tree node.
344
+
345
+ Args:
346
+ path: Current path to process
347
+ root_path: Root directory of tree
348
+ file_extensions: File extensions to include
349
+ ignore_patterns: Patterns to ignore
350
+ gitignore_parser: Gitignore parser instance
351
+ max_depth: Maximum depth to traverse
352
+ current_depth: Current depth in tree
353
+ calculate_hashes: Whether to calculate file hashes
354
+ progress_callback: Progress callback function
355
+
356
+ Returns:
357
+ TreeNode or None if path should be ignored
358
+ """
359
+ # Check if we should ignore this path
360
+ if self._should_ignore(path, ignore_patterns):
361
+ self.stats["files_ignored"] += 1
362
+ return None
363
+
364
+ # Check gitignore
365
+ if gitignore_parser and gitignore_parser.should_ignore(path):
366
+ self.stats["files_ignored"] += 1
367
+ return None
368
+
369
+ # Check depth limit
370
+ if max_depth is not None and current_depth > max_depth:
371
+ return None
372
+
373
+ # Create node
374
+ node = TreeNode(
375
+ name=path.name,
376
+ path=str(path),
377
+ type="directory" if path.is_dir() else "file",
378
+ )
379
+
380
+ # Handle files
381
+ if path.is_file():
382
+ # Check file extension filter
383
+ if file_extensions:
384
+ if not any(path.suffix == ext for ext in file_extensions):
385
+ return None
386
+
387
+ # Get file metadata
388
+ stat = path.stat()
389
+ metadata = FileMetadata(
390
+ path=str(path),
391
+ size=stat.st_size,
392
+ modified=stat.st_mtime,
393
+ language=self._detect_language(path),
394
+ )
395
+
396
+ # Calculate hash if requested
397
+ if calculate_hashes:
398
+ metadata.hash = self._calculate_file_hash(path)
399
+
400
+ node.metadata = metadata
401
+
402
+ # Update statistics
403
+ self.stats["files_found"] += 1
404
+ self.stats["total_size"] += stat.st_size
405
+ if metadata.language:
406
+ self.stats["languages"].add(metadata.language)
407
+
408
+ # Progress callback
409
+ if progress_callback:
410
+ progress_callback(path, self.stats)
411
+
412
+ # Handle directories
413
+ elif path.is_dir():
414
+ self.stats["directories_scanned"] += 1
415
+
416
+ try:
417
+ # Process children
418
+ children = []
419
+ for child_path in sorted(path.iterdir()):
420
+ child_node = self._build_node(
421
+ child_path,
422
+ root_path,
423
+ file_extensions,
424
+ ignore_patterns,
425
+ gitignore_parser,
426
+ max_depth,
427
+ current_depth + 1,
428
+ calculate_hashes,
429
+ progress_callback,
430
+ )
431
+
432
+ if child_node:
433
+ children.append(child_node)
434
+
435
+ node.children = children
436
+
437
+ except PermissionError:
438
+ self.logger.warning(f"Permission denied: {path}")
439
+
440
+ return node
441
+
442
+ def _should_ignore(self, path: Path, ignore_patterns: Set[str]) -> bool:
443
+ """Check if path matches any ignore pattern.
444
+
445
+ Args:
446
+ path: Path to check
447
+ ignore_patterns: Set of ignore patterns
448
+
449
+ Returns:
450
+ True if path should be ignored
451
+ """
452
+ name = path.name
453
+
454
+ return any(fnmatch.fnmatch(name, pattern) for pattern in ignore_patterns)
455
+
456
+ def _detect_language(self, path: Path) -> Optional[str]:
457
+ """Detect programming language from file extension.
458
+
459
+ Args:
460
+ path: File path
461
+
462
+ Returns:
463
+ Language name or None
464
+ """
465
+ suffix = path.suffix.lower()
466
+
467
+ # Special case for Dockerfile
468
+ if path.name.lower() in ("dockerfile", "dockerfile.*"):
469
+ return "dockerfile"
470
+
471
+ return self.LANGUAGE_MAP.get(suffix)
472
+
473
+ def _calculate_file_hash(self, path: Path) -> str:
474
+ """Calculate MD5 hash of file contents.
475
+
476
+ Args:
477
+ path: File path
478
+
479
+ Returns:
480
+ MD5 hash string
481
+ """
482
+ hasher = hashlib.md5()
483
+
484
+ try:
485
+ with open(path, "rb") as f:
486
+ # Read in chunks for large files
487
+ while chunk := f.read(8192):
488
+ hasher.update(chunk)
489
+ except Exception as e:
490
+ self.logger.warning(f"Failed to hash {path}: {e}")
491
+ return ""
492
+
493
+ return hasher.hexdigest()
494
+
495
+ def save_tree(self, tree: TreeNode, output_path: Path):
496
+ """Save tree to JSON file.
497
+
498
+ Args:
499
+ tree: Root tree node
500
+ output_path: Output file path
501
+ """
502
+ tree_dict = tree.to_dict()
503
+ tree_dict["stats"] = {
504
+ "directories_scanned": self.stats["directories_scanned"],
505
+ "files_found": self.stats["files_found"],
506
+ "files_ignored": self.stats["files_ignored"],
507
+ "total_size": self.stats["total_size"],
508
+ "languages": list(self.stats["languages"]),
509
+ "generated_at": datetime.utcnow().isoformat(),
510
+ }
511
+
512
+ with open(output_path, "w") as f:
513
+ json.dump(tree_dict, f, indent=2)
514
+
515
+ self.logger.info(f"Saved tree to {output_path}")
516
+
517
+ def load_tree(self, input_path: Path) -> TreeNode:
518
+ """Load tree from JSON file.
519
+
520
+ Args:
521
+ input_path: Input file path
522
+
523
+ Returns:
524
+ Root tree node
525
+ """
526
+ with open(input_path) as f:
527
+ tree_dict = json.load(f)
528
+
529
+ # Remove stats if present
530
+ if "stats" in tree_dict:
531
+ del tree_dict["stats"]
532
+
533
+ return self._dict_to_node(tree_dict)
534
+
535
+ def _dict_to_node(self, node_dict: Dict[str, Any]) -> TreeNode:
536
+ """Convert dictionary to TreeNode.
537
+
538
+ Args:
539
+ node_dict: Node dictionary
540
+
541
+ Returns:
542
+ TreeNode instance
543
+ """
544
+ node = TreeNode(
545
+ name=node_dict["name"], path=node_dict["path"], type=node_dict["type"]
546
+ )
547
+
548
+ if "metadata" in node_dict:
549
+ meta = node_dict["metadata"]
550
+ node.metadata = FileMetadata(
551
+ path=node.path,
552
+ size=meta.get("size", 0),
553
+ modified=meta.get("modified", 0),
554
+ hash=meta.get("hash"),
555
+ language=meta.get("language"),
556
+ )
557
+
558
+ if "children" in node_dict:
559
+ node.children = [
560
+ self._dict_to_node(child_dict) for child_dict in node_dict["children"]
561
+ ]
562
+
563
+ return node
564
+
565
+ def get_stats(self) -> Dict[str, Any]:
566
+ """Get current statistics.
567
+
568
+ Returns:
569
+ Statistics dictionary
570
+ """
571
+ return {**self.stats, "languages": list(self.stats["languages"])}
572
+
573
+ def compare_trees(
574
+ self, old_tree: TreeNode, new_tree: TreeNode
575
+ ) -> Dict[str, List[str]]:
576
+ """Compare two trees to find differences.
577
+
578
+ Args:
579
+ old_tree: Previous tree
580
+ new_tree: Current tree
581
+
582
+ Returns:
583
+ Dictionary with added, removed, and modified files
584
+ """
585
+ old_files = self._get_all_files(old_tree)
586
+ new_files = self._get_all_files(new_tree)
587
+
588
+ old_paths = set(old_files.keys())
589
+ new_paths = set(new_files.keys())
590
+
591
+ added = list(new_paths - old_paths)
592
+ removed = list(old_paths - new_paths)
593
+
594
+ # Check for modifications
595
+ modified = []
596
+ for path in old_paths & new_paths:
597
+ old_meta = old_files[path]
598
+ new_meta = new_files[path]
599
+
600
+ # Compare modification times or hashes
601
+ if old_meta.hash and new_meta.hash:
602
+ if old_meta.hash != new_meta.hash:
603
+ modified.append(path)
604
+ elif old_meta.modified != new_meta.modified:
605
+ modified.append(path)
606
+
607
+ return {
608
+ "added": sorted(added),
609
+ "removed": sorted(removed),
610
+ "modified": sorted(modified),
611
+ }
612
+
613
+ def _get_all_files(self, tree: TreeNode) -> Dict[str, FileMetadata]:
614
+ """Get all files from tree.
615
+
616
+ Args:
617
+ tree: Root tree node
618
+
619
+ Returns:
620
+ Dictionary mapping file paths to metadata
621
+ """
622
+ files = {}
623
+
624
+ def traverse(node: TreeNode):
625
+ if node.type == "file" and node.metadata:
626
+ files[node.path] = node.metadata
627
+ elif node.children:
628
+ for child in node.children:
629
+ traverse(child)
630
+
631
+ traverse(tree)
632
+ return files