claude-mpm 5.4.22__py3-none-any.whl → 5.4.48__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 (119) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/BASE_AGENT.md +164 -0
  3. claude_mpm/agents/BASE_ENGINEER.md +658 -0
  4. claude_mpm/agents/MEMORY.md +1 -1
  5. claude_mpm/agents/PM_INSTRUCTIONS.md +739 -1052
  6. claude_mpm/agents/WORKFLOW.md +5 -254
  7. claude_mpm/agents/agent_loader.py +1 -1
  8. claude_mpm/agents/base_agent.json +31 -0
  9. claude_mpm/agents/frontmatter_validator.py +2 -2
  10. claude_mpm/cli/commands/agent_state_manager.py +10 -10
  11. claude_mpm/cli/commands/agents.py +9 -9
  12. claude_mpm/cli/commands/auto_configure.py +4 -4
  13. claude_mpm/cli/commands/configure.py +1 -1
  14. claude_mpm/cli/commands/configure_agent_display.py +10 -0
  15. claude_mpm/cli/commands/mpm_init/core.py +65 -0
  16. claude_mpm/cli/commands/postmortem.py +1 -1
  17. claude_mpm/cli/commands/profile.py +277 -0
  18. claude_mpm/cli/commands/skills.py +14 -18
  19. claude_mpm/cli/executor.py +10 -0
  20. claude_mpm/cli/interactive/agent_wizard.py +2 -2
  21. claude_mpm/cli/parsers/base_parser.py +7 -0
  22. claude_mpm/cli/parsers/profile_parser.py +148 -0
  23. claude_mpm/cli/parsers/skills_parser.py +0 -6
  24. claude_mpm/cli/startup.py +346 -75
  25. claude_mpm/commands/mpm-config.md +13 -250
  26. claude_mpm/commands/mpm-doctor.md +9 -22
  27. claude_mpm/commands/mpm-help.md +5 -206
  28. claude_mpm/commands/mpm-init.md +81 -507
  29. claude_mpm/commands/mpm-monitor.md +15 -402
  30. claude_mpm/commands/mpm-organize.md +61 -441
  31. claude_mpm/commands/mpm-postmortem.md +6 -108
  32. claude_mpm/commands/mpm-session-resume.md +12 -363
  33. claude_mpm/commands/mpm-status.md +5 -69
  34. claude_mpm/commands/mpm-ticket-view.md +52 -495
  35. claude_mpm/commands/mpm-version.md +5 -107
  36. claude_mpm/core/config.py +2 -4
  37. claude_mpm/core/framework/loaders/agent_loader.py +1 -1
  38. claude_mpm/core/framework/loaders/instruction_loader.py +52 -11
  39. claude_mpm/core/optimized_startup.py +59 -0
  40. claude_mpm/core/shared/config_loader.py +1 -1
  41. claude_mpm/core/unified_agent_registry.py +1 -1
  42. claude_mpm/dashboard/static/svelte-build/_app/env.js +1 -0
  43. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.B_FtCwCQ.css +1 -0
  44. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.Cl_eSA4x.css +1 -0
  45. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BgChzWQ1.js +1 -0
  46. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CIXEwuWe.js +1 -0
  47. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CWc5urbQ.js +1 -0
  48. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DMkZpdF2.js +2 -0
  49. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DjhvlsAc.js +1 -0
  50. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/N4qtv3Hx.js +2 -0
  51. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/uj46x2Wr.js +1 -0
  52. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/app.DTL5mJO-.js +2 -0
  53. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.DzuEhzqh.js +1 -0
  54. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/0.CAGBuiOw.js +1 -0
  55. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/1.DFLC8jdE.js +1 -0
  56. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.DPvEihJJ.js +10 -0
  57. claude_mpm/dashboard/static/svelte-build/_app/version.json +1 -0
  58. claude_mpm/dashboard/static/svelte-build/favicon.svg +7 -0
  59. claude_mpm/dashboard/static/svelte-build/index.html +36 -0
  60. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  61. claude_mpm/hooks/claude_hooks/__pycache__/correlation_manager.cpython-311.pyc +0 -0
  62. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  63. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  64. claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
  65. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  66. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  67. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-311.pyc +0 -0
  68. claude_mpm/hooks/claude_hooks/hook_handler.py +149 -1
  69. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
  70. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
  71. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  72. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-311.pyc +0 -0
  73. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  74. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  75. claude_mpm/hooks/claude_hooks/services/connection_manager.py +26 -6
  76. claude_mpm/hooks/kuzu_memory_hook.py +5 -5
  77. claude_mpm/init.py +63 -0
  78. claude_mpm/models/git_repository.py +3 -3
  79. claude_mpm/scripts/start_activity_logging.py +0 -0
  80. claude_mpm/services/agents/agent_builder.py +3 -3
  81. claude_mpm/services/agents/cache_git_manager.py +6 -6
  82. claude_mpm/services/agents/deployment/agent_deployment.py +29 -7
  83. claude_mpm/services/agents/deployment/agent_discovery_service.py +2 -2
  84. claude_mpm/services/agents/deployment/agent_format_converter.py +23 -13
  85. claude_mpm/services/agents/deployment/agent_template_builder.py +29 -19
  86. claude_mpm/services/agents/deployment/agents_directory_resolver.py +2 -2
  87. claude_mpm/services/agents/deployment/async_agent_deployment.py +31 -27
  88. claude_mpm/services/agents/deployment/local_template_deployment.py +3 -1
  89. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +169 -26
  90. claude_mpm/services/agents/deployment/remote_agent_discovery_service.py +98 -75
  91. claude_mpm/services/agents/git_source_manager.py +19 -4
  92. claude_mpm/services/agents/recommender.py +5 -3
  93. claude_mpm/services/agents/single_tier_deployment_service.py +2 -2
  94. claude_mpm/services/agents/sources/git_source_sync_service.py +112 -6
  95. claude_mpm/services/agents/startup_sync.py +22 -2
  96. claude_mpm/services/diagnostics/checks/agent_check.py +2 -2
  97. claude_mpm/services/diagnostics/checks/agent_sources_check.py +1 -1
  98. claude_mpm/services/git/git_operations_service.py +8 -8
  99. claude_mpm/services/monitor/management/lifecycle.py +8 -1
  100. claude_mpm/services/monitor/server.py +473 -3
  101. claude_mpm/services/pm_skills_deployer.py +711 -0
  102. claude_mpm/services/profile_manager.py +331 -0
  103. claude_mpm/services/skills/git_skill_source_manager.py +101 -3
  104. claude_mpm/services/skills_deployer.py +4 -3
  105. claude_mpm/services/socketio/dashboard_server.py +1 -0
  106. claude_mpm/services/socketio/event_normalizer.py +37 -6
  107. claude_mpm/services/socketio/server/core.py +262 -123
  108. claude_mpm/skills/skill_manager.py +92 -3
  109. claude_mpm/utils/agent_dependency_loader.py +14 -2
  110. claude_mpm/utils/agent_filters.py +1 -1
  111. claude_mpm/utils/migration.py +4 -4
  112. claude_mpm/utils/robust_installer.py +47 -3
  113. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/METADATA +7 -4
  114. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/RECORD +118 -79
  115. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/WHEEL +0 -0
  116. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/entry_points.txt +0 -0
  117. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/licenses/LICENSE +0 -0
  118. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  119. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,331 @@
1
+ """
2
+ Profile Manager Service
3
+ ======================
4
+
5
+ Manages agent and skill filtering based on deployment profiles.
6
+
7
+ A profile defines which agents and skills should be deployed, reducing
8
+ context usage by limiting available agents to only what's needed for
9
+ a specific project or workflow.
10
+
11
+ Profile Structure:
12
+ profile:
13
+ name: framework-development
14
+ description: Python backend + TypeScript/Svelte dashboard
15
+
16
+ agents:
17
+ enabled:
18
+ - python-engineer
19
+ - typescript-engineer
20
+ disabled:
21
+ - java-engineer
22
+ - dart-engineer
23
+
24
+ skills:
25
+ enabled:
26
+ - flask
27
+ - pytest
28
+ disabled_categories:
29
+ - wordpress-*
30
+ - react-*
31
+
32
+ Usage:
33
+ # Auto-detect project directory (searches for .claude-mpm in cwd and parents)
34
+ profile_manager = ProfileManager()
35
+
36
+ # Or explicitly specify project directory
37
+ profile_manager = ProfileManager(project_dir=Path("/path/to/project"))
38
+
39
+ profile_manager.load_profile("framework-development")
40
+
41
+ if profile_manager.is_agent_enabled("python-engineer"):
42
+ # Deploy agent
43
+ pass
44
+
45
+ if profile_manager.is_skill_enabled("flask"):
46
+ # Deploy skill
47
+ pass
48
+ """
49
+
50
+ import fnmatch
51
+ from pathlib import Path
52
+ from typing import Optional, Set, Dict, Any
53
+
54
+ import yaml
55
+
56
+ from ..core.logger import get_logger
57
+
58
+ logger = get_logger(__name__)
59
+
60
+
61
+ class ProfileManager:
62
+ """
63
+ Manages deployment profiles for agent and skill filtering.
64
+
65
+ Provides methods to:
66
+ - Load profiles from YAML files
67
+ - Check if agents are enabled/disabled
68
+ - Check if skills are enabled/disabled (with glob pattern support)
69
+ - Get lists of enabled/disabled entities
70
+ """
71
+
72
+ def __init__(self, project_dir: Optional[Path] = None, profiles_dir: Optional[Path] = None):
73
+ """
74
+ Initialize ProfileManager.
75
+
76
+ Args:
77
+ project_dir: Project root directory. If not provided, tries to find
78
+ .claude-mpm directory in current or parent directories.
79
+ profiles_dir: Directory containing profile YAML files. If provided,
80
+ takes precedence over project_dir.
81
+ """
82
+ if profiles_dir:
83
+ self.profiles_dir = profiles_dir
84
+ elif project_dir:
85
+ self.profiles_dir = Path(project_dir) / ".claude-mpm" / "profiles"
86
+ else:
87
+ # Try to find .claude-mpm directory automatically
88
+ self.profiles_dir = self._find_profiles_dir()
89
+
90
+ self.active_profile: Optional[str] = None
91
+ self._profile_data: Dict[str, Any] = {}
92
+
93
+ # Cached sets for performance
94
+ self._enabled_agents: Set[str] = set()
95
+ self._disabled_agents: Set[str] = set()
96
+ self._enabled_skills: Set[str] = set()
97
+ self._disabled_skill_patterns: list[str] = []
98
+
99
+ def _find_profiles_dir(self) -> Path:
100
+ """Find profiles directory by searching for .claude-mpm in cwd and parents.
101
+
102
+ Returns:
103
+ Path to profiles directory (may not exist yet)
104
+ """
105
+ current = Path.cwd()
106
+
107
+ # Search current directory and up to 5 parent directories
108
+ for _ in range(6):
109
+ profiles_dir = current / ".claude-mpm" / "profiles"
110
+ if profiles_dir.exists():
111
+ logger.debug(f"Found profiles directory at: {profiles_dir}")
112
+ return profiles_dir
113
+ if current.parent == current: # Reached filesystem root
114
+ break
115
+ current = current.parent
116
+
117
+ # Fallback to cwd (directory may not exist yet, which is fine)
118
+ fallback = Path.cwd() / ".claude-mpm" / "profiles"
119
+ logger.debug(f"Profiles directory not found, using fallback: {fallback}")
120
+ return fallback
121
+
122
+ def load_profile(self, profile_name: str) -> bool:
123
+ """
124
+ Load profile from YAML file.
125
+
126
+ Args:
127
+ profile_name: Name of profile (without .yaml extension)
128
+
129
+ Returns:
130
+ bool: True if profile loaded successfully, False otherwise
131
+ """
132
+ profile_path = self.profiles_dir / f"{profile_name}.yaml"
133
+
134
+ logger.debug(f"Looking for profile at: {profile_path}")
135
+
136
+ if not profile_path.exists():
137
+ logger.warning(f"Profile not found: {profile_path}")
138
+ return False
139
+
140
+ try:
141
+ with profile_path.open("r") as f:
142
+ self._profile_data = yaml.safe_load(f) or {}
143
+
144
+ # Extract profile metadata
145
+ profile_info = self._profile_data.get("profile", {})
146
+ self.active_profile = profile_info.get("name", profile_name)
147
+
148
+ # Parse agents
149
+ agents_config = self._profile_data.get("agents", {})
150
+ self._enabled_agents = set(agents_config.get("enabled", []))
151
+ self._disabled_agents = set(agents_config.get("disabled", []))
152
+
153
+ # Parse skills
154
+ skills_config = self._profile_data.get("skills", {})
155
+ self._enabled_skills = set(skills_config.get("enabled", []))
156
+ self._disabled_skill_patterns = skills_config.get("disabled_categories", [])
157
+
158
+ logger.info(
159
+ f"Loaded profile '{self.active_profile}': "
160
+ f"{len(self._enabled_agents)} agents, "
161
+ f"{len(self._enabled_skills)} skills enabled"
162
+ )
163
+
164
+ return True
165
+
166
+ except Exception as e:
167
+ logger.error(f"Failed to load profile {profile_name}: {e}")
168
+ return False
169
+
170
+ def is_agent_enabled(self, agent_name: str) -> bool:
171
+ """
172
+ Check if agent is enabled in active profile.
173
+
174
+ If no profile is loaded, all agents are enabled by default.
175
+
176
+ Args:
177
+ agent_name: Name of agent to check
178
+
179
+ Returns:
180
+ bool: True if agent should be deployed
181
+ """
182
+ if not self.active_profile:
183
+ # No profile active - all agents enabled
184
+ return True
185
+
186
+ # If enabled list exists, agent must be in it
187
+ if self._enabled_agents:
188
+ return agent_name in self._enabled_agents
189
+
190
+ # Otherwise, agent must NOT be in disabled list
191
+ return agent_name not in self._disabled_agents
192
+
193
+ def is_skill_enabled(self, skill_name: str) -> bool:
194
+ """
195
+ Check if skill is enabled in active profile.
196
+
197
+ Supports both short names (flask) and full names (toolchains-python-frameworks-flask).
198
+ Supports glob pattern matching for disabled_categories.
199
+
200
+ If no profile is loaded, all skills are enabled by default.
201
+
202
+ Args:
203
+ skill_name: Name of skill to check (e.g., "flask", "toolchains-python-frameworks-flask")
204
+
205
+ Returns:
206
+ bool: True if skill should be deployed
207
+ """
208
+ if not self.active_profile:
209
+ # No profile active - all skills enabled
210
+ return True
211
+
212
+ # Check if skill is explicitly disabled by pattern
213
+ for pattern in self._disabled_skill_patterns:
214
+ if fnmatch.fnmatch(skill_name, pattern):
215
+ logger.debug(f"Skill '{skill_name}' matched disabled pattern '{pattern}'")
216
+ return False
217
+
218
+ # If enabled list exists, check for match
219
+ if self._enabled_skills:
220
+ # Exact match
221
+ if skill_name in self._enabled_skills:
222
+ return True
223
+
224
+ # Check if full skill name ends with short name from enabled list
225
+ # Example: "toolchains-python-frameworks-flask" matches "flask"
226
+ for short_name in self._enabled_skills:
227
+ if skill_name.endswith(f"-{short_name}"):
228
+ return True
229
+ # Also check if short name is contained as a segment
230
+ if f"-{short_name}-" in skill_name or skill_name.startswith(f"{short_name}-"):
231
+ return True
232
+
233
+ return False
234
+
235
+ # No enabled list and didn't match disabled pattern - allow it
236
+ return True
237
+
238
+ def get_enabled_agents(self) -> Set[str]:
239
+ """
240
+ Get set of enabled agent names.
241
+
242
+ Returns:
243
+ Set[str]: Agent names that should be deployed
244
+ """
245
+ return self._enabled_agents.copy()
246
+
247
+ def get_disabled_agents(self) -> Set[str]:
248
+ """
249
+ Get set of disabled agent names.
250
+
251
+ Returns:
252
+ Set[str]: Agent names that should NOT be deployed
253
+ """
254
+ return self._disabled_agents.copy()
255
+
256
+ def get_enabled_skills(self) -> Set[str]:
257
+ """
258
+ Get set of explicitly enabled skill names.
259
+
260
+ Returns:
261
+ Set[str]: Skill names that should be deployed
262
+ """
263
+ return self._enabled_skills.copy()
264
+
265
+ def get_disabled_skill_patterns(self) -> list[str]:
266
+ """
267
+ Get list of disabled skill glob patterns.
268
+
269
+ Returns:
270
+ list[str]: Glob patterns for skills that should NOT be deployed
271
+ """
272
+ return self._disabled_skill_patterns.copy()
273
+
274
+ def get_filtering_summary(self) -> Dict[str, Any]:
275
+ """
276
+ Get summary of current profile filtering.
277
+
278
+ Returns:
279
+ Dict containing:
280
+ - active_profile: Name of active profile (or None)
281
+ - enabled_agents_count: Number of explicitly enabled agents
282
+ - disabled_agents_count: Number of explicitly disabled agents
283
+ - enabled_skills_count: Number of explicitly enabled skills
284
+ - disabled_patterns_count: Number of disabled skill patterns
285
+ """
286
+ return {
287
+ "active_profile": self.active_profile,
288
+ "enabled_agents_count": len(self._enabled_agents),
289
+ "disabled_agents_count": len(self._disabled_agents),
290
+ "enabled_skills_count": len(self._enabled_skills),
291
+ "disabled_patterns_count": len(self._disabled_skill_patterns),
292
+ }
293
+
294
+ def list_available_profiles(self) -> list[str]:
295
+ """
296
+ List all available profile names in profiles directory.
297
+
298
+ Returns:
299
+ list[str]: Profile names (without .yaml extension)
300
+ """
301
+ if not self.profiles_dir.exists():
302
+ return []
303
+
304
+ profiles = []
305
+ for profile_path in self.profiles_dir.glob("*.yaml"):
306
+ profiles.append(profile_path.stem)
307
+
308
+ return sorted(profiles)
309
+
310
+ def get_profile_description(self, profile_name: str) -> Optional[str]:
311
+ """
312
+ Get description of a profile without loading it fully.
313
+
314
+ Args:
315
+ profile_name: Name of profile
316
+
317
+ Returns:
318
+ Optional[str]: Profile description or None if not found
319
+ """
320
+ profile_path = self.profiles_dir / f"{profile_name}.yaml"
321
+
322
+ if not profile_path.exists():
323
+ return None
324
+
325
+ try:
326
+ with profile_path.open("r") as f:
327
+ data = yaml.safe_load(f) or {}
328
+ profile_info = data.get("profile", {})
329
+ return profile_info.get("description")
330
+ except Exception:
331
+ return None
@@ -1049,12 +1049,33 @@ class GitSkillSourceManager:
1049
1049
  original_count = len(all_skills)
1050
1050
  # Normalize filter to lowercase for case-insensitive matching
1051
1051
  normalized_filter = {s.lower() for s in skill_filter}
1052
- # Match against deployment_name (not display name) since skill_filter contains
1053
- # deployment-style names like "toolchains-python-frameworks-django"
1052
+
1053
+ def matches_filter(deployment_name: str) -> bool:
1054
+ """Match using same fuzzy logic as ProfileManager.is_skill_enabled()"""
1055
+ deployment_lower = deployment_name.lower()
1056
+
1057
+ # Exact match
1058
+ if deployment_lower in normalized_filter:
1059
+ return True
1060
+
1061
+ # Fuzzy match: check if deployment name ends with or contains short name
1062
+ # Example: "toolchains-python-frameworks-flask" matches "flask"
1063
+ for short_name in normalized_filter:
1064
+ if deployment_lower.endswith(f"-{short_name}"):
1065
+ return True
1066
+ # Check if short name is contained as a segment
1067
+ if f"-{short_name}-" in deployment_lower:
1068
+ return True
1069
+ if deployment_lower.startswith(f"{short_name}-"):
1070
+ return True
1071
+
1072
+ return False
1073
+
1074
+ # Match against deployment_name using fuzzy matching
1054
1075
  all_skills = [
1055
1076
  s
1056
1077
  for s in all_skills
1057
- if s.get("deployment_name", "").lower() in normalized_filter
1078
+ if matches_filter(s.get("deployment_name", ""))
1058
1079
  ]
1059
1080
  filtered_count = original_count - len(all_skills)
1060
1081
  self.logger.info(
@@ -1062,6 +1083,16 @@ class GitSkillSourceManager:
1062
1083
  f"match agent requirements ({filtered_count} filtered out)"
1063
1084
  )
1064
1085
 
1086
+ # Cleanup: Remove skills from target directory that aren't in the filtered set
1087
+ # This ensures only the skills in the profile are deployed
1088
+ removed_skills = self._cleanup_unfiltered_skills(
1089
+ target_dir, all_skills
1090
+ )
1091
+ if removed_skills:
1092
+ self.logger.info(
1093
+ f"Removed {len(removed_skills)} skills not in profile filter: {removed_skills}"
1094
+ )
1095
+
1065
1096
  self.logger.info(
1066
1097
  f"Deploying {len(all_skills)} skills to {target_dir} (force={force})"
1067
1098
  )
@@ -1115,6 +1146,73 @@ class GitSkillSourceManager:
1115
1146
  "filtered_count": filtered_count,
1116
1147
  }
1117
1148
 
1149
+ def _cleanup_unfiltered_skills(
1150
+ self, target_dir: Path, filtered_skills: List[Dict[str, Any]]
1151
+ ) -> List[str]:
1152
+ """Remove skills from target directory that aren't in the filtered skill list.
1153
+
1154
+ Args:
1155
+ target_dir: Target deployment directory
1156
+ filtered_skills: List of skills that should remain deployed
1157
+
1158
+ Returns:
1159
+ List of skill names that were removed
1160
+ """
1161
+ import shutil
1162
+
1163
+ removed_skills = []
1164
+
1165
+ # Build set of deployment names that should exist
1166
+ expected_deployments = {
1167
+ skill.get("deployment_name") for skill in filtered_skills
1168
+ if skill.get("deployment_name")
1169
+ }
1170
+
1171
+ # Check each directory in target_dir
1172
+ if not target_dir.exists():
1173
+ return removed_skills
1174
+
1175
+ try:
1176
+ for item in target_dir.iterdir():
1177
+ # Skip files, only process directories
1178
+ if not item.is_dir():
1179
+ continue
1180
+
1181
+ # Skip hidden directories
1182
+ if item.name.startswith("."):
1183
+ continue
1184
+
1185
+ # Check if this skill directory should be kept
1186
+ if item.name not in expected_deployments:
1187
+ try:
1188
+ # Security: Validate path is within target_dir
1189
+ if not self._validate_safe_path(target_dir, item):
1190
+ self.logger.error(
1191
+ f"Refusing to remove path outside target directory: {item}"
1192
+ )
1193
+ continue
1194
+
1195
+ # Remove the skill directory
1196
+ if item.is_symlink():
1197
+ item.unlink()
1198
+ else:
1199
+ shutil.rmtree(item)
1200
+
1201
+ removed_skills.append(item.name)
1202
+ self.logger.debug(
1203
+ f"Removed unfiltered skill: {item.name}"
1204
+ )
1205
+
1206
+ except Exception as e:
1207
+ self.logger.warning(
1208
+ f"Failed to remove skill directory {item.name}: {e}"
1209
+ )
1210
+
1211
+ except Exception as e:
1212
+ self.logger.error(f"Error during skill cleanup: {e}")
1213
+
1214
+ return removed_skills
1215
+
1118
1216
  def _deploy_single_skill(
1119
1217
  self, skill: Dict[str, Any], target_dir: Path, deployment_name: str, force: bool
1120
1218
  ) -> Dict[str, Any]:
@@ -257,9 +257,9 @@ class SkillsDeployerService(LoggerMixin):
257
257
  self.logger.error(f"Failed to deploy {skill_name}: {e}")
258
258
  errors.append(f"{skill_name}: {e}")
259
259
 
260
- # Step 5: Cleanup orphaned skills (if selective mode enabled)
260
+ # Step 5: Cleanup orphaned skills (always run in selective mode)
261
261
  cleanup_result = {"removed_count": 0, "removed_skills": []}
262
- if selective and len(deployed) > 0:
262
+ if selective:
263
263
  # Get the set of skills that should remain deployed
264
264
  # This is the union of what we just deployed and what was already there
265
265
  try:
@@ -267,7 +267,8 @@ class SkillsDeployerService(LoggerMixin):
267
267
  cleanup_orphan_skills,
268
268
  )
269
269
 
270
- # Only cleanup if we're in selective mode
270
+ # Cleanup orphaned skills not referenced by agents
271
+ # This runs even if nothing new was deployed to remove stale skills
271
272
  cleanup_result = cleanup_orphan_skills(
272
273
  self.CLAUDE_SKILLS_DIR, set(filtered_skills_names)
273
274
  )
@@ -152,6 +152,7 @@ class DashboardServer(SocketIOServiceInterface):
152
152
 
153
153
  # Register handlers for all events we want to relay from monitor to dashboard
154
154
  relay_events = [
155
+ "claude_event", # Tool events from Claude Code hooks
155
156
  "session_started",
156
157
  "session_ended",
157
158
  "claude_status",
@@ -6,7 +6,7 @@ This normalizer ensures all events follow a consistent schema before broadcastin
6
6
  providing backward compatibility while establishing a standard format.
7
7
 
8
8
  DESIGN DECISION: Transform all events to a consistent schema:
9
- - event: Socket.IO event name (always "claude_event")
9
+ - event: Socket.IO event name (always "mpm_event")
10
10
  - type: Main category (hook, system, session, file, connection)
11
11
  - subtype: Specific event type (pre_tool, heartbeat, started, etc.)
12
12
  - timestamp: ISO format timestamp
@@ -72,7 +72,7 @@ class NormalizedEvent:
72
72
  structure explicit and self-documenting.
73
73
  """
74
74
 
75
- event: str = "claude_event" # Socket.IO event name
75
+ event: str = "mpm_event" # Socket.IO event name
76
76
  source: str = "" # WHERE the event comes from
77
77
  type: str = "" # WHAT category of event
78
78
  subtype: str = "" # Specific event type
@@ -81,6 +81,8 @@ class NormalizedEvent:
81
81
  correlation_id: Optional[str] = (
82
82
  None # For correlating related events (e.g., pre_tool/post_tool)
83
83
  )
84
+ session_id: Optional[str] = None # Session identifier for stream grouping
85
+ cwd: Optional[str] = None # Working directory for project identification
84
86
 
85
87
  def to_dict(self) -> Dict[str, Any]:
86
88
  """Convert to dictionary for emission."""
@@ -95,6 +97,12 @@ class NormalizedEvent:
95
97
  # Include correlation_id if present
96
98
  if self.correlation_id:
97
99
  result["correlation_id"] = self.correlation_id
100
+ # Include session_id if present
101
+ if self.session_id:
102
+ result["session_id"] = self.session_id
103
+ # Include cwd if present
104
+ if self.cwd:
105
+ result["cwd"] = self.cwd
98
106
  return result
99
107
 
100
108
 
@@ -113,6 +121,7 @@ class EventNormalizer:
113
121
  "pre_response": (EventType.HOOK, "pre_response"),
114
122
  "post_response": (EventType.HOOK, "post_response"),
115
123
  "hook_event": (EventType.HOOK, "generic"),
124
+ "hook_execution": (EventType.HOOK, "execution"), # Hook execution metadata
116
125
  "UserPrompt": (EventType.HOOK, "user_prompt"), # Legacy format
117
126
  # Test events (legacy format)
118
127
  "TestStart": (EventType.TEST, "start"),
@@ -225,20 +234,32 @@ class EventNormalizer:
225
234
  # Get or generate timestamp
226
235
  timestamp = self._extract_timestamp(event_data)
227
236
 
228
- # Extract correlation_id if present
237
+ # Extract correlation_id, session_id, and cwd if present
229
238
  correlation_id = None
239
+ session_id = None
240
+ cwd = None
230
241
  if isinstance(event_data, dict):
231
242
  correlation_id = event_data.get("correlation_id")
243
+ # Try both naming conventions for session_id
244
+ session_id = event_data.get("session_id") or event_data.get("sessionId")
245
+ # Try multiple field names for working directory
246
+ cwd = (
247
+ event_data.get("cwd")
248
+ or event_data.get("working_directory")
249
+ or event_data.get("workingDirectory")
250
+ )
232
251
 
233
252
  # Create normalized event
234
253
  normalized = NormalizedEvent(
235
- event="claude_event",
254
+ event="mpm_event",
236
255
  source=event_source,
237
256
  type=event_type,
238
257
  subtype=subtype,
239
258
  timestamp=timestamp,
240
259
  data=data,
241
260
  correlation_id=correlation_id,
261
+ session_id=session_id,
262
+ cwd=cwd,
242
263
  )
243
264
 
244
265
  self.stats["normalized"] += 1
@@ -252,7 +273,7 @@ class EventNormalizer:
252
273
 
253
274
  # Return a generic event on error
254
275
  return NormalizedEvent(
255
- event="claude_event",
276
+ event="mpm_event",
256
277
  source="system",
257
278
  type="unknown",
258
279
  subtype="error",
@@ -285,8 +306,16 @@ class EventNormalizer:
285
306
  # If source is not a valid EventSource value, keep it as-is
286
307
  pass
287
308
 
309
+ # Extract session_id and cwd, trying multiple naming conventions
310
+ session_id = event_data.get("session_id") or event_data.get("sessionId")
311
+ cwd = (
312
+ event_data.get("cwd")
313
+ or event_data.get("working_directory")
314
+ or event_data.get("workingDirectory")
315
+ )
316
+
288
317
  return NormalizedEvent(
289
- event="claude_event", # Always use standard event name
318
+ event="mpm_event", # Always use standard event name
290
319
  source=source,
291
320
  type=event_data.get("type", "unknown"),
292
321
  subtype=event_data.get("subtype", "generic"),
@@ -295,6 +324,8 @@ class EventNormalizer:
295
324
  ),
296
325
  data=event_data.get("data", {}),
297
326
  correlation_id=event_data.get("correlation_id"),
327
+ session_id=session_id,
328
+ cwd=cwd,
298
329
  )
299
330
 
300
331
  def _extract_event_info(self, event_data: Any) -> Tuple[str, str, Dict[str, Any]]: