claude-mpm 3.5.6__py3-none-any.whl → 3.6.0__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 (46) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/BASE_AGENT_TEMPLATE.md +96 -23
  3. claude_mpm/agents/BASE_PM.md +273 -0
  4. claude_mpm/agents/INSTRUCTIONS.md +114 -103
  5. claude_mpm/agents/agent_loader.py +36 -1
  6. claude_mpm/agents/async_agent_loader.py +421 -0
  7. claude_mpm/agents/templates/code_analyzer.json +81 -0
  8. claude_mpm/agents/templates/data_engineer.json +18 -3
  9. claude_mpm/agents/templates/documentation.json +18 -3
  10. claude_mpm/agents/templates/engineer.json +19 -4
  11. claude_mpm/agents/templates/ops.json +18 -3
  12. claude_mpm/agents/templates/qa.json +20 -4
  13. claude_mpm/agents/templates/research.json +20 -4
  14. claude_mpm/agents/templates/security.json +18 -3
  15. claude_mpm/agents/templates/version_control.json +16 -3
  16. claude_mpm/cli/__init__.py +5 -1
  17. claude_mpm/cli/commands/__init__.py +5 -1
  18. claude_mpm/cli/commands/agents.py +212 -3
  19. claude_mpm/cli/commands/aggregate.py +462 -0
  20. claude_mpm/cli/commands/config.py +277 -0
  21. claude_mpm/cli/commands/run.py +224 -36
  22. claude_mpm/cli/parser.py +176 -1
  23. claude_mpm/constants.py +19 -0
  24. claude_mpm/core/claude_runner.py +320 -44
  25. claude_mpm/core/config.py +161 -4
  26. claude_mpm/core/framework_loader.py +81 -0
  27. claude_mpm/hooks/claude_hooks/hook_handler.py +391 -9
  28. claude_mpm/init.py +40 -5
  29. claude_mpm/models/agent_session.py +511 -0
  30. claude_mpm/scripts/__init__.py +15 -0
  31. claude_mpm/scripts/start_activity_logging.py +86 -0
  32. claude_mpm/services/agents/deployment/agent_deployment.py +165 -19
  33. claude_mpm/services/agents/deployment/async_agent_deployment.py +461 -0
  34. claude_mpm/services/event_aggregator.py +547 -0
  35. claude_mpm/utils/agent_dependency_loader.py +655 -0
  36. claude_mpm/utils/console.py +11 -0
  37. claude_mpm/utils/dependency_cache.py +376 -0
  38. claude_mpm/utils/dependency_strategies.py +343 -0
  39. claude_mpm/utils/environment_context.py +310 -0
  40. {claude_mpm-3.5.6.dist-info → claude_mpm-3.6.0.dist-info}/METADATA +47 -3
  41. {claude_mpm-3.5.6.dist-info → claude_mpm-3.6.0.dist-info}/RECORD +45 -31
  42. claude_mpm/agents/templates/pm.json +0 -122
  43. {claude_mpm-3.5.6.dist-info → claude_mpm-3.6.0.dist-info}/WHEEL +0 -0
  44. {claude_mpm-3.5.6.dist-info → claude_mpm-3.6.0.dist-info}/entry_points.txt +0 -0
  45. {claude_mpm-3.5.6.dist-info → claude_mpm-3.6.0.dist-info}/licenses/LICENSE +0 -0
  46. {claude_mpm-3.5.6.dist-info → claude_mpm-3.6.0.dist-info}/top_level.txt +0 -0
@@ -471,9 +471,25 @@ class ClaudeRunner:
471
471
  return False
472
472
 
473
473
  def run_interactive(self, initial_context: Optional[str] = None):
474
- """Run Claude in interactive mode."""
475
- # TODO: Add response logging for interactive mode
476
- # This requires capturing stdout from the exec'd process or using subprocess with PTY
474
+ """Run Claude in interactive mode.
475
+
476
+ WHY: This method manages the interactive Claude session with optional response
477
+ logging through the hook system. Response logging works seamlessly with both
478
+ exec and subprocess launch methods via Claude Code's built-in hook infrastructure.
479
+
480
+ DESIGN DECISION: The hook system captures Claude events (UserPromptSubmit,
481
+ PreToolUse, PostToolUse, Task delegations) directly from Claude Code, enabling
482
+ response logging without process control overhead. This architecture provides:
483
+ - Better performance (no I/O stream interception needed)
484
+ - Full compatibility with exec mode (preserves default behavior)
485
+ - Clean separation of concerns (hooks handle logging independently)
486
+ - Comprehensive event capture (including agent delegations)
487
+
488
+ Args:
489
+ initial_context: Optional initial context to pass to Claude
490
+ """
491
+ # Use the launch method as specified
492
+ effective_launch_method = self.launch_method
477
493
 
478
494
  # Connect to Socket.IO server if enabled
479
495
  if self.enable_websocket:
@@ -491,7 +507,7 @@ class ClaudeRunner:
491
507
  # Notify session start
492
508
  self.websocket_server.session_started(
493
509
  session_id=session_id,
494
- launch_method=self.launch_method,
510
+ launch_method=effective_launch_method,
495
511
  working_dir=working_dir
496
512
  )
497
513
  except ImportError as e:
@@ -580,14 +596,14 @@ class ClaudeRunner:
580
596
 
581
597
  if self.project_logger:
582
598
  self.project_logger.log_system(
583
- f"Launching Claude interactive mode with {self.launch_method}",
599
+ f"Launching Claude interactive mode with {effective_launch_method}",
584
600
  level="INFO",
585
601
  component="session"
586
602
  )
587
603
  self._log_session_event({
588
604
  "event": "launching_claude_interactive",
589
605
  "command": " ".join(cmd),
590
- "method": self.launch_method
606
+ "method": effective_launch_method
591
607
  })
592
608
 
593
609
  # Notify WebSocket clients
@@ -598,7 +614,7 @@ class ClaudeRunner:
598
614
  )
599
615
 
600
616
  # Launch using selected method
601
- if self.launch_method == "subprocess":
617
+ if effective_launch_method == "subprocess":
602
618
  self._launch_subprocess_interactive(cmd, clean_env)
603
619
  else:
604
620
  # Default to exec for backward compatibility
@@ -1142,7 +1158,19 @@ class ClaudeRunner:
1142
1158
  from claude_mpm.services.framework_claude_md_generator.content_assembler import ContentAssembler
1143
1159
  assembler = ContentAssembler()
1144
1160
  processed_instructions = assembler.apply_template_variables(raw_instructions)
1145
- self.logger.info(f"Loaded and processed {instructions_source} PM instructions with dynamic capabilities")
1161
+
1162
+ # Append BASE_PM.md framework requirements with dynamic content
1163
+ base_pm_path = Path(__file__).parent.parent / "agents" / "BASE_PM.md"
1164
+ if base_pm_path.exists():
1165
+ base_pm_content = base_pm_path.read_text()
1166
+
1167
+ # Process BASE_PM.md with dynamic content injection
1168
+ base_pm_content = self._process_base_pm_content(base_pm_content)
1169
+
1170
+ processed_instructions += f"\n\n{base_pm_content}"
1171
+ self.logger.info(f"Appended BASE_PM.md with dynamic capabilities from deployed agents")
1172
+
1173
+ self.logger.info(f"Loaded and processed {instructions_source} PM instructions")
1146
1174
  return processed_instructions
1147
1175
  except ImportError:
1148
1176
  self.logger.warning("ContentAssembler not available, using raw instructions")
@@ -1157,6 +1185,274 @@ class ClaudeRunner:
1157
1185
  self.logger.error(f"Failed to load system instructions: {e}")
1158
1186
  return None
1159
1187
 
1188
+ def _process_base_pm_content(self, base_pm_content: str) -> str:
1189
+ """Process BASE_PM.md content with dynamic injections.
1190
+
1191
+ This method replaces template variables in BASE_PM.md with:
1192
+ - {{agent-capabilities}}: List of deployed agents from .claude/agents/
1193
+ - {{current-date}}: Today's date for temporal context
1194
+ """
1195
+ from datetime import datetime
1196
+
1197
+ # Replace {{current-date}} with actual date
1198
+ current_date = datetime.now().strftime('%Y-%m-%d')
1199
+ base_pm_content = base_pm_content.replace('{{current-date}}', current_date)
1200
+
1201
+ # Replace {{agent-capabilities}} with deployed agents
1202
+ if '{{agent-capabilities}}' in base_pm_content:
1203
+ capabilities_section = self._generate_deployed_agent_capabilities()
1204
+ base_pm_content = base_pm_content.replace('{{agent-capabilities}}', capabilities_section)
1205
+
1206
+ return base_pm_content
1207
+
1208
+ def _generate_deployed_agent_capabilities(self) -> str:
1209
+ """Generate agent capabilities from deployed agents following Claude Code's hierarchy.
1210
+
1211
+ Follows the agent precedence order:
1212
+ 1. Project agents (.claude/agents/) - highest priority
1213
+ 2. User agents (~/.config/claude/agents/) - middle priority
1214
+ 3. System agents (claude-desktop installation) - lowest priority
1215
+
1216
+ Project agents override user/system agents with the same ID.
1217
+ """
1218
+ try:
1219
+ # Track discovered agents by ID to handle overrides
1220
+ discovered_agents = {}
1221
+
1222
+ # 1. First read system agents (lowest priority)
1223
+ system_agents_dirs = [
1224
+ Path.home() / "Library" / "Application Support" / "Claude" / "agents", # macOS
1225
+ Path.home() / ".config" / "claude" / "agents", # Linux
1226
+ Path.home() / "AppData" / "Roaming" / "Claude" / "agents", # Windows
1227
+ ]
1228
+
1229
+ for system_dir in system_agents_dirs:
1230
+ if system_dir.exists():
1231
+ self._discover_agents_from_dir(system_dir, discovered_agents, "system")
1232
+ break
1233
+
1234
+ # 2. Then read user agents (middle priority, overrides system)
1235
+ user_agents_dir = Path.home() / ".config" / "claude" / "agents"
1236
+ if user_agents_dir.exists():
1237
+ self._discover_agents_from_dir(user_agents_dir, discovered_agents, "user")
1238
+
1239
+ # 3. Finally read project agents (highest priority, overrides all)
1240
+ project_agents_dir = Path.cwd() / ".claude" / "agents"
1241
+ if project_agents_dir.exists():
1242
+ self._discover_agents_from_dir(project_agents_dir, discovered_agents, "project")
1243
+
1244
+ if not discovered_agents:
1245
+ self.logger.warning("No agents found in any tier")
1246
+ return self._get_fallback_capabilities()
1247
+
1248
+ # Build capabilities section from discovered agents
1249
+ section = "\n## Available Agent Capabilities\n\n"
1250
+ section += "You have the following specialized agents available for delegation:\n\n"
1251
+
1252
+ # Group agents by category
1253
+ agents_by_category = {}
1254
+ for agent_id, agent_info in discovered_agents.items():
1255
+ category = agent_info['category']
1256
+ if category not in agents_by_category:
1257
+ agents_by_category[category] = []
1258
+ agents_by_category[category].append(agent_info)
1259
+
1260
+ # Output agents by category
1261
+ for category in sorted(agents_by_category.keys()):
1262
+ section += f"\n### {category} Agents\n"
1263
+ for agent in sorted(agents_by_category[category], key=lambda x: x['name']):
1264
+ tier_indicator = f" [{agent['tier']}]" if agent['tier'] != 'project' else ""
1265
+ section += f"- **{agent['name']}** (`{agent['id']}`{tier_indicator}): {agent['description']}\n"
1266
+
1267
+ # Add summary
1268
+ section += f"\n**Total Available Agents**: {len(discovered_agents)}\n"
1269
+
1270
+ # Show tier distribution
1271
+ tier_counts = {}
1272
+ for agent in discovered_agents.values():
1273
+ tier = agent['tier']
1274
+ tier_counts[tier] = tier_counts.get(tier, 0) + 1
1275
+
1276
+ if len(tier_counts) > 1:
1277
+ section += f"**Agent Sources**: "
1278
+ tier_summary = []
1279
+ for tier in ['project', 'user', 'system']:
1280
+ if tier in tier_counts:
1281
+ tier_summary.append(f"{tier_counts[tier]} {tier}")
1282
+ section += ", ".join(tier_summary) + "\n"
1283
+
1284
+ section += "Use the agent ID in parentheses when delegating tasks via the Task tool.\n"
1285
+
1286
+ self.logger.info(f"Generated capabilities for {len(discovered_agents)} agents " +
1287
+ f"(project: {tier_counts.get('project', 0)}, " +
1288
+ f"user: {tier_counts.get('user', 0)}, " +
1289
+ f"system: {tier_counts.get('system', 0)})")
1290
+ return section
1291
+
1292
+ except Exception as e:
1293
+ self.logger.error(f"Failed to generate deployed agent capabilities: {e}")
1294
+ return self._get_fallback_capabilities()
1295
+
1296
+ def _discover_agents_from_dir(self, agents_dir: Path, discovered_agents: dict, tier: str):
1297
+ """Discover agents from a specific directory and add/override in discovered_agents.
1298
+
1299
+ Args:
1300
+ agents_dir: Directory to search for agent .md files
1301
+ discovered_agents: Dictionary to update with discovered agents
1302
+ tier: The tier this directory represents (system/user/project)
1303
+ """
1304
+ if not agents_dir.exists():
1305
+ return
1306
+
1307
+ agent_files = list(agents_dir.glob("*.md"))
1308
+ for agent_file in sorted(agent_files):
1309
+ agent_id = agent_file.stem
1310
+
1311
+ # Skip pm.md if it exists (PM is not a deployable agent)
1312
+ if agent_id.lower() == 'pm':
1313
+ continue
1314
+
1315
+ # Read agent content and extract metadata
1316
+ try:
1317
+ content = agent_file.read_text()
1318
+ import re
1319
+
1320
+ # Check for YAML frontmatter
1321
+ name = agent_id.replace('_', ' ').title()
1322
+ desc = "Specialized agent for delegation"
1323
+
1324
+ if content.startswith('---'):
1325
+ # Parse YAML frontmatter
1326
+ frontmatter_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
1327
+ if frontmatter_match:
1328
+ frontmatter = frontmatter_match.group(1)
1329
+ # Extract name from frontmatter
1330
+ name_fm_match = re.search(r'^name:\s*(.+)$', frontmatter, re.MULTILINE)
1331
+ if name_fm_match:
1332
+ name_value = name_fm_match.group(1).strip()
1333
+ # Format the name nicely
1334
+ name = name_value.replace('_', ' ').title()
1335
+
1336
+ # Extract description from frontmatter
1337
+ desc_fm_match = re.search(r'^description:\s*(.+)$', frontmatter, re.MULTILINE)
1338
+ if desc_fm_match:
1339
+ desc = desc_fm_match.group(1).strip()
1340
+ else:
1341
+ # No frontmatter, extract from content
1342
+ name_match = re.search(r'^#\s+(.+?)(?:\s+Agent)?$', content, re.MULTILINE)
1343
+ if name_match:
1344
+ name = name_match.group(1)
1345
+
1346
+ # Get first non-heading line after the title
1347
+ lines = content.split('\n')
1348
+ for i, line in enumerate(lines):
1349
+ if line.startswith('#'):
1350
+ # Found title, look for description after it
1351
+ for desc_line in lines[i+1:]:
1352
+ desc_line = desc_line.strip()
1353
+ if desc_line and not desc_line.startswith('#'):
1354
+ desc = desc_line
1355
+ break
1356
+ break
1357
+
1358
+ # Categorize based on agent name/type
1359
+ category = self._categorize_agent(agent_id, content)
1360
+
1361
+ # Add or override agent in discovered_agents
1362
+ discovered_agents[agent_id] = {
1363
+ 'id': agent_id,
1364
+ 'name': name,
1365
+ 'description': desc[:150] + '...' if len(desc) > 150 else desc,
1366
+ 'category': category,
1367
+ 'tier': tier,
1368
+ 'path': str(agent_file)
1369
+ }
1370
+
1371
+ self.logger.debug(f"Discovered {tier} agent: {agent_id} from {agent_file}")
1372
+
1373
+ except Exception as e:
1374
+ self.logger.debug(f"Could not parse agent {agent_file}: {e}")
1375
+ continue
1376
+ def _categorize_agent(self, agent_id: str, content: str) -> str:
1377
+ """Categorize an agent based on its ID and content."""
1378
+ agent_id_lower = agent_id.lower()
1379
+ content_lower = content.lower()
1380
+
1381
+ if 'engineer' in agent_id_lower or 'engineering' in content_lower:
1382
+ return "Engineering"
1383
+ elif 'research' in agent_id_lower or 'analysis' in content_lower or 'analyzer' in agent_id_lower:
1384
+ return "Research"
1385
+ elif 'qa' in agent_id_lower or 'quality' in content_lower or 'test' in agent_id_lower:
1386
+ return "Quality"
1387
+ elif 'security' in agent_id_lower or 'security' in content_lower:
1388
+ return "Security"
1389
+ elif 'doc' in agent_id_lower or 'documentation' in content_lower:
1390
+ return "Documentation"
1391
+ elif 'data' in agent_id_lower:
1392
+ return "Data"
1393
+ elif 'ops' in agent_id_lower or 'deploy' in agent_id_lower or 'operations' in content_lower:
1394
+ return "Operations"
1395
+ elif 'version' in agent_id_lower or 'git' in content_lower:
1396
+ return "Version Control"
1397
+ else:
1398
+ return "General"
1399
+
1400
+ def _get_fallback_capabilities(self) -> str:
1401
+ """Return fallback agent capabilities when deployed agents can't be read."""
1402
+ return """
1403
+ ## Available Agent Capabilities
1404
+
1405
+ You have the following specialized agents available for delegation:
1406
+
1407
+ - **Engineer Agent**: Code implementation and development
1408
+ - **Research Agent**: Investigation and analysis
1409
+ - **QA Agent**: Testing and quality assurance
1410
+ - **Documentation Agent**: Documentation creation and maintenance
1411
+ - **Security Agent**: Security analysis and protection
1412
+ - **Data Engineer Agent**: Data management and pipelines
1413
+ - **Ops Agent**: Deployment and operations
1414
+ - **Version Control Agent**: Git operations and version management
1415
+
1416
+ Use these agents to delegate specialized work via the Task tool.
1417
+ """
1418
+
1419
+ def _generate_agent_capabilities_section(self, agents: dict) -> str:
1420
+ """Generate dynamic agent capabilities section from available agents."""
1421
+ if not agents:
1422
+ return ""
1423
+
1424
+ # Build capabilities section
1425
+ section = "\n\n## Available Agent Capabilities\n\n"
1426
+ section += "You have the following specialized agents available for delegation:\n\n"
1427
+
1428
+ # Group agents by category
1429
+ categories = {}
1430
+ for agent_id, info in agents.items():
1431
+ category = info.get('category', 'general')
1432
+ if category not in categories:
1433
+ categories[category] = []
1434
+ categories[category].append((agent_id, info))
1435
+
1436
+ # List agents by category
1437
+ for category in sorted(categories.keys()):
1438
+ section += f"\n### {category.title()} Agents\n"
1439
+ for agent_id, info in sorted(categories[category]):
1440
+ name = info.get('name', agent_id)
1441
+ desc = info.get('description', 'Specialized agent')
1442
+ tools = info.get('tools', [])
1443
+ section += f"- **{name}** (`{agent_id}`): {desc}\n"
1444
+ if tools:
1445
+ section += f" - Tools: {', '.join(tools[:5])}"
1446
+ if len(tools) > 5:
1447
+ section += f" (+{len(tools)-5} more)"
1448
+ section += "\n"
1449
+
1450
+ # Add summary
1451
+ section += f"\n**Total Available Agents**: {len(agents)}\n"
1452
+ section += "Use the agent ID in parentheses when delegating tasks via the Task tool.\n"
1453
+
1454
+ return section
1455
+
1160
1456
  def _create_system_prompt(self) -> str:
1161
1457
  """Create the complete system prompt including instructions."""
1162
1458
  if self.system_instructions:
@@ -1418,16 +1714,27 @@ class ClaudeRunner:
1418
1714
  # Don't fail the entire initialization - memory system is optional
1419
1715
 
1420
1716
  def _launch_subprocess_interactive(self, cmd: list, env: dict):
1421
- """Launch Claude as a subprocess with PTY for interactive mode."""
1717
+ """Launch Claude as a subprocess with PTY for interactive mode.
1718
+
1719
+ WHY: This method launches Claude as a subprocess when explicitly requested
1720
+ (via --launch-method subprocess). Subprocess mode maintains the parent process,
1721
+ which can be useful for:
1722
+ 1. Maintaining WebSocket connections and monitoring
1723
+ 2. Providing proper cleanup and error handling
1724
+ 3. Debugging and development scenarios
1725
+
1726
+ DESIGN DECISION: We use PTY (pseudo-terminal) to maintain full interactive
1727
+ capabilities. Response logging is handled through the hook system, not I/O
1728
+ interception, for better performance and compatibility.
1729
+ """
1422
1730
  import pty
1423
1731
  import select
1424
1732
  import termios
1425
1733
  import tty
1426
1734
  import signal
1427
1735
 
1428
- # Collect output for response logging if enabled
1429
- collected_output = [] if self.response_logger else None
1430
- collected_input = [] if self.response_logger else None
1736
+ # Note: Response logging is handled through the hook system,
1737
+ # not through I/O interception (better performance)
1431
1738
 
1432
1739
  # Save original terminal settings
1433
1740
  original_tty = None
@@ -1491,13 +1798,6 @@ class ClaudeRunner:
1491
1798
  data = os.read(master_fd, 4096)
1492
1799
  if data:
1493
1800
  os.write(sys.stdout.fileno(), data)
1494
- # Collect output for response logging
1495
- if collected_output is not None:
1496
- try:
1497
- output_text = data.decode('utf-8', errors='replace')
1498
- collected_output.append(output_text)
1499
- except Exception:
1500
- pass
1501
1801
  # Broadcast output to WebSocket clients
1502
1802
  if self.websocket_server:
1503
1803
  try:
@@ -1516,37 +1816,13 @@ class ClaudeRunner:
1516
1816
  data = os.read(sys.stdin.fileno(), 4096)
1517
1817
  if data:
1518
1818
  os.write(master_fd, data)
1519
- # Collect input for response logging
1520
- if collected_input is not None:
1521
- try:
1522
- input_text = data.decode('utf-8', errors='replace')
1523
- collected_input.append(input_text)
1524
- except Exception:
1525
- pass
1526
1819
  except OSError:
1527
1820
  break
1528
1821
 
1529
1822
  # Wait for process to complete
1530
1823
  process.wait()
1531
1824
 
1532
- # Log the interactive session if response logging is enabled
1533
- if self.response_logger and collected_output is not None and collected_output:
1534
- try:
1535
- full_output = ''.join(collected_output)
1536
- full_input = ''.join(collected_input) if collected_input else "Interactive session"
1537
- self.response_logger.log_response(
1538
- request_summary=f"Interactive session: {full_input[:200]}..." if len(full_input) > 200 else f"Interactive session: {full_input}",
1539
- response_content=full_output,
1540
- metadata={
1541
- "mode": "interactive-subprocess",
1542
- "model": "opus",
1543
- "exit_code": process.returncode,
1544
- "session_type": "subprocess"
1545
- },
1546
- agent="claude-interactive"
1547
- )
1548
- except Exception as e:
1549
- self.logger.debug(f"Failed to log interactive session: {e}")
1825
+ # Note: Response logging is handled through the hook system
1550
1826
 
1551
1827
  if self.project_logger:
1552
1828
  self.project_logger.log_system(
claude_mpm/core/config.py CHANGED
@@ -7,8 +7,10 @@ and default values with proper validation and type conversion.
7
7
 
8
8
  import os
9
9
  from pathlib import Path
10
- from typing import Any, Dict, Optional, Union
10
+ from typing import Any, Dict, Optional, Union, List, Tuple
11
11
  import logging
12
+ import yaml
13
+ import json
12
14
 
13
15
  from ..utils.config_manager import ConfigurationManager
14
16
  from .config_paths import ConfigPaths
@@ -49,14 +51,25 @@ class Config:
49
51
  if config:
50
52
  self._config.update(config)
51
53
 
54
+ # Track where configuration was loaded from
55
+ self._loaded_from = None
56
+
52
57
  # Load from file if provided
53
58
  if config_file:
54
59
  self.load_file(config_file)
60
+ self._loaded_from = str(config_file)
55
61
  else:
56
62
  # Try to load from standard location: .claude-mpm/configuration.yaml
57
63
  default_config = Path.cwd() / ".claude-mpm" / "configuration.yaml"
58
64
  if default_config.exists():
59
65
  self.load_file(default_config)
66
+ self._loaded_from = str(default_config)
67
+ else:
68
+ # Also try .yml extension
69
+ alt_config = Path.cwd() / ".claude-mpm" / "configuration.yml"
70
+ if alt_config.exists():
71
+ self.load_file(alt_config)
72
+ self._loaded_from = str(alt_config)
60
73
 
61
74
  # Load from environment variables (new and legacy prefixes)
62
75
  self._load_env_vars()
@@ -66,21 +79,64 @@ class Config:
66
79
  self._apply_defaults()
67
80
 
68
81
  def load_file(self, file_path: Union[str, Path]) -> None:
69
- """Load configuration from file."""
82
+ """Load configuration from file with enhanced error handling.
83
+
84
+ WHY: Configuration loading failures can cause silent issues. We need
85
+ to provide clear, actionable error messages to help users fix problems.
86
+ """
70
87
  file_path = Path(file_path)
71
88
 
72
89
  if not file_path.exists():
73
90
  logger.warning(f"Configuration file not found: {file_path}")
91
+ logger.info(f"TIP: Create a configuration file with: mkdir -p {file_path.parent} && touch {file_path}")
74
92
  return
75
93
 
76
94
  try:
95
+ # Check if file is readable
96
+ if not os.access(file_path, os.R_OK):
97
+ logger.error(f"Configuration file is not readable: {file_path}")
98
+ logger.info(f"TIP: Fix permissions with: chmod 644 {file_path}")
99
+ return
100
+
101
+ # Check file size (warn if too large)
102
+ file_size = file_path.stat().st_size
103
+ if file_size > 1024 * 1024: # 1MB
104
+ logger.warning(f"Configuration file is large ({file_size} bytes): {file_path}")
105
+
106
+ # Try to load the configuration
77
107
  file_config = self._config_mgr.load_auto(file_path)
78
108
  if file_config:
79
109
  self._config = self._config_mgr.merge_configs(self._config, file_config)
80
- logger.info(f"Loaded configuration from {file_path}")
81
-
110
+ logger.info(f" Successfully loaded configuration from {file_path}")
111
+
112
+ # Log important configuration values for debugging
113
+ if logger.isEnabledFor(logging.DEBUG):
114
+ response_logging = file_config.get('response_logging', {})
115
+ if response_logging:
116
+ logger.debug(f"Response logging enabled: {response_logging.get('enabled', False)}")
117
+ logger.debug(f"Response logging format: {response_logging.get('format', 'json')}")
118
+
119
+ except yaml.YAMLError as e:
120
+ logger.error(f"YAML syntax error in {file_path}: {e}")
121
+ if hasattr(e, 'problem_mark'):
122
+ mark = e.problem_mark
123
+ logger.error(f"Error at line {mark.line + 1}, column {mark.column + 1}")
124
+ logger.info("TIP: Validate your YAML at https://www.yamllint.com/ or run: python scripts/validate_configuration.py")
125
+ logger.info("TIP: Common issue - YAML requires spaces, not tabs. Fix with: sed -i '' 's/\t/ /g' " + str(file_path))
126
+ # Store error for later retrieval
127
+ self._config['_load_error'] = str(e)
128
+
129
+ except json.JSONDecodeError as e:
130
+ logger.error(f"JSON syntax error in {file_path}: {e}")
131
+ logger.error(f"Error at line {e.lineno}, column {e.colno}")
132
+ logger.info("TIP: Validate your JSON at https://jsonlint.com/")
133
+ self._config['_load_error'] = str(e)
134
+
82
135
  except Exception as e:
83
136
  logger.error(f"Failed to load configuration from {file_path}: {e}")
137
+ logger.info("TIP: Check file permissions and format (YAML/JSON)")
138
+ logger.info("TIP: Run validation with: python scripts/validate_configuration.py")
139
+ self._config['_load_error'] = str(e)
84
140
 
85
141
  def _load_env_vars(self) -> None:
86
142
  """Load configuration from environment variables."""
@@ -323,6 +379,12 @@ class Config:
323
379
  "emergency_stop": True
324
380
  }
325
381
  }
382
+ },
383
+ # Agent deployment configuration
384
+ "agent_deployment": {
385
+ "excluded_agents": [], # List of agent IDs to exclude from deployment
386
+ "exclude_dependencies": False, # Whether to exclude agent dependencies too
387
+ "case_sensitive": False # Whether agent name matching is case-sensitive
326
388
  }
327
389
  }
328
390
 
@@ -515,6 +577,101 @@ class Config:
515
577
 
516
578
  return base_config
517
579
 
580
+ def validate_configuration(self) -> Tuple[bool, List[str], List[str]]:
581
+ """Validate the loaded configuration programmatically.
582
+
583
+ WHY: Provide a programmatic way to validate configuration that can be
584
+ used by other components to check configuration health.
585
+
586
+ Returns:
587
+ Tuple of (is_valid, errors, warnings)
588
+ """
589
+ errors = []
590
+ warnings = []
591
+
592
+ # Check if there was a load error
593
+ if '_load_error' in self._config:
594
+ errors.append(f"Configuration load error: {self._config['_load_error']}")
595
+
596
+ # Validate response_logging configuration
597
+ response_logging = self.get('response_logging', {})
598
+ if response_logging:
599
+ # Check enabled field
600
+ if 'enabled' in response_logging and not isinstance(response_logging['enabled'], bool):
601
+ errors.append(
602
+ f"response_logging.enabled must be boolean, got {type(response_logging['enabled']).__name__}"
603
+ )
604
+
605
+ # Check format field
606
+ if 'format' in response_logging:
607
+ valid_formats = ['json', 'syslog', 'journald']
608
+ if response_logging['format'] not in valid_formats:
609
+ errors.append(
610
+ f"response_logging.format must be one of {valid_formats}, "
611
+ f"got '{response_logging['format']}'"
612
+ )
613
+
614
+ # Check session_directory
615
+ if 'session_directory' in response_logging:
616
+ session_dir = Path(response_logging['session_directory'])
617
+ if session_dir.is_absolute() and not session_dir.parent.exists():
618
+ warnings.append(
619
+ f"Parent directory for session_directory does not exist: {session_dir.parent}"
620
+ )
621
+
622
+ # Validate memory configuration
623
+ memory_config = self.get('memory', {})
624
+ if memory_config:
625
+ if 'enabled' in memory_config and not isinstance(memory_config['enabled'], bool):
626
+ errors.append(f"memory.enabled must be boolean")
627
+
628
+ # Check limits
629
+ limits = memory_config.get('limits', {})
630
+ for field in ['default_size_kb', 'max_sections', 'max_items_per_section']:
631
+ if field in limits:
632
+ value = limits[field]
633
+ if not isinstance(value, int) or value <= 0:
634
+ errors.append(f"memory.limits.{field} must be positive integer, got {value}")
635
+
636
+ # Validate health thresholds
637
+ health_thresholds = self.get('health_thresholds', {})
638
+ if health_thresholds:
639
+ cpu = health_thresholds.get('cpu_percent')
640
+ if cpu is not None and (not isinstance(cpu, (int, float)) or cpu < 0 or cpu > 100):
641
+ errors.append(f"health_thresholds.cpu_percent must be 0-100, got {cpu}")
642
+
643
+ mem = health_thresholds.get('memory_mb')
644
+ if mem is not None and (not isinstance(mem, (int, float)) or mem <= 0):
645
+ errors.append(f"health_thresholds.memory_mb must be positive, got {mem}")
646
+
647
+ is_valid = len(errors) == 0
648
+ return is_valid, errors, warnings
649
+
650
+ def get_configuration_status(self) -> Dict[str, Any]:
651
+ """Get detailed configuration status for debugging.
652
+
653
+ WHY: Provide a comprehensive view of configuration state for
654
+ troubleshooting and health checks.
655
+
656
+ Returns:
657
+ Dictionary with configuration status information
658
+ """
659
+ is_valid, errors, warnings = self.validate_configuration()
660
+
661
+ status = {
662
+ 'valid': is_valid,
663
+ 'errors': errors,
664
+ 'warnings': warnings,
665
+ 'loaded_from': getattr(self, '_loaded_from', 'defaults'),
666
+ 'key_count': len(self._config),
667
+ 'has_response_logging': 'response_logging' in self._config,
668
+ 'has_memory_config': 'memory' in self._config,
669
+ 'response_logging_enabled': self.get('response_logging.enabled', False),
670
+ 'memory_enabled': self.get('memory.enabled', False)
671
+ }
672
+
673
+ return status
674
+
518
675
  def __repr__(self) -> str:
519
676
  """String representation of configuration."""
520
677
  return f"<Config({len(self._config)} keys)>"