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.
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/CLAUDE_MPM_TEACHER_OUTPUT_STYLE.md +2002 -0
- claude_mpm/agents/PM_INSTRUCTIONS.md +1176 -909
- claude_mpm/agents/base_agent_loader.py +10 -35
- claude_mpm/agents/frontmatter_validator.py +68 -0
- claude_mpm/agents/templates/circuit-breakers.md +293 -44
- claude_mpm/cli/__init__.py +0 -1
- claude_mpm/cli/commands/__init__.py +2 -0
- claude_mpm/cli/commands/agent_state_manager.py +64 -11
- claude_mpm/cli/commands/agents.py +446 -25
- claude_mpm/cli/commands/auto_configure.py +535 -233
- claude_mpm/cli/commands/configure.py +545 -89
- claude_mpm/cli/commands/postmortem.py +401 -0
- claude_mpm/cli/commands/run.py +1 -39
- claude_mpm/cli/commands/skills.py +322 -19
- claude_mpm/cli/interactive/agent_wizard.py +302 -195
- claude_mpm/cli/parsers/agents_parser.py +137 -0
- claude_mpm/cli/parsers/auto_configure_parser.py +13 -0
- claude_mpm/cli/parsers/base_parser.py +4 -0
- claude_mpm/cli/parsers/skills_parser.py +7 -0
- claude_mpm/cli/startup.py +73 -32
- claude_mpm/commands/mpm-agents-auto-configure.md +2 -2
- claude_mpm/commands/mpm-agents-list.md +2 -2
- claude_mpm/commands/mpm-config-view.md +2 -2
- claude_mpm/commands/mpm-help.md +3 -0
- claude_mpm/commands/mpm-postmortem.md +123 -0
- claude_mpm/commands/mpm-session-resume.md +2 -2
- claude_mpm/commands/mpm-ticket-organize.md +2 -2
- claude_mpm/commands/mpm-ticket-view.md +2 -2
- claude_mpm/config/agent_presets.py +312 -82
- claude_mpm/config/skill_presets.py +392 -0
- claude_mpm/constants.py +1 -0
- claude_mpm/core/claude_runner.py +2 -25
- claude_mpm/core/framework/loaders/file_loader.py +54 -101
- claude_mpm/core/interactive_session.py +19 -5
- claude_mpm/core/oneshot_session.py +16 -4
- claude_mpm/core/output_style_manager.py +173 -43
- claude_mpm/core/protocols/__init__.py +23 -0
- claude_mpm/core/protocols/runner_protocol.py +103 -0
- claude_mpm/core/protocols/session_protocol.py +131 -0
- claude_mpm/core/shared/singleton_manager.py +11 -4
- claude_mpm/core/system_context.py +38 -0
- claude_mpm/core/unified_agent_registry.py +129 -1
- claude_mpm/core/unified_config.py +22 -0
- claude_mpm/hooks/claude_hooks/memory_integration.py +12 -1
- claude_mpm/models/agent_definition.py +7 -0
- claude_mpm/services/agents/cache_git_manager.py +621 -0
- claude_mpm/services/agents/deployment/multi_source_deployment_service.py +110 -3
- claude_mpm/services/agents/deployment/remote_agent_discovery_service.py +195 -1
- claude_mpm/services/agents/sources/git_source_sync_service.py +37 -5
- claude_mpm/services/analysis/__init__.py +25 -0
- claude_mpm/services/analysis/postmortem_reporter.py +474 -0
- claude_mpm/services/analysis/postmortem_service.py +765 -0
- claude_mpm/services/command_deployment_service.py +108 -5
- claude_mpm/services/core/base.py +7 -2
- claude_mpm/services/diagnostics/checks/mcp_services_check.py +7 -15
- claude_mpm/services/git/git_operations_service.py +8 -8
- claude_mpm/services/mcp_config_manager.py +75 -145
- claude_mpm/services/mcp_gateway/core/process_pool.py +22 -16
- claude_mpm/services/mcp_service_verifier.py +6 -3
- claude_mpm/services/monitor/daemon.py +28 -8
- claude_mpm/services/monitor/daemon_manager.py +96 -19
- claude_mpm/services/project/project_organizer.py +4 -0
- claude_mpm/services/runner_configuration_service.py +16 -3
- claude_mpm/services/session_management_service.py +16 -4
- claude_mpm/utils/agent_filters.py +288 -0
- claude_mpm/utils/gitignore.py +3 -0
- claude_mpm/utils/migration.py +372 -0
- claude_mpm/utils/progress.py +5 -1
- {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/METADATA +69 -8
- {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/RECORD +76 -62
- /claude_mpm/agents/{OUTPUT_STYLE.md → CLAUDE_MPM_OUTPUT_STYLE.md} +0 -0
- {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/WHEEL +0 -0
- {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/entry_points.txt +0 -0
- {claude_mpm-5.0.2.dist-info → claude_mpm-5.1.9.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
-
|
|
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.
|
|
156
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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"
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
)
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
claude_mpm/utils/gitignore.py
CHANGED
|
@@ -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)
|