claude-mpm 5.4.96__py3-none-any.whl → 5.6.17__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 (214) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/{CLAUDE_MPM_FOUNDERS_OUTPUT_STYLE.md → CLAUDE_MPM_RESEARCH_OUTPUT_STYLE.md} +14 -6
  3. claude_mpm/agents/PM_INSTRUCTIONS.md +44 -10
  4. claude_mpm/agents/WORKFLOW.md +2 -0
  5. claude_mpm/agents/templates/circuit-breakers.md +26 -17
  6. claude_mpm/cli/commands/autotodos.py +45 -5
  7. claude_mpm/cli/commands/commander.py +216 -0
  8. claude_mpm/cli/commands/hook_errors.py +60 -60
  9. claude_mpm/cli/commands/run.py +35 -3
  10. claude_mpm/cli/commands/skill_source.py +51 -2
  11. claude_mpm/cli/commands/skills.py +5 -3
  12. claude_mpm/cli/executor.py +32 -17
  13. claude_mpm/cli/parsers/base_parser.py +17 -0
  14. claude_mpm/cli/parsers/commander_parser.py +116 -0
  15. claude_mpm/cli/parsers/run_parser.py +10 -0
  16. claude_mpm/cli/parsers/skill_source_parser.py +4 -0
  17. claude_mpm/cli/parsers/skills_parser.py +5 -0
  18. claude_mpm/cli/startup.py +124 -3
  19. claude_mpm/cli/startup_display.py +2 -1
  20. claude_mpm/cli/utils.py +7 -3
  21. claude_mpm/commander/__init__.py +78 -0
  22. claude_mpm/commander/adapters/__init__.py +60 -0
  23. claude_mpm/commander/adapters/auggie.py +260 -0
  24. claude_mpm/commander/adapters/base.py +288 -0
  25. claude_mpm/commander/adapters/claude_code.py +392 -0
  26. claude_mpm/commander/adapters/codex.py +237 -0
  27. claude_mpm/commander/adapters/communication.py +366 -0
  28. claude_mpm/commander/adapters/example_usage.py +310 -0
  29. claude_mpm/commander/adapters/mpm.py +389 -0
  30. claude_mpm/commander/adapters/registry.py +204 -0
  31. claude_mpm/commander/api/__init__.py +16 -0
  32. claude_mpm/commander/api/app.py +121 -0
  33. claude_mpm/commander/api/errors.py +133 -0
  34. claude_mpm/commander/api/routes/__init__.py +8 -0
  35. claude_mpm/commander/api/routes/events.py +184 -0
  36. claude_mpm/commander/api/routes/inbox.py +171 -0
  37. claude_mpm/commander/api/routes/messages.py +148 -0
  38. claude_mpm/commander/api/routes/projects.py +271 -0
  39. claude_mpm/commander/api/routes/sessions.py +226 -0
  40. claude_mpm/commander/api/routes/work.py +296 -0
  41. claude_mpm/commander/api/schemas.py +186 -0
  42. claude_mpm/commander/chat/__init__.py +7 -0
  43. claude_mpm/commander/chat/cli.py +111 -0
  44. claude_mpm/commander/chat/commands.py +96 -0
  45. claude_mpm/commander/chat/repl.py +310 -0
  46. claude_mpm/commander/config.py +49 -0
  47. claude_mpm/commander/config_loader.py +115 -0
  48. claude_mpm/commander/core/__init__.py +10 -0
  49. claude_mpm/commander/core/block_manager.py +325 -0
  50. claude_mpm/commander/core/response_manager.py +323 -0
  51. claude_mpm/commander/daemon.py +594 -0
  52. claude_mpm/commander/env_loader.py +59 -0
  53. claude_mpm/commander/events/__init__.py +26 -0
  54. claude_mpm/commander/events/manager.py +332 -0
  55. claude_mpm/commander/frameworks/__init__.py +12 -0
  56. claude_mpm/commander/frameworks/base.py +143 -0
  57. claude_mpm/commander/frameworks/claude_code.py +58 -0
  58. claude_mpm/commander/frameworks/mpm.py +62 -0
  59. claude_mpm/commander/inbox/__init__.py +16 -0
  60. claude_mpm/commander/inbox/dedup.py +128 -0
  61. claude_mpm/commander/inbox/inbox.py +224 -0
  62. claude_mpm/commander/inbox/models.py +70 -0
  63. claude_mpm/commander/instance_manager.py +337 -0
  64. claude_mpm/commander/llm/__init__.py +6 -0
  65. claude_mpm/commander/llm/openrouter_client.py +167 -0
  66. claude_mpm/commander/llm/summarizer.py +70 -0
  67. claude_mpm/commander/memory/__init__.py +45 -0
  68. claude_mpm/commander/memory/compression.py +347 -0
  69. claude_mpm/commander/memory/embeddings.py +230 -0
  70. claude_mpm/commander/memory/entities.py +310 -0
  71. claude_mpm/commander/memory/example_usage.py +290 -0
  72. claude_mpm/commander/memory/integration.py +325 -0
  73. claude_mpm/commander/memory/search.py +381 -0
  74. claude_mpm/commander/memory/store.py +657 -0
  75. claude_mpm/commander/models/__init__.py +18 -0
  76. claude_mpm/commander/models/events.py +121 -0
  77. claude_mpm/commander/models/project.py +162 -0
  78. claude_mpm/commander/models/work.py +214 -0
  79. claude_mpm/commander/parsing/__init__.py +20 -0
  80. claude_mpm/commander/parsing/extractor.py +132 -0
  81. claude_mpm/commander/parsing/output_parser.py +270 -0
  82. claude_mpm/commander/parsing/patterns.py +100 -0
  83. claude_mpm/commander/persistence/__init__.py +11 -0
  84. claude_mpm/commander/persistence/event_store.py +274 -0
  85. claude_mpm/commander/persistence/state_store.py +309 -0
  86. claude_mpm/commander/persistence/work_store.py +164 -0
  87. claude_mpm/commander/polling/__init__.py +13 -0
  88. claude_mpm/commander/polling/event_detector.py +104 -0
  89. claude_mpm/commander/polling/output_buffer.py +49 -0
  90. claude_mpm/commander/polling/output_poller.py +153 -0
  91. claude_mpm/commander/project_session.py +268 -0
  92. claude_mpm/commander/proxy/__init__.py +12 -0
  93. claude_mpm/commander/proxy/formatter.py +89 -0
  94. claude_mpm/commander/proxy/output_handler.py +191 -0
  95. claude_mpm/commander/proxy/relay.py +155 -0
  96. claude_mpm/commander/registry.py +410 -0
  97. claude_mpm/commander/runtime/__init__.py +10 -0
  98. claude_mpm/commander/runtime/executor.py +191 -0
  99. claude_mpm/commander/runtime/monitor.py +346 -0
  100. claude_mpm/commander/session/__init__.py +6 -0
  101. claude_mpm/commander/session/context.py +81 -0
  102. claude_mpm/commander/session/manager.py +59 -0
  103. claude_mpm/commander/tmux_orchestrator.py +361 -0
  104. claude_mpm/commander/web/__init__.py +1 -0
  105. claude_mpm/commander/work/__init__.py +30 -0
  106. claude_mpm/commander/work/executor.py +207 -0
  107. claude_mpm/commander/work/queue.py +405 -0
  108. claude_mpm/commander/workflow/__init__.py +27 -0
  109. claude_mpm/commander/workflow/event_handler.py +241 -0
  110. claude_mpm/commander/workflow/notifier.py +146 -0
  111. claude_mpm/commands/mpm-config.md +8 -0
  112. claude_mpm/commands/mpm-doctor.md +8 -0
  113. claude_mpm/commands/mpm-help.md +8 -0
  114. claude_mpm/commands/mpm-init.md +8 -0
  115. claude_mpm/commands/mpm-monitor.md +8 -0
  116. claude_mpm/commands/mpm-organize.md +8 -0
  117. claude_mpm/commands/mpm-postmortem.md +8 -0
  118. claude_mpm/commands/mpm-session-resume.md +8 -0
  119. claude_mpm/commands/mpm-status.md +8 -0
  120. claude_mpm/commands/mpm-ticket-view.md +8 -0
  121. claude_mpm/commands/mpm-version.md +8 -0
  122. claude_mpm/commands/mpm.md +8 -0
  123. claude_mpm/config/agent_presets.py +8 -7
  124. claude_mpm/config/skill_sources.py +16 -0
  125. claude_mpm/core/claude_runner.py +143 -0
  126. claude_mpm/core/config.py +32 -19
  127. claude_mpm/core/logger.py +26 -9
  128. claude_mpm/core/logging_utils.py +35 -11
  129. claude_mpm/core/output_style_manager.py +49 -12
  130. claude_mpm/core/unified_config.py +10 -6
  131. claude_mpm/core/unified_paths.py +68 -80
  132. claude_mpm/experimental/cli_enhancements.py +2 -1
  133. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-312.pyc +0 -0
  134. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-314.pyc +0 -0
  135. claude_mpm/hooks/claude_hooks/__pycache__/auto_pause_handler.cpython-311.pyc +0 -0
  136. claude_mpm/hooks/claude_hooks/__pycache__/auto_pause_handler.cpython-312.pyc +0 -0
  137. claude_mpm/hooks/claude_hooks/__pycache__/auto_pause_handler.cpython-314.pyc +0 -0
  138. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  139. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-312.pyc +0 -0
  140. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-314.pyc +0 -0
  141. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  142. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-312.pyc +0 -0
  143. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-314.pyc +0 -0
  144. claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
  145. claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-314.pyc +0 -0
  146. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  147. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-312.pyc +0 -0
  148. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-314.pyc +0 -0
  149. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  150. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-312.pyc +0 -0
  151. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-314.pyc +0 -0
  152. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-312.pyc +0 -0
  153. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-314.pyc +0 -0
  154. claude_mpm/hooks/claude_hooks/auto_pause_handler.py +29 -30
  155. claude_mpm/hooks/claude_hooks/event_handlers.py +112 -99
  156. claude_mpm/hooks/claude_hooks/hook_handler.py +81 -88
  157. claude_mpm/hooks/claude_hooks/hook_wrapper.sh +6 -11
  158. claude_mpm/hooks/claude_hooks/installer.py +116 -8
  159. claude_mpm/hooks/claude_hooks/memory_integration.py +51 -31
  160. claude_mpm/hooks/claude_hooks/response_tracking.py +39 -58
  161. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-312.pyc +0 -0
  162. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-314.pyc +0 -0
  163. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
  164. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  165. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-312.pyc +0 -0
  166. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-314.pyc +0 -0
  167. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-312.pyc +0 -0
  168. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-314.pyc +0 -0
  169. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  170. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-312.pyc +0 -0
  171. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-314.pyc +0 -0
  172. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  173. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-312.pyc +0 -0
  174. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-314.pyc +0 -0
  175. claude_mpm/hooks/claude_hooks/services/connection_manager.py +23 -28
  176. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +36 -103
  177. claude_mpm/hooks/claude_hooks/services/state_manager.py +23 -36
  178. claude_mpm/hooks/claude_hooks/services/subagent_processor.py +47 -73
  179. claude_mpm/hooks/session_resume_hook.py +22 -18
  180. claude_mpm/hooks/templates/pre_tool_use_template.py +10 -2
  181. claude_mpm/scripts/claude-hook-handler.sh +43 -16
  182. claude_mpm/scripts/start_activity_logging.py +0 -0
  183. claude_mpm/services/agents/agent_recommendation_service.py +8 -8
  184. claude_mpm/services/agents/agent_selection_service.py +2 -2
  185. claude_mpm/services/agents/loading/framework_agent_loader.py +75 -2
  186. claude_mpm/services/agents/single_tier_deployment_service.py +4 -4
  187. claude_mpm/services/event_log.py +8 -0
  188. claude_mpm/services/pm_skills_deployer.py +84 -6
  189. claude_mpm/services/skills/git_skill_source_manager.py +130 -10
  190. claude_mpm/services/skills/selective_skill_deployer.py +28 -0
  191. claude_mpm/services/skills/skill_discovery_service.py +74 -4
  192. claude_mpm/services/skills_deployer.py +31 -5
  193. claude_mpm/skills/__init__.py +2 -1
  194. claude_mpm/skills/bundled/pm/mpm/SKILL.md +38 -0
  195. claude_mpm/skills/bundled/pm/mpm-config/SKILL.md +29 -0
  196. claude_mpm/skills/bundled/pm/mpm-doctor/SKILL.md +53 -0
  197. claude_mpm/skills/bundled/pm/mpm-help/SKILL.md +35 -0
  198. claude_mpm/skills/bundled/pm/mpm-init/SKILL.md +125 -0
  199. claude_mpm/skills/bundled/pm/mpm-monitor/SKILL.md +32 -0
  200. claude_mpm/skills/bundled/pm/mpm-organize/SKILL.md +121 -0
  201. claude_mpm/skills/bundled/pm/mpm-postmortem/SKILL.md +22 -0
  202. claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
  203. claude_mpm/skills/bundled/pm/mpm-session-resume/SKILL.md +31 -0
  204. claude_mpm/skills/bundled/pm/mpm-status/SKILL.md +37 -0
  205. claude_mpm/skills/bundled/pm/mpm-ticket-view/SKILL.md +110 -0
  206. claude_mpm/skills/bundled/pm/mpm-version/SKILL.md +21 -0
  207. claude_mpm/skills/registry.py +295 -90
  208. {claude_mpm-5.4.96.dist-info → claude_mpm-5.6.17.dist-info}/METADATA +22 -6
  209. {claude_mpm-5.4.96.dist-info → claude_mpm-5.6.17.dist-info}/RECORD +213 -83
  210. {claude_mpm-5.4.96.dist-info → claude_mpm-5.6.17.dist-info}/WHEEL +0 -0
  211. {claude_mpm-5.4.96.dist-info → claude_mpm-5.6.17.dist-info}/entry_points.txt +0 -0
  212. {claude_mpm-5.4.96.dist-info → claude_mpm-5.6.17.dist-info}/licenses/LICENSE +0 -0
  213. {claude_mpm-5.4.96.dist-info → claude_mpm-5.6.17.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  214. {claude_mpm-5.4.96.dist-info → claude_mpm-5.6.17.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,325 @@
1
+ """BlockManager for coordinating work blocking with events.
2
+
3
+ This module provides BlockManager which automatically blocks/unblocks
4
+ work items based on blocking event detection and resolution.
5
+ """
6
+
7
+ import logging
8
+ from typing import Dict, List, Optional, Set
9
+
10
+ from ..events.manager import EventManager
11
+ from ..models.events import Event
12
+ from ..models.work import WorkState
13
+ from ..work.executor import WorkExecutor
14
+ from ..work.queue import WorkQueue
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class BlockManager:
20
+ """Coordinates blocking events with work execution.
21
+
22
+ Monitors blocking events and automatically blocks/unblocks work items
23
+ based on event lifecycle. Tracks event-to-work relationships for precise
24
+ unblocking when events are resolved.
25
+
26
+ Attributes:
27
+ event_manager: EventManager for querying blocking events
28
+ work_queues: Dict mapping project_id -> WorkQueue
29
+ work_executors: Dict mapping project_id -> WorkExecutor
30
+
31
+ Example:
32
+ >>> manager = BlockManager(event_manager, work_queues, work_executors)
33
+ >>> blocked = await manager.check_and_block(event)
34
+ >>> unblocked = await manager.check_and_unblock(event_id)
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ event_manager: EventManager,
40
+ work_queues: Dict[str, WorkQueue],
41
+ work_executors: Dict[str, WorkExecutor],
42
+ ):
43
+ """Initialize BlockManager.
44
+
45
+ Args:
46
+ event_manager: EventManager instance
47
+ work_queues: Dict mapping project_id -> WorkQueue
48
+ work_executors: Dict mapping project_id -> WorkExecutor
49
+
50
+ Raises:
51
+ ValueError: If any required parameter is None
52
+ """
53
+ if event_manager is None:
54
+ raise ValueError("EventManager cannot be None")
55
+ if work_queues is None:
56
+ raise ValueError("work_queues cannot be None")
57
+ if work_executors is None:
58
+ raise ValueError("work_executors cannot be None")
59
+
60
+ self.event_manager = event_manager
61
+ self.work_queues = work_queues
62
+ self.work_executors = work_executors
63
+
64
+ # Track event-to-work mapping: event_id -> set of work_ids
65
+ self._event_work_mapping: Dict[str, Set[str]] = {}
66
+
67
+ logger.debug("BlockManager initialized")
68
+
69
+ async def check_and_block(self, event: Event) -> List[str]:
70
+ """Check if event is blocking and block affected work.
71
+
72
+ When a blocking event is detected:
73
+ 1. Determine blocking scope (project or all)
74
+ 2. Find all in-progress work items in scope
75
+ 3. Block each work item via WorkExecutor
76
+ 4. Track event-to-work mapping for later unblocking
77
+
78
+ Args:
79
+ event: Event to check for blocking
80
+
81
+ Returns:
82
+ List of work item IDs that were blocked
83
+
84
+ Example:
85
+ >>> event = Event(type=EventType.ERROR, ...)
86
+ >>> blocked = await manager.check_and_block(event)
87
+ >>> print(f"Blocked {len(blocked)} work items")
88
+ """
89
+ if not event.is_blocking:
90
+ logger.debug("Event %s is not blocking, no action needed", event.id)
91
+ return []
92
+
93
+ logger.info(
94
+ "Processing blocking event %s (scope: %s): %s",
95
+ event.id,
96
+ event.blocking_scope,
97
+ event.title,
98
+ )
99
+
100
+ blocked_work_ids = []
101
+
102
+ # Determine which projects to block based on scope
103
+ if event.blocking_scope == "all":
104
+ # Block all projects
105
+ target_projects = list(self.work_queues.keys())
106
+ logger.info("Event %s blocks ALL projects", event.id)
107
+ elif event.blocking_scope == "project":
108
+ # Block only this project
109
+ target_projects = [event.project_id]
110
+ logger.info("Event %s blocks project %s only", event.id, event.project_id)
111
+ else:
112
+ logger.warning(
113
+ "Unknown blocking scope '%s' for event %s",
114
+ event.blocking_scope,
115
+ event.id,
116
+ )
117
+ return []
118
+
119
+ # Block in-progress work in target projects
120
+ for project_id in target_projects:
121
+ queue = self.work_queues.get(project_id)
122
+ if not queue:
123
+ logger.debug("No work queue for project %s", project_id)
124
+ continue
125
+
126
+ executor = self.work_executors.get(project_id)
127
+ if not executor:
128
+ logger.debug("No work executor for project %s", project_id)
129
+ continue
130
+
131
+ # Get in-progress work items
132
+ in_progress = queue.list(WorkState.IN_PROGRESS)
133
+
134
+ for work_item in in_progress:
135
+ # Block the work item
136
+ block_reason = f"Event {event.id}: {event.title}"
137
+ success = await executor.handle_block(work_item.id, block_reason)
138
+
139
+ if success:
140
+ blocked_work_ids.append(work_item.id)
141
+ logger.info(
142
+ "Blocked work item %s for project %s: %s",
143
+ work_item.id,
144
+ project_id,
145
+ block_reason,
146
+ )
147
+ else:
148
+ logger.warning(
149
+ "Failed to block work item %s for project %s",
150
+ work_item.id,
151
+ project_id,
152
+ )
153
+
154
+ # Track event-to-work mapping
155
+ if blocked_work_ids:
156
+ self._event_work_mapping[event.id] = set(blocked_work_ids)
157
+ logger.info(
158
+ "Event %s blocked %d work items: %s",
159
+ event.id,
160
+ len(blocked_work_ids),
161
+ blocked_work_ids,
162
+ )
163
+
164
+ return blocked_work_ids
165
+
166
+ async def check_and_unblock(self, event_id: str) -> List[str]:
167
+ """Unblock work items when event is resolved.
168
+
169
+ When a blocking event is resolved:
170
+ 1. Look up which work items were blocked by this event
171
+ 2. Unblock each work item via WorkExecutor
172
+ 3. Remove event-to-work mapping
173
+
174
+ Args:
175
+ event_id: ID of resolved event
176
+
177
+ Returns:
178
+ List of work item IDs that were unblocked
179
+
180
+ Example:
181
+ >>> unblocked = await manager.check_and_unblock("evt_123")
182
+ >>> print(f"Unblocked {len(unblocked)} work items")
183
+ """
184
+ # Get work items blocked by this event
185
+ work_ids = self._event_work_mapping.pop(event_id, set())
186
+
187
+ if not work_ids:
188
+ logger.debug("No work items blocked by event %s", event_id)
189
+ return []
190
+
191
+ logger.info(
192
+ "Unblocking %d work items for resolved event %s", len(work_ids), event_id
193
+ )
194
+
195
+ unblocked_work_ids = []
196
+
197
+ # Unblock each work item
198
+ for work_id in work_ids:
199
+ # Find which project this work belongs to
200
+ project_id = self._find_work_project(work_id)
201
+ if not project_id:
202
+ logger.warning("Cannot find project for work item %s", work_id)
203
+ continue
204
+
205
+ executor = self.work_executors.get(project_id)
206
+ if not executor:
207
+ logger.warning("No executor for project %s", project_id)
208
+ continue
209
+
210
+ # Unblock the work item
211
+ success = await executor.handle_unblock(work_id)
212
+
213
+ if success:
214
+ unblocked_work_ids.append(work_id)
215
+ logger.info("Unblocked work item %s", work_id)
216
+ else:
217
+ logger.warning("Failed to unblock work item %s", work_id)
218
+
219
+ return unblocked_work_ids
220
+
221
+ def _find_work_project(self, work_id: str) -> Optional[str]:
222
+ """Find which project a work item belongs to.
223
+
224
+ Args:
225
+ work_id: Work item ID to search for
226
+
227
+ Returns:
228
+ Project ID if found, None otherwise
229
+ """
230
+ for project_id, queue in self.work_queues.items():
231
+ work_item = queue.get(work_id)
232
+ if work_item:
233
+ return project_id
234
+ return None
235
+
236
+ def get_blocked_work(self, event_id: str) -> Set[str]:
237
+ """Get work items blocked by a specific event.
238
+
239
+ Args:
240
+ event_id: Event ID to check
241
+
242
+ Returns:
243
+ Set of work item IDs blocked by this event
244
+
245
+ Example:
246
+ >>> work_ids = manager.get_blocked_work("evt_123")
247
+ """
248
+ return self._event_work_mapping.get(event_id, set()).copy()
249
+
250
+ def get_blocking_events(self, work_id: str) -> List[str]:
251
+ """Get events that are blocking a specific work item.
252
+
253
+ Args:
254
+ work_id: Work item ID to check
255
+
256
+ Returns:
257
+ List of event IDs blocking this work item
258
+
259
+ Example:
260
+ >>> events = manager.get_blocking_events("work-123")
261
+ """
262
+ blocking_events = []
263
+ for event_id, work_ids in self._event_work_mapping.items():
264
+ if work_id in work_ids:
265
+ blocking_events.append(event_id)
266
+ return blocking_events
267
+
268
+ def is_work_blocked(self, work_id: str) -> bool:
269
+ """Check if a work item is currently blocked.
270
+
271
+ Args:
272
+ work_id: Work item ID to check
273
+
274
+ Returns:
275
+ True if work item is blocked by any event, False otherwise
276
+
277
+ Example:
278
+ >>> if manager.is_work_blocked("work-123"):
279
+ ... print("Work is blocked")
280
+ """
281
+ return len(self.get_blocking_events(work_id)) > 0
282
+
283
+ def clear_project_mappings(self, project_id: str) -> int:
284
+ """Clear all event-work mappings for a project.
285
+
286
+ Called when a project is shut down or reset.
287
+
288
+ Args:
289
+ project_id: Project ID to clear
290
+
291
+ Returns:
292
+ Number of work items that had mappings removed
293
+
294
+ Example:
295
+ >>> count = manager.clear_project_mappings("proj_123")
296
+ """
297
+ queue = self.work_queues.get(project_id)
298
+ if not queue:
299
+ return 0
300
+
301
+ # Get all work IDs for this project
302
+ all_work = queue.list()
303
+ project_work_ids = {w.id for w in all_work}
304
+
305
+ removed_count = 0
306
+
307
+ # Remove work items from event mappings
308
+ for event_id in list(self._event_work_mapping.keys()):
309
+ work_ids = self._event_work_mapping[event_id]
310
+ original_len = len(work_ids)
311
+
312
+ # Remove project work items
313
+ work_ids.difference_update(project_work_ids)
314
+
315
+ removed_count += original_len - len(work_ids)
316
+
317
+ # Remove empty mappings
318
+ if not work_ids:
319
+ del self._event_work_mapping[event_id]
320
+
321
+ logger.info(
322
+ "Cleared %d work item mappings for project %s", removed_count, project_id
323
+ )
324
+
325
+ return removed_count
@@ -0,0 +1,323 @@
1
+ """ResponseManager for centralized response routing and validation.
2
+
3
+ This module provides ResponseManager which handles response validation,
4
+ routing, and delivery to runtime sessions.
5
+ """
6
+
7
+ import logging
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from ..events.manager import EventManager
13
+ from ..models.events import Event, EventType
14
+ from ..runtime.executor import RuntimeExecutor
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _utc_now() -> datetime:
20
+ """Return current UTC time with timezone info."""
21
+ return datetime.now(timezone.utc)
22
+
23
+
24
+ @dataclass
25
+ class ResponseRoute:
26
+ """Encapsulates a validated response ready for delivery.
27
+
28
+ Attributes:
29
+ event: Event being responded to
30
+ response: User's response text
31
+ valid: Whether validation passed
32
+ validation_errors: List of validation error messages
33
+ timestamp: When the route was created
34
+ delivered: Whether response has been delivered
35
+ delivery_timestamp: When the response was delivered
36
+ """
37
+
38
+ event: Event
39
+ response: str
40
+ valid: bool
41
+ validation_errors: List[str] = field(default_factory=list)
42
+ timestamp: datetime = field(default_factory=_utc_now)
43
+ delivered: bool = False
44
+ delivery_timestamp: Optional[datetime] = None
45
+
46
+
47
+ class ResponseManager:
48
+ """Centralizes response validation, routing, and delivery.
49
+
50
+ Provides centralized response handling with validation and routing
51
+ capabilities for event responses.
52
+
53
+ Attributes:
54
+ event_manager: EventManager for retrieving events
55
+ runtime_executor: Optional RuntimeExecutor for response delivery
56
+ _response_history: History of all response attempts per event
57
+
58
+ Example:
59
+ >>> manager = ResponseManager(event_manager, runtime_executor)
60
+ >>> valid, errors = manager.validate_response(event, "staging")
61
+ >>> if valid:
62
+ ... route = manager.validate_and_route(event_id, "staging")
63
+ ... success = await manager.deliver_response(route)
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ event_manager: EventManager,
69
+ runtime_executor: Optional[RuntimeExecutor] = None,
70
+ ) -> None:
71
+ """Initialize ResponseManager.
72
+
73
+ Args:
74
+ event_manager: EventManager instance for retrieving events
75
+ runtime_executor: Optional RuntimeExecutor for response delivery
76
+
77
+ Raises:
78
+ ValueError: If event_manager is None
79
+ """
80
+ if event_manager is None:
81
+ raise ValueError("EventManager cannot be None")
82
+
83
+ self.event_manager = event_manager
84
+ self.runtime_executor = runtime_executor
85
+ self._response_history: Dict[str, List[ResponseRoute]] = {}
86
+
87
+ logger.debug(
88
+ "ResponseManager initialized (runtime_executor: %s)",
89
+ "enabled" if runtime_executor else "disabled",
90
+ )
91
+
92
+ def validate_response(self, event: Event, response: str) -> Tuple[bool, List[str]]:
93
+ """Validate response against event constraints.
94
+
95
+ Validation rules:
96
+ 1. Empty responses: Not allowed for blocking events
97
+ 2. DECISION_NEEDED options: Response must match one of the options
98
+ 3. Response whitespace: Stripped before validation
99
+
100
+ Args:
101
+ event: Event being responded to
102
+ response: User's response
103
+
104
+ Returns:
105
+ Tuple of (is_valid, list_of_error_messages)
106
+
107
+ Example:
108
+ >>> valid, errors = manager.validate_response(event, "staging")
109
+ >>> if not valid:
110
+ ... for error in errors:
111
+ ... print(f"Validation error: {error}")
112
+ """
113
+ errors: List[str] = []
114
+
115
+ # Strip whitespace for validation
116
+ response_stripped = response.strip()
117
+
118
+ # Rule 1: Empty responses not allowed for blocking events
119
+ if event.is_blocking and not response_stripped:
120
+ errors.append("Response cannot be empty for blocking events")
121
+
122
+ # Rule 2: DECISION_NEEDED events must use one of the provided options
123
+ if event.type == EventType.DECISION_NEEDED and event.options:
124
+ if response_stripped not in event.options:
125
+ errors.append(
126
+ f"Response must be one of: {', '.join(event.options)}. "
127
+ f"Got: '{response_stripped}'"
128
+ )
129
+
130
+ # Future validation rules can be added here:
131
+ # - Max length check
132
+ # - Format validation (e.g., regex patterns)
133
+ # - Custom validators per event type
134
+ # - Conditional validation based on event context
135
+
136
+ is_valid = len(errors) == 0
137
+ return is_valid, errors
138
+
139
+ def validate_and_route(
140
+ self, event_id: str, response: str
141
+ ) -> Optional[ResponseRoute]:
142
+ """Create a validated ResponseRoute for an event.
143
+
144
+ Retrieves the event, validates the response, and creates a ResponseRoute
145
+ with validation results.
146
+
147
+ Args:
148
+ event_id: ID of event to respond to
149
+ response: User's response
150
+
151
+ Returns:
152
+ ResponseRoute with validation results, or None if event not found
153
+
154
+ Example:
155
+ >>> route = manager.validate_and_route("evt_123", "staging")
156
+ >>> if route and route.valid:
157
+ ... await manager.deliver_response(route)
158
+ >>> elif route:
159
+ ... print(f"Validation failed: {route.validation_errors}")
160
+ """
161
+ # Get the event
162
+ event = self.event_manager.get(event_id)
163
+ if not event:
164
+ logger.warning("Event not found: %s", event_id)
165
+ return None
166
+
167
+ # Validate response
168
+ valid, errors = self.validate_response(event, response)
169
+
170
+ # Create route
171
+ route = ResponseRoute(
172
+ event=event,
173
+ response=response,
174
+ valid=valid,
175
+ validation_errors=errors,
176
+ )
177
+
178
+ logger.debug(
179
+ "Created route for event %s: valid=%s, errors=%s",
180
+ event_id,
181
+ valid,
182
+ errors,
183
+ )
184
+
185
+ return route
186
+
187
+ async def deliver_response(self, route: ResponseRoute) -> bool:
188
+ """Deliver a validated response to the runtime.
189
+
190
+ Records the response in event history and attempts delivery to the
191
+ runtime executor if available.
192
+
193
+ Args:
194
+ route: ResponseRoute to deliver
195
+
196
+ Returns:
197
+ True if delivery successful, False otherwise
198
+
199
+ Raises:
200
+ ValueError: If route validation failed
201
+
202
+ Example:
203
+ >>> route = manager.validate_and_route("evt_123", "yes")
204
+ >>> if route and route.valid:
205
+ ... success = await manager.deliver_response(route)
206
+ ... if success:
207
+ ... print("Response delivered successfully")
208
+ """
209
+ if not route.valid:
210
+ error_msg = "; ".join(route.validation_errors)
211
+ raise ValueError(f"Cannot deliver invalid response: {error_msg}")
212
+
213
+ # Mark route as delivered
214
+ route.delivered = True
215
+ route.delivery_timestamp = _utc_now()
216
+
217
+ # Track in history
218
+ self._add_to_history(route)
219
+
220
+ # For non-blocking events, no runtime delivery needed
221
+ if not route.event.is_blocking:
222
+ logger.debug(
223
+ "Event %s is non-blocking, no runtime delivery needed",
224
+ route.event.id,
225
+ )
226
+ return True
227
+
228
+ # Deliver to runtime if executor available
229
+ if not self.runtime_executor:
230
+ logger.warning(
231
+ "No runtime executor available, cannot deliver response for event %s",
232
+ route.event.id,
233
+ )
234
+ return False
235
+
236
+ # Note: Actual delivery is handled by EventHandler which has session context
237
+ # ResponseManager just validates and tracks responses
238
+ # The EventHandler will call executor.send_message() with session's active_pane
239
+ logger.info(
240
+ "Response validated and ready for delivery (event %s): %s",
241
+ route.event.id,
242
+ route.response[:50],
243
+ )
244
+ return True
245
+
246
+ def _add_to_history(self, route: ResponseRoute) -> None:
247
+ """Add response route to history tracking.
248
+
249
+ Args:
250
+ route: ResponseRoute to record
251
+ """
252
+ event_id = route.event.id
253
+ if event_id not in self._response_history:
254
+ self._response_history[event_id] = []
255
+
256
+ self._response_history[event_id].append(route)
257
+ logger.debug(
258
+ "Added response to history for event %s (total: %d)",
259
+ event_id,
260
+ len(self._response_history[event_id]),
261
+ )
262
+
263
+ def get_response_history(self, event_id: str) -> List[ResponseRoute]:
264
+ """Get all response attempts for an event (for audit trail).
265
+
266
+ Args:
267
+ event_id: Event ID to query
268
+
269
+ Returns:
270
+ List of ResponseRoute objects for this event (chronological order)
271
+
272
+ Example:
273
+ >>> history = manager.get_response_history("evt_123")
274
+ >>> for i, route in enumerate(history, 1):
275
+ ... status = "valid" if route.valid else "invalid"
276
+ ... print(f"Attempt {i} ({status}): {route.response}")
277
+ """
278
+ return self._response_history.get(event_id, []).copy()
279
+
280
+ def clear_history(self, event_id: str) -> int:
281
+ """Clear response history for an event.
282
+
283
+ Args:
284
+ event_id: Event ID to clear
285
+
286
+ Returns:
287
+ Number of history entries removed
288
+
289
+ Example:
290
+ >>> removed = manager.clear_history("evt_123")
291
+ >>> print(f"Cleared {removed} history entries")
292
+ """
293
+ history = self._response_history.pop(event_id, [])
294
+ count = len(history)
295
+ if count > 0:
296
+ logger.debug("Cleared %d history entries for event %s", count, event_id)
297
+ return count
298
+
299
+ def get_stats(self) -> Dict[str, Any]:
300
+ """Get statistics about response history.
301
+
302
+ Returns:
303
+ Dict with statistics about tracked responses
304
+
305
+ Example:
306
+ >>> stats = manager.get_stats()
307
+ >>> print(f"Total events with history: {stats['total_events']}")
308
+ >>> print(f"Total response attempts: {stats['total_responses']}")
309
+ """
310
+ total_events = len(self._response_history)
311
+ total_responses = sum(len(routes) for routes in self._response_history.values())
312
+ valid_responses = sum(
313
+ sum(1 for route in routes if route.valid)
314
+ for routes in self._response_history.values()
315
+ )
316
+ invalid_responses = total_responses - valid_responses
317
+
318
+ return {
319
+ "total_events": total_events,
320
+ "total_responses": total_responses,
321
+ "valid_responses": valid_responses,
322
+ "invalid_responses": invalid_responses,
323
+ }