claude-mpm 4.1.4__py3-none-any.whl → 4.1.5__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 (41) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/cli/commands/tickets.py +365 -784
  3. claude_mpm/core/output_style_manager.py +24 -0
  4. claude_mpm/core/unified_agent_registry.py +46 -15
  5. claude_mpm/services/agents/deployment/agent_discovery_service.py +12 -3
  6. claude_mpm/services/agents/deployment/agent_lifecycle_manager.py +172 -233
  7. claude_mpm/services/agents/deployment/agent_lifecycle_manager_refactored.py +575 -0
  8. claude_mpm/services/agents/deployment/agent_operation_service.py +573 -0
  9. claude_mpm/services/agents/deployment/agent_record_service.py +419 -0
  10. claude_mpm/services/agents/deployment/agent_state_service.py +381 -0
  11. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +4 -2
  12. claude_mpm/services/infrastructure/__init__.py +31 -5
  13. claude_mpm/services/infrastructure/monitoring/__init__.py +43 -0
  14. claude_mpm/services/infrastructure/monitoring/aggregator.py +437 -0
  15. claude_mpm/services/infrastructure/monitoring/base.py +130 -0
  16. claude_mpm/services/infrastructure/monitoring/legacy.py +203 -0
  17. claude_mpm/services/infrastructure/monitoring/network.py +218 -0
  18. claude_mpm/services/infrastructure/monitoring/process.py +342 -0
  19. claude_mpm/services/infrastructure/monitoring/resources.py +243 -0
  20. claude_mpm/services/infrastructure/monitoring/service.py +367 -0
  21. claude_mpm/services/infrastructure/monitoring.py +67 -1030
  22. claude_mpm/services/project/analyzer.py +13 -4
  23. claude_mpm/services/project/analyzer_refactored.py +450 -0
  24. claude_mpm/services/project/analyzer_v2.py +566 -0
  25. claude_mpm/services/project/architecture_analyzer.py +461 -0
  26. claude_mpm/services/project/dependency_analyzer.py +462 -0
  27. claude_mpm/services/project/language_analyzer.py +265 -0
  28. claude_mpm/services/project/metrics_collector.py +410 -0
  29. claude_mpm/services/ticket_manager.py +5 -1
  30. claude_mpm/services/ticket_services/__init__.py +26 -0
  31. claude_mpm/services/ticket_services/crud_service.py +328 -0
  32. claude_mpm/services/ticket_services/formatter_service.py +290 -0
  33. claude_mpm/services/ticket_services/search_service.py +324 -0
  34. claude_mpm/services/ticket_services/validation_service.py +303 -0
  35. claude_mpm/services/ticket_services/workflow_service.py +244 -0
  36. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/METADATA +1 -1
  37. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/RECORD +41 -17
  38. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/WHEEL +0 -0
  39. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/entry_points.txt +0 -0
  40. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/licenses/LICENSE +0 -0
  41. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,324 @@
1
+ """
2
+ Search service for tickets.
3
+
4
+ WHY: Extracts search logic into a dedicated service for better
5
+ organization and testability of search functionality.
6
+
7
+ DESIGN DECISIONS:
8
+ - Supports text-based search across multiple fields
9
+ - Handles filtering by type and status
10
+ - Provides relevance ranking for search results
11
+ - Abstracts search backend (can switch between different implementations)
12
+ """
13
+
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from ...core.logger import get_logger
17
+
18
+
19
+ class TicketSearchService:
20
+ """Service for searching tickets."""
21
+
22
+ def __init__(self, ticket_manager=None):
23
+ """
24
+ Initialize the search service.
25
+
26
+ Args:
27
+ ticket_manager: Optional ticket manager instance for testing
28
+ """
29
+ self.logger = get_logger("services.ticket_search")
30
+ self._ticket_manager = ticket_manager
31
+
32
+ @property
33
+ def ticket_manager(self):
34
+ """Lazy load ticket manager."""
35
+ if self._ticket_manager is None:
36
+ try:
37
+ from ..ticket_manager import TicketManager
38
+ except ImportError:
39
+ from claude_mpm.services.ticket_manager import TicketManager
40
+ self._ticket_manager = TicketManager()
41
+ return self._ticket_manager
42
+
43
+ def search_tickets(
44
+ self,
45
+ query: str,
46
+ type_filter: str = "all",
47
+ status_filter: str = "all",
48
+ limit: int = 10,
49
+ search_fields: Optional[List[str]] = None,
50
+ ) -> List[Dict[str, Any]]:
51
+ """
52
+ Search tickets by query string.
53
+
54
+ Args:
55
+ query: Search query
56
+ type_filter: Filter by ticket type
57
+ status_filter: Filter by status
58
+ limit: Maximum results to return
59
+ search_fields: Fields to search in (default: title, description, tags)
60
+
61
+ Returns:
62
+ List of matching tickets
63
+ """
64
+ if not search_fields:
65
+ search_fields = ["title", "description", "tags"]
66
+
67
+ try:
68
+ # Get all available tickets
69
+ all_tickets = self.ticket_manager.list_recent_tickets(limit=100)
70
+
71
+ # Search and filter
72
+ matched_tickets = []
73
+ query_lower = query.lower()
74
+
75
+ for ticket in all_tickets:
76
+ # Check if query matches any search field
77
+ if self._matches_query(ticket, query_lower, search_fields):
78
+ # Apply type filter
79
+ if not self._passes_type_filter(ticket, type_filter):
80
+ continue
81
+
82
+ # Apply status filter
83
+ if not self._passes_status_filter(ticket, status_filter):
84
+ continue
85
+
86
+ matched_tickets.append(ticket)
87
+
88
+ if len(matched_tickets) >= limit:
89
+ break
90
+
91
+ # Sort by relevance
92
+ return self._sort_by_relevance(matched_tickets, query_lower)
93
+
94
+ except Exception as e:
95
+ self.logger.error(f"Error searching tickets: {e}")
96
+ return []
97
+
98
+ def _matches_query(
99
+ self, ticket: Dict[str, Any], query: str, search_fields: List[str]
100
+ ) -> bool:
101
+ """
102
+ Check if ticket matches the search query.
103
+
104
+ Returns:
105
+ True if ticket matches query in any search field
106
+ """
107
+ for field in search_fields:
108
+ if field == "title" and query in ticket.get("title", "").lower():
109
+ return True
110
+
111
+ if (
112
+ field == "description"
113
+ and query in ticket.get("description", "").lower()
114
+ ):
115
+ return True
116
+
117
+ if field == "tags":
118
+ tags = ticket.get("tags", [])
119
+ if any(query in tag.lower() for tag in tags):
120
+ return True
121
+
122
+ if field == "id" and query in ticket.get("id", "").lower():
123
+ return True
124
+
125
+ # Search in metadata fields
126
+ if field == "metadata":
127
+ metadata = ticket.get("metadata", {})
128
+ for value in metadata.values():
129
+ if isinstance(value, str) and query in value.lower():
130
+ return True
131
+
132
+ return False
133
+
134
+ def _passes_type_filter(self, ticket: Dict[str, Any], type_filter: str) -> bool:
135
+ """
136
+ Check if ticket passes type filter.
137
+
138
+ Returns:
139
+ True if ticket matches type filter
140
+ """
141
+ if type_filter == "all":
142
+ return True
143
+
144
+ ticket_type = ticket.get("metadata", {}).get("ticket_type", "unknown")
145
+ return ticket_type == type_filter
146
+
147
+ def _passes_status_filter(self, ticket: Dict[str, Any], status_filter: str) -> bool:
148
+ """
149
+ Check if ticket passes status filter.
150
+
151
+ Returns:
152
+ True if ticket matches status filter
153
+ """
154
+ if status_filter == "all":
155
+ return True
156
+
157
+ return ticket.get("status") == status_filter
158
+
159
+ def _sort_by_relevance(
160
+ self, tickets: List[Dict[str, Any]], query: str
161
+ ) -> List[Dict[str, Any]]:
162
+ """
163
+ Sort tickets by relevance to search query.
164
+
165
+ Returns:
166
+ Sorted list of tickets (most relevant first)
167
+ """
168
+
169
+ def relevance_score(ticket):
170
+ score = 0
171
+
172
+ # Title matches are most relevant
173
+ if query in ticket.get("title", "").lower():
174
+ score += 10
175
+
176
+ # ID matches are very relevant
177
+ if query in ticket.get("id", "").lower():
178
+ score += 8
179
+
180
+ # Tag matches are moderately relevant
181
+ tags = ticket.get("tags", [])
182
+ for tag in tags:
183
+ if query in tag.lower():
184
+ score += 5
185
+
186
+ # Description matches are less relevant
187
+ if query in ticket.get("description", "").lower():
188
+ score += 2
189
+
190
+ # Boost recent tickets slightly
191
+ if ticket.get("status") in ["open", "in_progress"]:
192
+ score += 1
193
+
194
+ return score
195
+
196
+ return sorted(tickets, key=relevance_score, reverse=True)
197
+
198
+ def find_similar_tickets(
199
+ self, ticket_id: str, limit: int = 5
200
+ ) -> List[Dict[str, Any]]:
201
+ """
202
+ Find tickets similar to a given ticket.
203
+
204
+ Args:
205
+ ticket_id: ID of the reference ticket
206
+ limit: Maximum similar tickets to return
207
+
208
+ Returns:
209
+ List of similar tickets
210
+ """
211
+ try:
212
+ # Get the reference ticket
213
+ reference = self.ticket_manager.get_ticket(ticket_id)
214
+ if not reference:
215
+ return []
216
+
217
+ # Extract keywords from title and tags
218
+ keywords = self._extract_keywords(reference)
219
+
220
+ # Search for similar tickets
221
+ similar = []
222
+ all_tickets = self.ticket_manager.list_recent_tickets(limit=50)
223
+
224
+ for ticket in all_tickets:
225
+ if ticket["id"] == ticket_id:
226
+ continue # Skip the reference ticket
227
+
228
+ # Calculate similarity score
229
+ score = self._calculate_similarity(reference, ticket, keywords)
230
+
231
+ if score > 0:
232
+ similar.append((score, ticket))
233
+
234
+ # Sort by similarity and return top results
235
+ similar.sort(key=lambda x: x[0], reverse=True)
236
+ return [ticket for _, ticket in similar[:limit]]
237
+
238
+ except Exception as e:
239
+ self.logger.error(f"Error finding similar tickets: {e}")
240
+ return []
241
+
242
+ def _extract_keywords(self, ticket: Dict[str, Any]) -> List[str]:
243
+ """
244
+ Extract keywords from a ticket for similarity matching.
245
+
246
+ Returns:
247
+ List of keywords
248
+ """
249
+ keywords = []
250
+
251
+ # Extract words from title
252
+ title = ticket.get("title", "")
253
+ keywords.extend(title.lower().split())
254
+
255
+ # Add tags as keywords
256
+ keywords.extend([tag.lower() for tag in ticket.get("tags", [])])
257
+
258
+ # Extract key words from description (first 100 chars)
259
+ desc = ticket.get("description", "")[:100]
260
+ keywords.extend(desc.lower().split()[:10])
261
+
262
+ # Remove common words
263
+ common_words = {
264
+ "the",
265
+ "a",
266
+ "an",
267
+ "and",
268
+ "or",
269
+ "but",
270
+ "in",
271
+ "on",
272
+ "at",
273
+ "to",
274
+ "for",
275
+ }
276
+ keywords = [w for w in keywords if w not in common_words and len(w) > 2]
277
+
278
+ return list(set(keywords)) # Remove duplicates
279
+
280
+ def _calculate_similarity(
281
+ self, ref_ticket: Dict[str, Any], ticket: Dict[str, Any], keywords: List[str]
282
+ ) -> float:
283
+ """
284
+ Calculate similarity score between two tickets.
285
+
286
+ Returns:
287
+ Similarity score (0-100)
288
+ """
289
+ score = 0.0
290
+
291
+ # Same type bonus
292
+ ref_type = ref_ticket.get("metadata", {}).get("ticket_type")
293
+ ticket_type = ticket.get("metadata", {}).get("ticket_type")
294
+ if ref_type and ticket_type and ref_type == ticket_type:
295
+ score += 10
296
+
297
+ # Same status bonus
298
+ if ref_ticket.get("status") == ticket.get("status"):
299
+ score += 5
300
+
301
+ # Same priority bonus
302
+ if ref_ticket.get("priority") == ticket.get("priority"):
303
+ score += 3
304
+
305
+ # Keyword matches
306
+ ticket_text = (
307
+ ticket.get("title", "").lower()
308
+ + " "
309
+ + ticket.get("description", "").lower()
310
+ + " "
311
+ + " ".join(ticket.get("tags", []))
312
+ ).lower()
313
+
314
+ for keyword in keywords:
315
+ if keyword in ticket_text:
316
+ score += 2
317
+
318
+ # Common tags
319
+ ref_tags = set(ref_ticket.get("tags", []))
320
+ ticket_tags = set(ticket.get("tags", []))
321
+ common_tags = ref_tags.intersection(ticket_tags)
322
+ score += len(common_tags) * 5
323
+
324
+ return min(score, 100) # Cap at 100
@@ -0,0 +1,303 @@
1
+ """
2
+ Input validation service for tickets.
3
+
4
+ WHY: Centralizes all validation logic to ensure data integrity and
5
+ provide consistent error messages across the ticket system.
6
+
7
+ DESIGN DECISIONS:
8
+ - Returns validation results with detailed error messages
9
+ - Validates ticket IDs, types, statuses, priorities
10
+ - Handles pagination parameter validation
11
+ - Provides sanitization for user inputs
12
+ """
13
+
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+
17
+ class TicketValidationService:
18
+ """Service for validating ticket inputs."""
19
+
20
+ # Valid ticket types
21
+ VALID_TYPES = ["task", "issue", "epic", "bug", "feature", "story"]
22
+
23
+ # Valid ticket statuses
24
+ VALID_STATUSES = [
25
+ "open",
26
+ "in_progress",
27
+ "ready",
28
+ "tested",
29
+ "done",
30
+ "waiting",
31
+ "closed",
32
+ "blocked",
33
+ "all",
34
+ ]
35
+
36
+ # Valid priorities
37
+ VALID_PRIORITIES = ["low", "medium", "high", "critical"]
38
+
39
+ # Valid workflow states
40
+ VALID_WORKFLOW_STATES = [
41
+ "todo",
42
+ "in_progress",
43
+ "ready",
44
+ "tested",
45
+ "done",
46
+ "blocked",
47
+ ]
48
+
49
+ def validate_ticket_id(self, ticket_id: Any) -> Tuple[bool, Optional[str]]:
50
+ """
51
+ Validate a ticket ID.
52
+
53
+ Returns:
54
+ Tuple of (is_valid, error_message)
55
+ """
56
+ if not ticket_id:
57
+ return False, "No ticket ID provided"
58
+
59
+ if not isinstance(ticket_id, str):
60
+ return False, "Ticket ID must be a string"
61
+
62
+ if len(ticket_id) < 3:
63
+ return False, "Invalid ticket ID format"
64
+
65
+ return True, None
66
+
67
+ def validate_ticket_type(self, ticket_type: str) -> Tuple[bool, Optional[str]]:
68
+ """
69
+ Validate a ticket type.
70
+
71
+ Returns:
72
+ Tuple of (is_valid, error_message)
73
+ """
74
+ if not ticket_type:
75
+ return True, None # Type is optional, default will be used
76
+
77
+ if ticket_type not in self.VALID_TYPES:
78
+ return (
79
+ False,
80
+ f"Invalid ticket type: {ticket_type}. Valid types: {', '.join(self.VALID_TYPES)}",
81
+ )
82
+
83
+ return True, None
84
+
85
+ def validate_status(self, status: str) -> Tuple[bool, Optional[str]]:
86
+ """
87
+ Validate a ticket status.
88
+
89
+ Returns:
90
+ Tuple of (is_valid, error_message)
91
+ """
92
+ if not status:
93
+ return True, None # Status is optional
94
+
95
+ if status not in self.VALID_STATUSES:
96
+ return (
97
+ False,
98
+ f"Invalid status: {status}. Valid statuses: {', '.join(self.VALID_STATUSES)}",
99
+ )
100
+
101
+ return True, None
102
+
103
+ def validate_priority(self, priority: str) -> Tuple[bool, Optional[str]]:
104
+ """
105
+ Validate a ticket priority.
106
+
107
+ Returns:
108
+ Tuple of (is_valid, error_message)
109
+ """
110
+ if not priority:
111
+ return True, None # Priority is optional, default will be used
112
+
113
+ if priority not in self.VALID_PRIORITIES:
114
+ return (
115
+ False,
116
+ f"Invalid priority: {priority}. Valid priorities: {', '.join(self.VALID_PRIORITIES)}",
117
+ )
118
+
119
+ return True, None
120
+
121
+ def validate_workflow_state(self, state: str) -> Tuple[bool, Optional[str]]:
122
+ """
123
+ Validate a workflow state.
124
+
125
+ Returns:
126
+ Tuple of (is_valid, error_message)
127
+ """
128
+ if not state:
129
+ return False, "No workflow state provided"
130
+
131
+ if state not in self.VALID_WORKFLOW_STATES:
132
+ return (
133
+ False,
134
+ f"Invalid workflow state: {state}. Valid states: {', '.join(self.VALID_WORKFLOW_STATES)}",
135
+ )
136
+
137
+ return True, None
138
+
139
+ def validate_pagination(
140
+ self, page: int, page_size: int
141
+ ) -> Tuple[bool, Optional[str]]:
142
+ """
143
+ Validate pagination parameters.
144
+
145
+ Returns:
146
+ Tuple of (is_valid, error_message)
147
+ """
148
+ if page < 1:
149
+ return False, "Page number must be 1 or greater"
150
+
151
+ if page_size < 1:
152
+ return False, "Page size must be 1 or greater"
153
+
154
+ if page_size > 100:
155
+ return False, "Page size cannot exceed 100"
156
+
157
+ return True, None
158
+
159
+ def validate_create_params(
160
+ self, params: Dict[str, Any]
161
+ ) -> Tuple[bool, Optional[str]]:
162
+ """
163
+ Validate parameters for ticket creation.
164
+
165
+ Returns:
166
+ Tuple of (is_valid, error_message)
167
+ """
168
+ # Title is required
169
+ if not params.get("title"):
170
+ return False, "Title is required for ticket creation"
171
+
172
+ if len(params["title"]) < 3:
173
+ return False, "Title must be at least 3 characters long"
174
+
175
+ if len(params["title"]) > 200:
176
+ return False, "Title cannot exceed 200 characters"
177
+
178
+ # Validate optional fields if provided
179
+ if "type" in params:
180
+ valid, error = self.validate_ticket_type(params["type"])
181
+ if not valid:
182
+ return False, error
183
+
184
+ if "priority" in params:
185
+ valid, error = self.validate_priority(params["priority"])
186
+ if not valid:
187
+ return False, error
188
+
189
+ # Validate parent references
190
+ if params.get("parent_epic"):
191
+ valid, error = self.validate_ticket_id(params["parent_epic"])
192
+ if not valid:
193
+ return False, f"Invalid parent epic: {error}"
194
+
195
+ if params.get("parent_issue"):
196
+ valid, error = self.validate_ticket_id(params["parent_issue"])
197
+ if not valid:
198
+ return False, f"Invalid parent issue: {error}"
199
+
200
+ return True, None
201
+
202
+ def validate_update_params(
203
+ self, params: Dict[str, Any]
204
+ ) -> Tuple[bool, Optional[str]]:
205
+ """
206
+ Validate parameters for ticket update.
207
+
208
+ Returns:
209
+ Tuple of (is_valid, error_message)
210
+ """
211
+ # At least one update field must be provided
212
+ update_fields = ["status", "priority", "description", "tags", "assign"]
213
+ if not any(field in params for field in update_fields):
214
+ return False, "No update fields specified"
215
+
216
+ # Validate status if provided
217
+ if "status" in params:
218
+ valid, error = self.validate_status(params["status"])
219
+ if not valid:
220
+ return False, error
221
+
222
+ # Validate priority if provided
223
+ if "priority" in params:
224
+ valid, error = self.validate_priority(params["priority"])
225
+ if not valid:
226
+ return False, error
227
+
228
+ return True, None
229
+
230
+ def sanitize_tags(self, tags: Any) -> List[str]:
231
+ """
232
+ Sanitize and parse tags input.
233
+
234
+ Returns:
235
+ List of sanitized tags
236
+ """
237
+ if not tags:
238
+ return []
239
+
240
+ if isinstance(tags, str):
241
+ # Split comma-separated tags
242
+ tag_list = [tag.strip() for tag in tags.split(",")]
243
+ elif isinstance(tags, list):
244
+ tag_list = [str(tag).strip() for tag in tags]
245
+ else:
246
+ return []
247
+
248
+ # Remove empty tags and duplicates
249
+ return list(filter(None, dict.fromkeys(tag_list)))
250
+
251
+ def sanitize_description(self, description: Any) -> str:
252
+ """
253
+ Sanitize description input.
254
+
255
+ Returns:
256
+ Sanitized description string
257
+ """
258
+ if not description:
259
+ return ""
260
+
261
+ if isinstance(description, list):
262
+ # Join list elements with spaces
263
+ return " ".join(str(item) for item in description)
264
+
265
+ return str(description).strip()
266
+
267
+ def validate_search_query(self, query: str) -> Tuple[bool, Optional[str]]:
268
+ """
269
+ Validate a search query.
270
+
271
+ Returns:
272
+ Tuple of (is_valid, error_message)
273
+ """
274
+ if not query:
275
+ return False, "Search query cannot be empty"
276
+
277
+ if len(query) < 2:
278
+ return False, "Search query must be at least 2 characters"
279
+
280
+ if len(query) > 100:
281
+ return False, "Search query cannot exceed 100 characters"
282
+
283
+ return True, None
284
+
285
+ def validate_comment(self, comment: Any) -> Tuple[bool, Optional[str]]:
286
+ """
287
+ Validate a comment.
288
+
289
+ Returns:
290
+ Tuple of (is_valid, error_message)
291
+ """
292
+ if not comment:
293
+ return False, "Comment cannot be empty"
294
+
295
+ comment_str = self.sanitize_description(comment)
296
+
297
+ if len(comment_str) < 1:
298
+ return False, "Comment cannot be empty"
299
+
300
+ if len(comment_str) > 5000:
301
+ return False, "Comment cannot exceed 5000 characters"
302
+
303
+ return True, None