gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,283 @@
1
+ """Commit policy enforcement for workflow engine.
2
+
3
+ Provides actions that enforce commit requirements before stopping or closing tasks.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from gobby.workflows.git_utils import get_dirty_files
12
+
13
+ if TYPE_CHECKING:
14
+ from gobby.storage.tasks import LocalTaskManager
15
+ from gobby.workflows.definitions import WorkflowState
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ async def capture_baseline_dirty_files(
21
+ workflow_state: WorkflowState | None,
22
+ project_path: str | None = None,
23
+ ) -> dict[str, Any] | None:
24
+ """
25
+ Capture current dirty files as baseline for session-aware detection.
26
+
27
+ Called on session_start to record pre-existing dirty files. The
28
+ require_commit_before_stop action will compare against this baseline
29
+ to detect only NEW dirty files made during the session.
30
+
31
+ Args:
32
+ workflow_state: Workflow state to store baseline in
33
+ project_path: Path to the project directory for git status check
34
+
35
+ Returns:
36
+ Dict with captured baseline info, or None if no workflow_state
37
+ """
38
+ if not workflow_state:
39
+ logger.debug("capture_baseline_dirty_files: No workflow_state, skipping")
40
+ return None
41
+
42
+ dirty_files = get_dirty_files(project_path)
43
+
44
+ # Store as a list in workflow state (sets aren't JSON serializable)
45
+ workflow_state.variables["baseline_dirty_files"] = list(dirty_files)
46
+
47
+ # Log for debugging baseline capture issues
48
+ files_preview = list(dirty_files)[:5]
49
+ logger.info(
50
+ f"capture_baseline_dirty_files: project_path={project_path}, "
51
+ f"captured {len(dirty_files)} files: {files_preview}"
52
+ )
53
+
54
+ return {
55
+ "baseline_captured": True,
56
+ "file_count": len(dirty_files),
57
+ "files": list(dirty_files),
58
+ }
59
+
60
+
61
+ async def require_commit_before_stop(
62
+ workflow_state: WorkflowState | None,
63
+ project_path: str | None = None,
64
+ task_manager: LocalTaskManager | None = None,
65
+ ) -> dict[str, Any] | None:
66
+ """
67
+ Block stop if there's an in_progress task with uncommitted changes.
68
+
69
+ This action is designed for on_stop triggers to enforce that agents
70
+ commit their work and close tasks before stopping.
71
+
72
+ Args:
73
+ workflow_state: Workflow state with variables (claimed_task_id, etc.)
74
+ project_path: Path to the project directory for git status check
75
+ task_manager: LocalTaskManager to verify task status
76
+
77
+ Returns:
78
+ Dict with decision="block" and reason if task has uncommitted changes,
79
+ or None to allow the stop.
80
+ """
81
+ if not workflow_state:
82
+ logger.debug("require_commit_before_stop: No workflow_state, allowing")
83
+ return None
84
+
85
+ claimed_task_id = workflow_state.variables.get("claimed_task_id")
86
+ if not claimed_task_id:
87
+ logger.debug("require_commit_before_stop: No claimed task, allowing")
88
+ return None
89
+
90
+ # Verify the task is actually still in_progress (not just cached in workflow state)
91
+ if task_manager:
92
+ task = task_manager.get_task(claimed_task_id)
93
+ if not task or task.status != "in_progress":
94
+ # Task was changed - clear the stale workflow state
95
+ logger.debug(
96
+ f"require_commit_before_stop: Task '{claimed_task_id}' is no longer "
97
+ f"in_progress (status={task.status if task else 'not found'}), clearing state"
98
+ )
99
+ workflow_state.variables["claimed_task_id"] = None
100
+ workflow_state.variables["task_claimed"] = False
101
+ return None
102
+
103
+ # Check for uncommitted changes using baseline-aware comparison
104
+ current_dirty = get_dirty_files(project_path)
105
+
106
+ if not current_dirty:
107
+ logger.debug("require_commit_before_stop: No uncommitted changes, allowing")
108
+ return None
109
+
110
+ # Get baseline dirty files captured at session start
111
+ baseline_dirty = set(workflow_state.variables.get("baseline_dirty_files", []))
112
+
113
+ # Calculate NEW dirty files (not in baseline)
114
+ new_dirty = current_dirty - baseline_dirty
115
+
116
+ if not new_dirty:
117
+ logger.debug(
118
+ f"require_commit_before_stop: All {len(current_dirty)} dirty files were pre-existing "
119
+ f"(in baseline), allowing"
120
+ )
121
+ return None
122
+
123
+ logger.debug(
124
+ f"require_commit_before_stop: Found {len(new_dirty)} new dirty files "
125
+ f"(baseline had {len(baseline_dirty)}, current has {len(current_dirty)})"
126
+ )
127
+
128
+ # Track how many times we've blocked to prevent infinite loops
129
+ block_count = workflow_state.variables.get("_commit_block_count", 0)
130
+ if block_count >= 3:
131
+ logger.warning(
132
+ f"require_commit_before_stop: Reached max block count ({block_count}), allowing"
133
+ )
134
+ return None
135
+
136
+ workflow_state.variables["_commit_block_count"] = block_count + 1
137
+
138
+ # Block - agent needs to commit and close
139
+ logger.info(
140
+ f"require_commit_before_stop: Blocking stop - task '{claimed_task_id}' "
141
+ f"has {len(new_dirty)} uncommitted changes"
142
+ )
143
+
144
+ # Build list of new dirty files for the message (limit to 10 for readability)
145
+ new_dirty_list = sorted(new_dirty)[:10]
146
+ files_display = "\n".join(f" - {f}" for f in new_dirty_list)
147
+ if len(new_dirty) > 10:
148
+ files_display += f"\n ... and {len(new_dirty) - 10} more files"
149
+
150
+ return {
151
+ "decision": "block",
152
+ "reason": (
153
+ f"Task '{claimed_task_id}' is in_progress with {len(new_dirty)} uncommitted "
154
+ f"changes made during this session:\n{files_display}\n\n"
155
+ f"Before stopping, commit your changes and close the task:\n"
156
+ f"1. Commit with [{claimed_task_id}] in the message\n"
157
+ f'2. Close the task: close_task(task_id="{claimed_task_id}", commit_sha="...")'
158
+ ),
159
+ }
160
+
161
+
162
+ async def require_task_review_or_close_before_stop(
163
+ workflow_state: WorkflowState | None,
164
+ task_manager: LocalTaskManager | None = None,
165
+ project_id: str | None = None,
166
+ **kwargs: Any,
167
+ ) -> dict[str, Any] | None:
168
+ """Block stop if session has an in_progress task.
169
+
170
+ Agents must close their task (or send to review) before stopping.
171
+ The close_task() validation already requires a commit, so we don't
172
+ need to check for uncommitted changes here - that's handled by
173
+ require_commit_before_stop if needed.
174
+
175
+ Checks both:
176
+ 1. claimed_task_id - task explicitly claimed via update_task(status="in_progress")
177
+ 2. session_task - task(s) assigned via set_variable (fallback if no claimed_task_id)
178
+
179
+ Args:
180
+ workflow_state: Workflow state with variables (claimed_task_id, etc.)
181
+ task_manager: LocalTaskManager to verify task status
182
+ project_id: Project ID for resolving task references (#N, N formats)
183
+ **kwargs: Accepts additional kwargs for compatibility
184
+
185
+ Returns:
186
+ Dict with decision="block" and reason if task is still in_progress,
187
+ or None to allow the stop.
188
+ """
189
+ if not workflow_state:
190
+ logger.debug("require_task_review_or_close_before_stop: No workflow_state, allowing")
191
+ return None
192
+
193
+ # 1. Check claimed_task_id first (existing behavior)
194
+ claimed_task_id = workflow_state.variables.get("claimed_task_id")
195
+
196
+ # 2. If no claimed task, fall back to session_task
197
+ if not claimed_task_id and task_manager:
198
+ session_task = workflow_state.variables.get("session_task")
199
+ if session_task and session_task != "*":
200
+ # Normalize to list
201
+ task_ids = [session_task] if isinstance(session_task, str) else session_task
202
+
203
+ if isinstance(task_ids, list):
204
+ for task_id in task_ids:
205
+ try:
206
+ task = task_manager.get_task(task_id, project_id=project_id)
207
+ except ValueError:
208
+ continue
209
+ if task and task.status == "in_progress":
210
+ claimed_task_id = task_id
211
+ logger.debug(
212
+ f"require_task_review_or_close_before_stop: Found in_progress "
213
+ f"session_task '{task_id}'"
214
+ )
215
+ break
216
+ # Also check subtasks
217
+ if task:
218
+ subtasks = task_manager.list_tasks(parent_task_id=task.id)
219
+ for subtask in subtasks:
220
+ if subtask.status == "in_progress":
221
+ claimed_task_id = subtask.id
222
+ logger.debug(
223
+ f"require_task_review_or_close_before_stop: Found in_progress "
224
+ f"subtask '{subtask.id}' under session_task '{task_id}'"
225
+ )
226
+ break
227
+ if claimed_task_id:
228
+ break
229
+
230
+ if not claimed_task_id:
231
+ logger.debug("require_task_review_or_close_before_stop: No claimed task, allowing")
232
+ return None
233
+
234
+ if not task_manager:
235
+ logger.debug("require_task_review_or_close_before_stop: No task_manager, allowing")
236
+ return None
237
+
238
+ try:
239
+ task = task_manager.get_task(claimed_task_id, project_id=project_id)
240
+ if not task:
241
+ # Task not found - clear stale workflow state and allow
242
+ logger.debug(
243
+ f"require_task_review_or_close_before_stop: Task '{claimed_task_id}' not found, "
244
+ f"clearing state"
245
+ )
246
+ workflow_state.variables["claimed_task_id"] = None
247
+ workflow_state.variables["task_claimed"] = False
248
+ return None
249
+
250
+ if task.status != "in_progress":
251
+ # Task is closed or in review - allow stop
252
+ logger.debug(
253
+ f"require_task_review_or_close_before_stop: Task '{claimed_task_id}' "
254
+ f"status={task.status}, allowing"
255
+ )
256
+ # Clear stale workflow state
257
+ workflow_state.variables["claimed_task_id"] = None
258
+ workflow_state.variables["task_claimed"] = False
259
+ return None
260
+
261
+ # Task is still in_progress - block the stop
262
+ task_ref = f"#{task.seq_num}" if task.seq_num else task.id[:8]
263
+ logger.info(
264
+ f"require_task_review_or_close_before_stop: Blocking stop - task "
265
+ f"{task_ref} is still in_progress"
266
+ )
267
+
268
+ return {
269
+ "decision": "block",
270
+ "reason": (
271
+ f"\nTask {task_ref} is still in_progress. "
272
+ f"Close it with close_task() before stopping."
273
+ ),
274
+ "task_id": claimed_task_id,
275
+ "task_status": task.status,
276
+ }
277
+
278
+ except Exception as e:
279
+ logger.warning(
280
+ f"require_task_review_or_close_before_stop: Failed to check task status: {e}"
281
+ )
282
+ # Allow stop if we can't check - don't block on errors
283
+ return None
@@ -0,0 +1,269 @@
1
+ """ActionHandler wrappers for enforcement actions.
2
+
3
+ These handlers match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
4
+ They bridge the workflow engine to the core enforcement functions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from gobby.workflows.enforcement.blocking import block_tools
13
+ from gobby.workflows.enforcement.commit_policy import (
14
+ capture_baseline_dirty_files,
15
+ require_commit_before_stop,
16
+ require_task_review_or_close_before_stop,
17
+ )
18
+ from gobby.workflows.enforcement.task_policy import (
19
+ require_active_task,
20
+ require_task_complete,
21
+ validate_session_task_scope,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from gobby.storage.tasks import LocalTaskManager
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ __all__ = [
30
+ "handle_block_tools",
31
+ "handle_capture_baseline_dirty_files",
32
+ "handle_require_active_task",
33
+ "handle_require_commit_before_stop",
34
+ "handle_require_task_complete",
35
+ "handle_require_task_review_or_close_before_stop",
36
+ "handle_validate_session_task_scope",
37
+ ]
38
+
39
+
40
+ async def handle_capture_baseline_dirty_files(
41
+ context: Any,
42
+ task_manager: LocalTaskManager | None = None,
43
+ **kwargs: Any,
44
+ ) -> dict[str, Any] | None:
45
+ """ActionHandler wrapper for capture_baseline_dirty_files.
46
+
47
+ Note: project_path comes from session's project lookup or event_data.cwd.
48
+ """
49
+ from gobby.storage.projects import LocalProjectManager
50
+
51
+ # Get project path - prioritize session lookup over hook payload
52
+ project_path = None
53
+
54
+ # 1. Get from session's project (most reliable - session exists by now)
55
+ if context.session_id and context.session_manager:
56
+ session = context.session_manager.get(context.session_id)
57
+ if session and session.project_id:
58
+ project_mgr = LocalProjectManager(context.db)
59
+ project = project_mgr.get(session.project_id)
60
+ if project and project.repo_path:
61
+ project_path = project.repo_path
62
+
63
+ # 2. Fallback to event_data.cwd (from hook payload)
64
+ if not project_path and context.event_data:
65
+ project_path = context.event_data.get("cwd")
66
+
67
+ return await capture_baseline_dirty_files(
68
+ workflow_state=context.state,
69
+ project_path=project_path,
70
+ )
71
+
72
+
73
+ async def handle_require_commit_before_stop(
74
+ context: Any,
75
+ task_manager: LocalTaskManager | None = None,
76
+ **kwargs: Any,
77
+ ) -> dict[str, Any] | None:
78
+ """ActionHandler wrapper for require_commit_before_stop.
79
+
80
+ Note: task_manager must be passed via closure from executor.
81
+ """
82
+ from gobby.storage.projects import LocalProjectManager
83
+
84
+ # Get project path
85
+ project_path = None
86
+
87
+ if context.session_id and context.session_manager:
88
+ session = context.session_manager.get(context.session_id)
89
+ if session and session.project_id:
90
+ project_mgr = LocalProjectManager(context.db)
91
+ project = project_mgr.get(session.project_id)
92
+ if project and project.repo_path:
93
+ project_path = project.repo_path
94
+
95
+ if not project_path and context.event_data:
96
+ project_path = context.event_data.get("cwd")
97
+
98
+ return await require_commit_before_stop(
99
+ workflow_state=context.state,
100
+ project_path=project_path,
101
+ task_manager=task_manager,
102
+ )
103
+
104
+
105
+ async def handle_require_task_review_or_close_before_stop(
106
+ context: Any,
107
+ task_manager: LocalTaskManager | None = None,
108
+ **kwargs: Any,
109
+ ) -> dict[str, Any] | None:
110
+ """ActionHandler wrapper for require_task_review_or_close_before_stop."""
111
+ project_id = None
112
+ if context.session_manager:
113
+ session = context.session_manager.get(context.session_id)
114
+ if session:
115
+ project_id = session.project_id
116
+
117
+ return await require_task_review_or_close_before_stop(
118
+ workflow_state=context.state,
119
+ task_manager=task_manager,
120
+ project_id=project_id,
121
+ )
122
+
123
+
124
+ async def handle_validate_session_task_scope(
125
+ context: Any,
126
+ task_manager: LocalTaskManager | None = None,
127
+ **kwargs: Any,
128
+ ) -> dict[str, Any] | None:
129
+ """ActionHandler wrapper for validate_session_task_scope."""
130
+ return await validate_session_task_scope(
131
+ task_manager=task_manager,
132
+ workflow_state=context.state,
133
+ event_data=context.event_data,
134
+ )
135
+
136
+
137
+ async def handle_block_tools(
138
+ context: Any,
139
+ task_manager: LocalTaskManager | None = None,
140
+ **kwargs: Any,
141
+ ) -> dict[str, Any] | None:
142
+ """ActionHandler wrapper for block_tools.
143
+
144
+ Passes task_manager via closure from register_defaults.
145
+ """
146
+ from gobby.storage.projects import LocalProjectManager
147
+
148
+ # Get project_path for git dirty file checks
149
+ project_path = kwargs.get("project_path")
150
+ if not project_path and context.event_data:
151
+ project_path = context.event_data.get("cwd")
152
+
153
+ # Get source from session for is_plan_file checks
154
+ source = None
155
+ current_session = None
156
+ if context.session_manager:
157
+ current_session = context.session_manager.get(context.session_id)
158
+ if current_session:
159
+ source = current_session.source
160
+
161
+ # Fallback to session's project path
162
+ if not project_path and current_session and context.db:
163
+ project_mgr = LocalProjectManager(context.db)
164
+ project = project_mgr.get(current_session.project_id)
165
+ if project and project.repo_path:
166
+ project_path = project.repo_path
167
+
168
+ return await block_tools(
169
+ rules=kwargs.get("rules"),
170
+ event_data=context.event_data,
171
+ workflow_state=context.state,
172
+ project_path=project_path,
173
+ task_manager=task_manager,
174
+ source=source,
175
+ )
176
+
177
+
178
+ async def handle_require_active_task(
179
+ context: Any,
180
+ task_manager: LocalTaskManager | None = None,
181
+ **kwargs: Any,
182
+ ) -> dict[str, Any] | None:
183
+ """ActionHandler wrapper for require_active_task.
184
+
185
+ DEPRECATED: Use block_tools action with rules instead.
186
+ Kept for backward compatibility with existing workflows.
187
+ """
188
+ # Get project_id from session for project-scoped task filtering
189
+ current_session = None
190
+ project_id = None
191
+ if context.session_manager:
192
+ current_session = context.session_manager.get(context.session_id)
193
+ if current_session:
194
+ project_id = current_session.project_id
195
+
196
+ return await require_active_task(
197
+ task_manager=task_manager,
198
+ session_id=context.session_id,
199
+ config=context.config,
200
+ event_data=context.event_data,
201
+ project_id=project_id,
202
+ workflow_state=context.state,
203
+ session_manager=context.session_manager,
204
+ session_task_manager=context.session_task_manager,
205
+ )
206
+
207
+
208
+ async def handle_require_task_complete(
209
+ context: Any,
210
+ task_manager: LocalTaskManager | None = None,
211
+ template_engine: Any | None = None,
212
+ **kwargs: Any,
213
+ ) -> dict[str, Any] | None:
214
+ """ActionHandler wrapper for require_task_complete.
215
+
216
+ Supports:
217
+ - Single task ID: "#47"
218
+ - List of task IDs: ["#47", "#48"]
219
+ - Wildcard: "*" - work until no ready tasks remain
220
+ """
221
+ project_id = None
222
+ if context.session_manager and context.session_id:
223
+ session = context.session_manager.get(context.session_id)
224
+ if session:
225
+ project_id = session.project_id
226
+
227
+ # Get task_id from kwargs - may be a template that needs resolving
228
+ task_spec = kwargs.get("task_id")
229
+
230
+ # If it's a template reference like "{{ variables.session_task }}", resolve it
231
+ if task_spec and "{{" in str(task_spec) and template_engine:
232
+ task_spec = template_engine.render(
233
+ str(task_spec),
234
+ {"variables": context.state.variables if context.state else {}},
235
+ )
236
+
237
+ # Handle different task_spec types:
238
+ # - None/empty: no enforcement
239
+ # - "*": wildcard - fetch ready tasks
240
+ # - list: multiple specific tasks
241
+ # - string: single task ID
242
+ task_ids: list[str] | None = None
243
+
244
+ if not task_spec:
245
+ return None
246
+ elif task_spec == "*":
247
+ # Wildcard: get all ready tasks for this project
248
+ if task_manager:
249
+ ready_tasks = task_manager.list_ready_tasks(
250
+ project_id=project_id,
251
+ limit=100,
252
+ )
253
+ task_ids = [t.id for t in ready_tasks]
254
+ if not task_ids:
255
+ # No ready tasks - allow stop
256
+ return None
257
+ elif isinstance(task_spec, list):
258
+ task_ids = task_spec
259
+ else:
260
+ task_ids = [str(task_spec)]
261
+
262
+ return await require_task_complete(
263
+ task_manager=task_manager,
264
+ session_id=context.session_id,
265
+ task_ids=task_ids,
266
+ event_data=context.event_data,
267
+ project_id=project_id,
268
+ workflow_state=context.state,
269
+ )