claude-mpm 5.0.2__py3-none-any.whl → 5.1.9__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.

Potentially problematic release.


This version of claude-mpm might be problematic. Click here for more details.

Files changed (76) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/CLAUDE_MPM_TEACHER_OUTPUT_STYLE.md +2002 -0
  3. claude_mpm/agents/PM_INSTRUCTIONS.md +1176 -909
  4. claude_mpm/agents/base_agent_loader.py +10 -35
  5. claude_mpm/agents/frontmatter_validator.py +68 -0
  6. claude_mpm/agents/templates/circuit-breakers.md +293 -44
  7. claude_mpm/cli/__init__.py +0 -1
  8. claude_mpm/cli/commands/__init__.py +2 -0
  9. claude_mpm/cli/commands/agent_state_manager.py +64 -11
  10. claude_mpm/cli/commands/agents.py +446 -25
  11. claude_mpm/cli/commands/auto_configure.py +535 -233
  12. claude_mpm/cli/commands/configure.py +545 -89
  13. claude_mpm/cli/commands/postmortem.py +401 -0
  14. claude_mpm/cli/commands/run.py +1 -39
  15. claude_mpm/cli/commands/skills.py +322 -19
  16. claude_mpm/cli/interactive/agent_wizard.py +302 -195
  17. claude_mpm/cli/parsers/agents_parser.py +137 -0
  18. claude_mpm/cli/parsers/auto_configure_parser.py +13 -0
  19. claude_mpm/cli/parsers/base_parser.py +4 -0
  20. claude_mpm/cli/parsers/skills_parser.py +7 -0
  21. claude_mpm/cli/startup.py +73 -32
  22. claude_mpm/commands/mpm-agents-auto-configure.md +2 -2
  23. claude_mpm/commands/mpm-agents-list.md +2 -2
  24. claude_mpm/commands/mpm-config-view.md +2 -2
  25. claude_mpm/commands/mpm-help.md +3 -0
  26. claude_mpm/commands/mpm-postmortem.md +123 -0
  27. claude_mpm/commands/mpm-session-resume.md +2 -2
  28. claude_mpm/commands/mpm-ticket-organize.md +2 -2
  29. claude_mpm/commands/mpm-ticket-view.md +2 -2
  30. claude_mpm/config/agent_presets.py +312 -82
  31. claude_mpm/config/skill_presets.py +392 -0
  32. claude_mpm/constants.py +1 -0
  33. claude_mpm/core/claude_runner.py +2 -25
  34. claude_mpm/core/framework/loaders/file_loader.py +54 -101
  35. claude_mpm/core/interactive_session.py +19 -5
  36. claude_mpm/core/oneshot_session.py +16 -4
  37. claude_mpm/core/output_style_manager.py +173 -43
  38. claude_mpm/core/protocols/__init__.py +23 -0
  39. claude_mpm/core/protocols/runner_protocol.py +103 -0
  40. claude_mpm/core/protocols/session_protocol.py +131 -0
  41. claude_mpm/core/shared/singleton_manager.py +11 -4
  42. claude_mpm/core/system_context.py +38 -0
  43. claude_mpm/core/unified_agent_registry.py +129 -1
  44. claude_mpm/core/unified_config.py +22 -0
  45. claude_mpm/hooks/claude_hooks/memory_integration.py +12 -1
  46. claude_mpm/models/agent_definition.py +7 -0
  47. claude_mpm/services/agents/cache_git_manager.py +621 -0
  48. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +110 -3
  49. claude_mpm/services/agents/deployment/remote_agent_discovery_service.py +195 -1
  50. claude_mpm/services/agents/sources/git_source_sync_service.py +37 -5
  51. claude_mpm/services/analysis/__init__.py +25 -0
  52. claude_mpm/services/analysis/postmortem_reporter.py +474 -0
  53. claude_mpm/services/analysis/postmortem_service.py +765 -0
  54. claude_mpm/services/command_deployment_service.py +108 -5
  55. claude_mpm/services/core/base.py +7 -2
  56. claude_mpm/services/diagnostics/checks/mcp_services_check.py +7 -15
  57. claude_mpm/services/git/git_operations_service.py +8 -8
  58. claude_mpm/services/mcp_config_manager.py +75 -145
  59. claude_mpm/services/mcp_gateway/core/process_pool.py +22 -16
  60. claude_mpm/services/mcp_service_verifier.py +6 -3
  61. claude_mpm/services/monitor/daemon.py +28 -8
  62. claude_mpm/services/monitor/daemon_manager.py +96 -19
  63. claude_mpm/services/project/project_organizer.py +4 -0
  64. claude_mpm/services/runner_configuration_service.py +16 -3
  65. claude_mpm/services/session_management_service.py +16 -4
  66. claude_mpm/utils/agent_filters.py +288 -0
  67. claude_mpm/utils/gitignore.py +3 -0
  68. claude_mpm/utils/migration.py +372 -0
  69. claude_mpm/utils/progress.py +5 -1
  70. {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/METADATA +69 -8
  71. {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/RECORD +76 -62
  72. /claude_mpm/agents/{OUTPUT_STYLE.md → CLAUDE_MPM_OUTPUT_STYLE.md} +0 -0
  73. {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/WHEEL +0 -0
  74. {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/entry_points.txt +0 -0
  75. {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/licenses/LICENSE +0 -0
  76. {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/top_level.txt +0 -0
@@ -88,11 +88,12 @@ class UnifiedMonitorDaemon:
88
88
  self.shutdown_event = threading.Event()
89
89
 
90
90
  def _get_default_pid_file(self) -> str:
91
- """Get default PID file path."""
91
+ """Get default PID file path with port number to support multiple daemons."""
92
92
  project_root = Path.cwd()
93
93
  claude_mpm_dir = project_root / ".claude-mpm"
94
94
  claude_mpm_dir.mkdir(exist_ok=True)
95
- return str(claude_mpm_dir / "monitor-daemon.pid")
95
+ # Include port in filename to support multiple daemon instances
96
+ return str(claude_mpm_dir / f"monitor-daemon-{self.port}.pid")
96
97
 
97
98
  def start(self, force_restart: bool = False) -> bool:
98
99
  """Start the unified monitor daemon.
@@ -145,16 +146,26 @@ class UnifiedMonitorDaemon:
145
146
  if self.daemon_manager.is_running():
146
147
  existing_pid = self.daemon_manager.get_pid()
147
148
  if not force_restart:
148
- self.logger.warning(f"Daemon already running with PID {existing_pid}")
149
+ msg = f"Daemon already running on port {self.port} with PID {existing_pid}"
150
+ self.logger.warning(msg)
151
+ # If we're in subprocess mode, this is an error - we should have cleaned up
152
+ if os.environ.get("CLAUDE_MPM_SUBPROCESS_DAEMON") == "1":
153
+ self.logger.error(
154
+ f"SUBPROCESS ERROR: {msg} - This should not happen in subprocess mode!"
155
+ )
149
156
  return False
150
157
  # Force restart was already handled above
151
158
 
152
159
  # Check for our service on the port
153
160
  is_ours, pid = self.daemon_manager.is_our_service()
154
161
  if is_ours and pid and not force_restart:
155
- self.logger.warning(
156
- f"Our service already running on port {self.port} (PID: {pid})"
157
- )
162
+ msg = f"Our service already running on port {self.port} (PID: {pid})"
163
+ self.logger.warning(msg)
164
+ # If we're in subprocess mode, this is an error - we should have cleaned up
165
+ if os.environ.get("CLAUDE_MPM_SUBPROCESS_DAEMON") == "1":
166
+ self.logger.error(
167
+ f"SUBPROCESS ERROR: {msg} - This should not happen in subprocess mode!"
168
+ )
158
169
  return False
159
170
 
160
171
  # Use subprocess approach for clean daemon startup (v4.2.40)
@@ -169,20 +180,29 @@ class UnifiedMonitorDaemon:
169
180
  if os.environ.get("CLAUDE_MPM_SUBPROCESS_DAEMON") == "1":
170
181
  # We're in a subprocess started by start_daemon_subprocess
171
182
  # We need to write the PID file ourselves since parent didn't
172
- self.logger.info("Running in subprocess daemon mode, writing PID file")
183
+ self.logger.info(f"Running in subprocess daemon mode on port {self.port}")
184
+ self.logger.info(f"Subprocess PID: {os.getpid()}")
185
+ self.logger.info(f"PID file path: {self.daemon_manager.pid_file}")
173
186
  self.daemon_manager.write_pid_file()
187
+ self.logger.info("PID file written successfully")
174
188
 
175
189
  # Setup signal handlers for graceful shutdown
176
190
  self._setup_signal_handlers()
191
+ self.logger.info("Signal handlers configured")
177
192
 
178
193
  # Start the server (this will run until shutdown)
194
+ self.logger.info("Starting server in subprocess mode...")
179
195
  try:
180
196
  result = self._run_server()
181
197
  if not result:
182
198
  self.logger.error("Failed to start server in subprocess mode")
199
+ return result
200
+ self.logger.info("Server started successfully in subprocess mode")
183
201
  return result
184
202
  except Exception as e:
185
- self.logger.error(f"Server startup exception in subprocess: {e}")
203
+ self.logger.error(
204
+ f"Server startup exception in subprocess: {e}", exc_info=True
205
+ )
186
206
  raise
187
207
  else:
188
208
  # Legacy fork approach (kept for compatibility but not used by default)
@@ -80,18 +80,20 @@ class DaemonManager:
80
80
  self.startup_status_file = None
81
81
 
82
82
  def _get_default_pid_file(self) -> Path:
83
- """Get default PID file path."""
83
+ """Get default PID file path with port number to support multiple daemons."""
84
84
  project_root = Path.cwd()
85
85
  claude_mpm_dir = project_root / ".claude-mpm"
86
86
  claude_mpm_dir.mkdir(exist_ok=True)
87
- return claude_mpm_dir / "monitor-daemon.pid"
87
+ # Include port in filename to support multiple daemon instances
88
+ return claude_mpm_dir / f"monitor-daemon-{self.port}.pid"
88
89
 
89
90
  def _get_default_log_file(self) -> Path:
90
- """Get default log file path."""
91
+ """Get default log file path with port number to support multiple daemons."""
91
92
  project_root = Path.cwd()
92
93
  claude_mpm_dir = project_root / ".claude-mpm"
93
94
  claude_mpm_dir.mkdir(exist_ok=True)
94
- return claude_mpm_dir / "monitor-daemon.log"
95
+ # Include port in filename to support multiple daemon instances
96
+ return claude_mpm_dir / f"monitor-daemon-{self.port}.log"
95
97
 
96
98
  def cleanup_port_conflicts(self, max_retries: int = 3) -> bool:
97
99
  """Clean up any processes using the daemon port.
@@ -471,6 +473,55 @@ class DaemonManager:
471
473
 
472
474
  return None
473
475
 
476
+ def _verify_daemon_health(self, max_attempts: int = 3) -> bool:
477
+ """Verify daemon is healthy by checking HTTP health endpoint.
478
+
479
+ Args:
480
+ max_attempts: Maximum number of connection attempts
481
+
482
+ Returns:
483
+ True if health check passes, False otherwise
484
+ """
485
+ try:
486
+ import requests
487
+
488
+ for attempt in range(max_attempts):
489
+ try:
490
+ # Try to connect to health endpoint
491
+ response = requests.get(
492
+ f"http://{self.host}:{self.port}/health", timeout=2
493
+ )
494
+
495
+ if response.status_code == 200:
496
+ self.logger.debug(
497
+ f"Health check passed on attempt {attempt + 1}/{max_attempts}"
498
+ )
499
+ return True
500
+
501
+ self.logger.debug(
502
+ f"Health check returned status {response.status_code} on attempt {attempt + 1}/{max_attempts}"
503
+ )
504
+
505
+ except requests.exceptions.RequestException as e:
506
+ self.logger.debug(
507
+ f"Health check attempt {attempt + 1}/{max_attempts} failed: {e}"
508
+ )
509
+
510
+ # Wait before retry (except on last attempt)
511
+ if attempt < max_attempts - 1:
512
+ time.sleep(1)
513
+
514
+ self.logger.debug(f"Health check failed after {max_attempts} attempts")
515
+ return False
516
+
517
+ except ImportError:
518
+ # requests not available, skip health check
519
+ self.logger.debug("requests library not available, skipping health check")
520
+ return True
521
+ except Exception as e:
522
+ self.logger.debug(f"Health check error: {e}")
523
+ return False
524
+
474
525
  def start_daemon(self, force_restart: bool = False) -> bool:
475
526
  """Start the daemon with automatic cleanup and retry.
476
527
 
@@ -588,36 +639,62 @@ class DaemonManager:
588
639
  pid = process.pid
589
640
  self.logger.info(f"Monitor subprocess started with PID {pid}")
590
641
 
591
- # Wait for the subprocess to write its PID file
642
+ # Wait for the subprocess to write its PID file and bind to port
592
643
  # The subprocess will write the PID file after it starts successfully
593
644
  max_wait = 10 # seconds
594
645
  start_time = time.time()
646
+ pid_file_found = False
647
+ port_bound = False
648
+
649
+ self.logger.debug(f"Waiting up to {max_wait}s for daemon to start...")
595
650
 
596
651
  while time.time() - start_time < max_wait:
597
652
  # Check if process is still running
598
- if process.poll() is not None:
599
- # Process exited
653
+ returncode = process.poll()
654
+ if returncode is not None:
655
+ # Process exited - this is the bug we're fixing!
656
+ self.logger.error(
657
+ f"Monitor daemon subprocess exited prematurely with code {returncode}"
658
+ )
600
659
  self.logger.error(
601
- f"Monitor daemon exited with code {process.returncode}"
660
+ f"Port {self.port} daemon failed to start. Check {self.log_file} for details."
602
661
  )
603
662
  return False
604
663
 
605
664
  # Check if PID file was written
606
- if self.pid_file.exists():
665
+ if not pid_file_found and self.pid_file.exists():
607
666
  try:
608
667
  with self.pid_file.open() as f:
609
668
  written_pid = int(f.read().strip())
610
669
  if written_pid == pid:
611
- # PID file written correctly, check port
612
- if (
613
- not self._is_port_available()
614
- ): # Port NOT available means it's in use (good!)
615
- self.logger.info(
616
- f"Monitor daemon successfully started on port {self.port}"
617
- )
618
- return True
619
- except Exception:
620
- pass # PID file not ready yet
670
+ pid_file_found = True
671
+ self.logger.debug(
672
+ f"PID file found with correct PID {pid}"
673
+ )
674
+ except Exception as e:
675
+ self.logger.debug(f"Error reading PID file: {e}")
676
+
677
+ # Check if port is bound (health check)
678
+ if not port_bound and not self._is_port_available():
679
+ # Port NOT available means it's in use (good!)
680
+ port_bound = True
681
+ self.logger.debug(f"Port {self.port} is now bound")
682
+
683
+ # Success criteria: both PID file exists and port is bound
684
+ if pid_file_found and port_bound:
685
+ self.logger.info(
686
+ f"Monitor daemon successfully started on port {self.port} (PID: {pid})"
687
+ )
688
+ # Additional health check: verify we can connect
689
+ if self._verify_daemon_health(max_attempts=3):
690
+ self.logger.info("Daemon health check passed")
691
+ return True
692
+ self.logger.warning(
693
+ "Daemon started but health check failed - may still be initializing"
694
+ )
695
+ return (
696
+ True # Return success anyway if PID file and port are good
697
+ )
621
698
 
622
699
  time.sleep(0.5)
623
700
 
@@ -58,6 +58,10 @@ class ProjectOrganizer:
58
58
  ".mcp-vector-search/",
59
59
  ".kuzu-memory/",
60
60
  "kuzu-memories/", # kuzu-memory database directory
61
+ # User-specific config files (should NOT be committed)
62
+ ".mcp.json",
63
+ ".claude.json",
64
+ ".claude/",
61
65
  # Python artifacts
62
66
  "__pycache__/",
63
67
  "*.py[cod]",
@@ -10,10 +10,14 @@ This service handles:
10
10
  5. Hook service registration
11
11
 
12
12
  Extracted from ClaudeRunner to follow Single Responsibility Principle.
13
+
14
+ DEPENDENCY INJECTION:
15
+ This service uses protocol-based dependency injection to avoid circular imports
16
+ when registering the SessionManagementService.
13
17
  """
14
18
 
15
19
  import os
16
- from typing import Any, Dict, List, Optional, Tuple
20
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
17
21
 
18
22
  from claude_mpm.core.base_service import BaseService
19
23
  from claude_mpm.core.config import Config
@@ -22,6 +26,13 @@ from claude_mpm.core.logger import get_project_logger
22
26
  from claude_mpm.core.shared.config_loader import ConfigLoader
23
27
  from claude_mpm.services.core.interfaces import RunnerConfigurationInterface
24
28
 
29
+ # Protocol imports for type checking without circular dependencies
30
+ if TYPE_CHECKING:
31
+ from claude_mpm.core.protocols import ClaudeRunnerProtocol
32
+ else:
33
+ # At runtime, accept any object with matching interface
34
+ ClaudeRunnerProtocol = Any
35
+
25
36
 
26
37
  class RunnerConfigurationService(BaseService, RunnerConfigurationInterface):
27
38
  """Service for configuring and initializing ClaudeRunner components."""
@@ -495,12 +506,14 @@ class RunnerConfigurationService(BaseService, RunnerConfigurationInterface):
495
506
  )
496
507
  return None
497
508
 
498
- def register_session_management_service(self, container, runner):
509
+ def register_session_management_service(
510
+ self, container, runner: "ClaudeRunnerProtocol"
511
+ ):
499
512
  """Register session management service in the DI container.
500
513
 
501
514
  Args:
502
515
  container: DI container instance
503
- runner: ClaudeRunner instance dependency
516
+ runner: ClaudeRunner instance (or any object matching ClaudeRunnerProtocol)
504
517
 
505
518
  Returns:
506
519
  Initialized session management service or None if failed
@@ -9,29 +9,41 @@ This service handles:
9
9
  4. Session logging and cleanup
10
10
 
11
11
  Extracted from ClaudeRunner to follow Single Responsibility Principle.
12
+
13
+ DEPENDENCY INJECTION:
14
+ This service uses protocol-based dependency injection to avoid circular imports.
15
+ It accepts a ClaudeRunnerProtocol instead of importing ClaudeRunner directly.
12
16
  """
13
17
 
14
18
  import time
15
19
  import uuid
16
20
  from datetime import timezone
17
- from typing import Any, Dict, List, Optional
21
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
18
22
 
19
23
  from claude_mpm.core.base_service import BaseService
20
24
  from claude_mpm.core.enums import OperationResult, ServiceState
21
25
  from claude_mpm.services.core.interfaces import SessionManagementInterface
22
26
 
27
+ # Protocol imports for type checking without circular dependencies
28
+ if TYPE_CHECKING:
29
+ from claude_mpm.core.protocols import ClaudeRunnerProtocol
30
+ else:
31
+ # At runtime, accept any object with matching interface
32
+ ClaudeRunnerProtocol = Any
33
+
23
34
 
24
35
  class SessionManagementService(BaseService, SessionManagementInterface):
25
36
  """Service for managing Claude session orchestration."""
26
37
 
27
- def __init__(self, runner=None):
38
+ def __init__(self, runner: Optional["ClaudeRunnerProtocol"] = None):
28
39
  """Initialize the session management service.
29
40
 
30
41
  Args:
31
- runner: ClaudeRunner instance for delegation
42
+ runner: ClaudeRunner instance (or any object matching ClaudeRunnerProtocol)
43
+ for delegation
32
44
  """
33
45
  super().__init__(name="session_management_service")
34
- self.runner = runner
46
+ self.runner: Optional[ClaudeRunnerProtocol] = runner
35
47
  self.active_sessions = {} # Track active sessions
36
48
 
37
49
  async def _initialize(self) -> None:
@@ -0,0 +1,288 @@
1
+ """
2
+ Agent filtering utilities for claude-mpm.
3
+
4
+ WHY: This module provides centralized filtering logic to remove non-deployable
5
+ agents (BASE_AGENT) and already-deployed agents from user-facing displays.
6
+
7
+ DESIGN DECISIONS:
8
+ - BASE_AGENT is a build tool, not a deployable agent - filter everywhere
9
+ - Deployed agent detection supports both new (.claude-mpm/agents/) and
10
+ legacy (.claude/agents/)
11
+ - Case-insensitive BASE_AGENT detection for robustness
12
+ - Pure functions for easy testing and reuse
13
+
14
+ IMPLEMENTATION NOTES:
15
+ - Related to ticket 1M-502 Phase 1: UX improvements for agent filtering
16
+ - Addresses user confusion from seeing BASE_AGENT and deployed agents in lists
17
+ """
18
+
19
+ from pathlib import Path
20
+ from typing import Dict, List, Optional, Set
21
+
22
+
23
+ def is_base_agent(agent_id: str) -> bool:
24
+ """Check if agent is BASE_AGENT (build tool, not deployable).
25
+
26
+ BASE_AGENT is an internal build tool used to construct other agents.
27
+ It should never appear in user-facing agent lists or deployment menus.
28
+
29
+ Args:
30
+ agent_id: Agent identifier to check (may include path like "qa/BASE-AGENT")
31
+
32
+ Returns:
33
+ True if agent is BASE_AGENT (case-insensitive), False otherwise
34
+
35
+ Examples:
36
+ >>> is_base_agent("BASE_AGENT")
37
+ True
38
+ >>> is_base_agent("base-agent")
39
+ True
40
+ >>> is_base_agent("qa/BASE-AGENT")
41
+ True
42
+ >>> is_base_agent("ENGINEER")
43
+ False
44
+ """
45
+ if not agent_id:
46
+ return False
47
+
48
+ # Extract filename from path (handle cases like "qa/BASE-AGENT")
49
+ # 1M-502: Remote agents may have path prefixes like "qa/", "pm/", etc.
50
+ agent_name = agent_id.split("/")[-1]
51
+
52
+ normalized_id = agent_name.lower().replace("-", "").replace("_", "")
53
+ return normalized_id == "baseagent"
54
+
55
+
56
+ def filter_base_agents(agents: List[Dict]) -> List[Dict]:
57
+ """Remove BASE_AGENT from agent list.
58
+
59
+ Filters out any agent with agent_id matching BASE_AGENT (case-insensitive).
60
+ This prevents users from seeing or selecting the internal build tool.
61
+
62
+ Args:
63
+ agents: List of agent dictionaries, each containing at least 'agent_id' key
64
+
65
+ Returns:
66
+ Filtered list with BASE_AGENT removed
67
+
68
+ Examples:
69
+ >>> agents = [
70
+ ... {"agent_id": "ENGINEER", "name": "Engineer"},
71
+ ... {"agent_id": "BASE_AGENT", "name": "Base Agent"},
72
+ ... {"agent_id": "PM", "name": "PM"}
73
+ ... ]
74
+ >>> filtered = filter_base_agents(agents)
75
+ >>> len(filtered)
76
+ 2
77
+ >>> "BASE_AGENT" in [a["agent_id"] for a in filtered]
78
+ False
79
+ """
80
+ return [a for a in agents if not is_base_agent(a.get("agent_id", ""))]
81
+
82
+
83
+ def get_deployed_agent_ids(project_dir: Optional[Path] = None) -> Set[str]:
84
+ """Get set of currently deployed agent IDs.
85
+
86
+ Checks virtual deployment state (.mpm_deployment_state) first, then falls back
87
+ to physical .md files for backward compatibility. This ensures agents are detected
88
+ whether deployed virtually or as physical files.
89
+
90
+ Args:
91
+ project_dir: Project directory to check, defaults to current working directory
92
+
93
+ Returns:
94
+ Set of deployed agent IDs (leaf names like "python-engineer", "qa")
95
+
96
+ Examples:
97
+ >>> deployed = get_deployed_agent_ids()
98
+ >>> "python-engineer" in deployed # If agent exists in deployment state
99
+ True
100
+ >>> "ENGINEER" in deployed # If ENGINEER.md exists
101
+ True
102
+
103
+ Design Rationale:
104
+ - Primary detection: Virtual deployment state (.mpm_deployment_state)
105
+ - Fallback detection: Physical .md files (.claude-mpm/agents/, .claude/agents/)
106
+ - Returns leaf names for consistent comparison with agent_id formats
107
+ - Combines both detection methods for complete coverage
108
+ - Graceful error handling for malformed or missing state files
109
+
110
+ Related:
111
+ - Fixes checkbox interface showing all agents as "○ [Available]" instead of "● [Installed]"
112
+ - Matches detection logic from _is_agent_deployed() in agent_state_manager.py
113
+ - Related to ticket 1M-502: Virtual deployment state detection
114
+ """
115
+ deployed = set()
116
+
117
+ # Track if project_dir was explicitly provided
118
+ explicit_project_dir = project_dir is not None
119
+
120
+ if project_dir is None:
121
+ project_dir = Path.cwd()
122
+
123
+ # NEW: Check virtual deployment state (primary method)
124
+ # This is the current deployment model used by Claude Code
125
+ deployment_state_paths = [
126
+ project_dir / ".claude" / "agents" / ".mpm_deployment_state",
127
+ ]
128
+
129
+ # Only check user-level state if using default project directory
130
+ # This prevents test isolation issues when explicit project_dir is provided
131
+ if not explicit_project_dir:
132
+ deployment_state_paths.append(
133
+ Path.home() / ".claude" / "agents" / ".mpm_deployment_state"
134
+ )
135
+
136
+ for state_path in deployment_state_paths:
137
+ if state_path.exists():
138
+ try:
139
+ import json
140
+
141
+ with state_path.open() as f:
142
+ state = json.load(f)
143
+
144
+ # Extract agent IDs from deployment state
145
+ # Agent IDs are leaf names (e.g., "python-engineer", "qa")
146
+ agents = state.get("last_check_results", {}).get("agents", {})
147
+ deployed.update(agents.keys())
148
+
149
+ except (json.JSONDecodeError, KeyError) as e:
150
+ # Log error but continue - don't break if state file is malformed
151
+ import logging
152
+
153
+ logger = logging.getLogger(__name__)
154
+ logger.debug(f"Failed to read deployment state from {state_path}: {e}")
155
+ continue
156
+ except Exception as e:
157
+ # Catch unexpected errors - fail gracefully
158
+ import logging
159
+
160
+ logger = logging.getLogger(__name__)
161
+ logger.debug(f"Unexpected error reading deployment state: {e}")
162
+ continue
163
+
164
+ # EXISTING: Check physical .md files (fallback for backward compatibility)
165
+ # Check new architecture
166
+ new_agents_dir = project_dir / ".claude-mpm" / "agents"
167
+ if new_agents_dir.exists():
168
+ for file in new_agents_dir.glob("*.md"):
169
+ if file.stem not in {"BASE-AGENT", ".DS_Store"}:
170
+ deployed.add(file.stem)
171
+
172
+ # Check legacy architecture
173
+ legacy_agents_dir = project_dir / ".claude" / "agents"
174
+ if legacy_agents_dir.exists():
175
+ for file in legacy_agents_dir.glob("*.md"):
176
+ if file.stem not in {"BASE-AGENT", ".DS_Store"}:
177
+ deployed.add(file.stem)
178
+
179
+ # Check .claude/templates/ directory (where agents are actually deployed)
180
+ templates_dir = project_dir / ".claude" / "templates"
181
+ if templates_dir.exists():
182
+ for file in templates_dir.glob("*.md"):
183
+ if file.stem not in {
184
+ "BASE-AGENT",
185
+ ".DS_Store",
186
+ "README",
187
+ "circuit-breakers",
188
+ }:
189
+ # Skip template/example files
190
+ if not any(x in file.stem for x in ["example", "template", "pm-"]):
191
+ deployed.add(file.stem)
192
+
193
+ # Check user-level directory only if using default project directory
194
+ # This prevents test isolation issues when explicit project_dir is provided
195
+ if not explicit_project_dir:
196
+ user_agents_dir = Path.home() / ".claude" / "agents"
197
+ if user_agents_dir.exists():
198
+ for file in user_agents_dir.glob("*.md"):
199
+ if file.stem not in {"BASE-AGENT", ".DS_Store"}:
200
+ deployed.add(file.stem)
201
+
202
+ return deployed
203
+
204
+
205
+ def filter_deployed_agents(
206
+ agents: List[Dict], project_dir: Optional[Path] = None
207
+ ) -> List[Dict]:
208
+ """Remove already-deployed agents from list.
209
+
210
+ Filters agent list to show only agents that are not currently deployed.
211
+ This prevents users from attempting to re-deploy existing agents and
212
+ reduces confusion in deployment menus.
213
+
214
+ Args:
215
+ agents: List of agent dictionaries, each containing at least 'agent_id' key
216
+ project_dir: Project directory to check, defaults to current working directory
217
+
218
+ Returns:
219
+ Filtered list containing only non-deployed agents
220
+
221
+ Examples:
222
+ >>> agents = [
223
+ ... {"agent_id": "ENGINEER", "name": "Engineer"},
224
+ ... {"agent_id": "PM", "name": "PM"},
225
+ ... {"agent_id": "QA", "name": "QA"}
226
+ ... ]
227
+ >>> # Assuming ENGINEER is deployed
228
+ >>> filtered = filter_deployed_agents(agents)
229
+ >>> "ENGINEER" not in [a["agent_id"] for a in filtered]
230
+ True
231
+
232
+ Design Rationale:
233
+ - Checks filesystem for actual deployed files (source of truth)
234
+ - Supports both new and legacy agent directory structures
235
+ - Preserves agent order for consistent UX
236
+ """
237
+ deployed_ids = get_deployed_agent_ids(project_dir)
238
+ return [a for a in agents if a.get("agent_id") not in deployed_ids]
239
+
240
+
241
+ def apply_all_filters(
242
+ agents: List[Dict],
243
+ project_dir: Optional[Path] = None,
244
+ filter_base: bool = True,
245
+ filter_deployed: bool = False,
246
+ ) -> List[Dict]:
247
+ """Apply multiple filters to agent list in correct order.
248
+
249
+ Convenience function to apply common filtering combinations. Filters are
250
+ applied in this order:
251
+ 1. BASE_AGENT filtering (if enabled)
252
+ 2. Deployed agent filtering (if enabled)
253
+
254
+ Args:
255
+ agents: List of agent dictionaries to filter
256
+ project_dir: Project directory for deployment checks
257
+ filter_base: Remove BASE_AGENT from list (default: True)
258
+ filter_deployed: Remove deployed agents from list (default: False)
259
+
260
+ Returns:
261
+ Filtered agent list
262
+
263
+ Examples:
264
+ >>> agents = get_all_agents()
265
+ >>> # For display/info purposes - remove only BASE_AGENT
266
+ >>> filtered = apply_all_filters(
267
+ ... agents, filter_base=True, filter_deployed=False
268
+ ... )
269
+ >>> # For deployment menus - remove BASE_AGENT and deployed agents
270
+ >>> deployable = apply_all_filters(
271
+ ... agents, filter_base=True, filter_deployed=True
272
+ ... )
273
+
274
+ Usage Guidelines:
275
+ - Use filter_base=True (default) for all user-facing displays
276
+ - Use filter_deployed=True when showing deployment options
277
+ - Use filter_deployed=False when showing all available agents
278
+ (info/list commands)
279
+ """
280
+ result = agents
281
+
282
+ if filter_base:
283
+ result = filter_base_agents(result)
284
+
285
+ if filter_deployed:
286
+ result = filter_deployed_agents(result, project_dir)
287
+
288
+ return result
@@ -212,6 +212,9 @@ def ensure_claude_mpm_gitignore(project_dir: str = ".") -> dict:
212
212
  entries_to_add = [
213
213
  ".claude-mpm/",
214
214
  ".claude/agents/",
215
+ ".mcp.json",
216
+ ".claude.json",
217
+ ".claude/",
215
218
  ]
216
219
 
217
220
  added, existing = manager.ensure_entries(entries_to_add)