gobby 0.2.6__py3-none-any.whl → 0.2.7__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 (146) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/codex_impl/__init__.py +28 -0
  4. gobby/adapters/codex_impl/adapter.py +722 -0
  5. gobby/adapters/codex_impl/client.py +679 -0
  6. gobby/adapters/codex_impl/protocol.py +20 -0
  7. gobby/adapters/codex_impl/types.py +68 -0
  8. gobby/agents/definitions.py +11 -1
  9. gobby/agents/isolation.py +395 -0
  10. gobby/agents/sandbox.py +261 -0
  11. gobby/agents/spawn.py +42 -287
  12. gobby/agents/spawn_executor.py +385 -0
  13. gobby/agents/spawners/__init__.py +24 -0
  14. gobby/agents/spawners/command_builder.py +189 -0
  15. gobby/agents/spawners/embedded.py +21 -2
  16. gobby/agents/spawners/headless.py +21 -2
  17. gobby/agents/spawners/prompt_manager.py +125 -0
  18. gobby/cli/install.py +4 -4
  19. gobby/cli/installers/claude.py +6 -0
  20. gobby/cli/installers/gemini.py +6 -0
  21. gobby/cli/installers/shared.py +103 -4
  22. gobby/cli/sessions.py +1 -1
  23. gobby/cli/utils.py +9 -2
  24. gobby/config/__init__.py +12 -97
  25. gobby/config/app.py +10 -94
  26. gobby/config/extensions.py +2 -2
  27. gobby/config/features.py +7 -130
  28. gobby/config/tasks.py +4 -28
  29. gobby/hooks/__init__.py +0 -13
  30. gobby/hooks/event_handlers.py +45 -2
  31. gobby/hooks/hook_manager.py +2 -2
  32. gobby/hooks/plugins.py +1 -1
  33. gobby/hooks/webhooks.py +1 -1
  34. gobby/llm/resolver.py +3 -2
  35. gobby/mcp_proxy/importer.py +62 -4
  36. gobby/mcp_proxy/instructions.py +2 -0
  37. gobby/mcp_proxy/registries.py +1 -4
  38. gobby/mcp_proxy/services/recommendation.py +43 -11
  39. gobby/mcp_proxy/tools/agents.py +31 -731
  40. gobby/mcp_proxy/tools/clones.py +0 -385
  41. gobby/mcp_proxy/tools/memory.py +2 -2
  42. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  43. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  44. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  45. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  46. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  47. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  48. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  49. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  50. gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
  51. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  52. gobby/mcp_proxy/tools/worktrees.py +0 -343
  53. gobby/memory/ingestion/__init__.py +5 -0
  54. gobby/memory/ingestion/multimodal.py +221 -0
  55. gobby/memory/manager.py +62 -283
  56. gobby/memory/search/__init__.py +10 -0
  57. gobby/memory/search/coordinator.py +248 -0
  58. gobby/memory/services/__init__.py +5 -0
  59. gobby/memory/services/crossref.py +142 -0
  60. gobby/prompts/loader.py +5 -2
  61. gobby/servers/http.py +1 -4
  62. gobby/servers/routes/admin.py +14 -0
  63. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  64. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  65. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  66. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  67. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  68. gobby/servers/routes/mcp/hooks.py +1 -1
  69. gobby/servers/routes/mcp/tools.py +48 -1506
  70. gobby/sessions/lifecycle.py +1 -1
  71. gobby/sessions/processor.py +10 -0
  72. gobby/sessions/transcripts/base.py +1 -0
  73. gobby/sessions/transcripts/claude.py +15 -5
  74. gobby/skills/parser.py +30 -2
  75. gobby/storage/migrations.py +159 -372
  76. gobby/storage/sessions.py +43 -7
  77. gobby/storage/skills.py +37 -4
  78. gobby/storage/tasks/_lifecycle.py +18 -3
  79. gobby/sync/memories.py +1 -1
  80. gobby/tasks/external_validator.py +1 -1
  81. gobby/tasks/validation.py +22 -20
  82. gobby/tools/summarizer.py +91 -10
  83. gobby/utils/project_context.py +2 -3
  84. gobby/utils/status.py +13 -0
  85. gobby/workflows/actions.py +221 -1217
  86. gobby/workflows/artifact_actions.py +31 -0
  87. gobby/workflows/autonomous_actions.py +11 -0
  88. gobby/workflows/context_actions.py +50 -1
  89. gobby/workflows/enforcement/__init__.py +47 -0
  90. gobby/workflows/enforcement/blocking.py +269 -0
  91. gobby/workflows/enforcement/commit_policy.py +283 -0
  92. gobby/workflows/enforcement/handlers.py +269 -0
  93. gobby/workflows/enforcement/task_policy.py +542 -0
  94. gobby/workflows/git_utils.py +106 -0
  95. gobby/workflows/llm_actions.py +30 -0
  96. gobby/workflows/mcp_actions.py +20 -1
  97. gobby/workflows/memory_actions.py +80 -0
  98. gobby/workflows/safe_evaluator.py +183 -0
  99. gobby/workflows/session_actions.py +44 -0
  100. gobby/workflows/state_actions.py +60 -1
  101. gobby/workflows/stop_signal_actions.py +55 -0
  102. gobby/workflows/summary_actions.py +94 -1
  103. gobby/workflows/task_sync_actions.py +347 -0
  104. gobby/workflows/todo_actions.py +34 -1
  105. gobby/workflows/webhook_actions.py +185 -0
  106. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
  107. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
  108. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  109. gobby/adapters/codex.py +0 -1332
  110. gobby/install/claude/commands/gobby/bug.md +0 -51
  111. gobby/install/claude/commands/gobby/chore.md +0 -51
  112. gobby/install/claude/commands/gobby/epic.md +0 -52
  113. gobby/install/claude/commands/gobby/eval.md +0 -235
  114. gobby/install/claude/commands/gobby/feat.md +0 -49
  115. gobby/install/claude/commands/gobby/nit.md +0 -52
  116. gobby/install/claude/commands/gobby/ref.md +0 -52
  117. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  118. gobby/prompts/defaults/expansion/system.md +0 -119
  119. gobby/prompts/defaults/expansion/user.md +0 -48
  120. gobby/prompts/defaults/external_validation/agent.md +0 -72
  121. gobby/prompts/defaults/external_validation/external.md +0 -63
  122. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  123. gobby/prompts/defaults/external_validation/system.md +0 -6
  124. gobby/prompts/defaults/features/import_mcp.md +0 -22
  125. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  126. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  127. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  128. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  129. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  130. gobby/prompts/defaults/features/server_description.md +0 -20
  131. gobby/prompts/defaults/features/server_description_system.md +0 -6
  132. gobby/prompts/defaults/features/task_description.md +0 -31
  133. gobby/prompts/defaults/features/task_description_system.md +0 -6
  134. gobby/prompts/defaults/features/tool_summary.md +0 -17
  135. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  136. gobby/prompts/defaults/handoff/compact.md +0 -63
  137. gobby/prompts/defaults/handoff/session_end.md +0 -57
  138. gobby/prompts/defaults/memory/extract.md +0 -61
  139. gobby/prompts/defaults/research/step.md +0 -58
  140. gobby/prompts/defaults/validation/criteria.md +0 -47
  141. gobby/prompts/defaults/validation/validate.md +0 -38
  142. gobby/storage/migrations_legacy.py +0 -1359
  143. gobby/workflows/task_enforcement_actions.py +0 -1343
  144. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  145. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  146. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,542 @@
1
+ """Task policy enforcement for workflow engine.
2
+
3
+ Provides actions that enforce task tracking and scoping requirements.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from gobby.mcp_proxy.tools.task_readiness import is_descendant_of
12
+ from gobby.workflows.git_utils import get_task_session_liveness
13
+
14
+ if TYPE_CHECKING:
15
+ from gobby.config.app import DaemonConfig
16
+ from gobby.storage.session_tasks import SessionTaskManager
17
+ from gobby.storage.sessions import LocalSessionManager
18
+ from gobby.storage.tasks import LocalTaskManager
19
+ from gobby.workflows.definitions import WorkflowState
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ async def require_task_complete(
25
+ task_manager: LocalTaskManager | None,
26
+ session_id: str,
27
+ task_ids: list[str] | None,
28
+ event_data: dict[str, Any] | None = None,
29
+ project_id: str | None = None,
30
+ workflow_state: WorkflowState | None = None,
31
+ ) -> dict[str, Any] | None:
32
+ """
33
+ Block agent from stopping until task(s) (and their subtasks) are complete.
34
+
35
+ This action is designed for on_stop triggers to enforce that the
36
+ agent completes all subtasks under specified task(s) before stopping.
37
+
38
+ Supports:
39
+ - Single task: ["#47"]
40
+ - Multiple tasks: ["#47", "#48"]
41
+ - Wildcard mode handled by caller (passes ready tasks as list)
42
+
43
+ Logic per task:
44
+ 1. If task has incomplete subtasks and agent has no claimed task -> suggest next subtask
45
+ 2. If task has incomplete subtasks and agent has claimed task -> remind to finish it
46
+ 3. If all subtasks done but task not closed -> remind to close the task
47
+ 4. If task is closed -> move to next task in list
48
+
49
+ Args:
50
+ task_manager: LocalTaskManager for querying tasks
51
+ session_id: Current session ID
52
+ task_ids: List of task IDs to enforce completion on
53
+ event_data: Hook event data
54
+ project_id: Optional project ID for scoping
55
+ workflow_state: Workflow state with variables (task_claimed, etc.)
56
+
57
+ Returns:
58
+ Dict with decision="block" and reason if any task incomplete,
59
+ or None to allow the stop.
60
+ """
61
+ if not task_ids:
62
+ logger.debug("require_task_complete: No task_ids specified, allowing")
63
+ return None
64
+
65
+ if not task_manager:
66
+ logger.debug("require_task_complete: No task_manager available, allowing")
67
+ return None
68
+
69
+ # Track how many times we've blocked in this session
70
+ block_count = 0
71
+ if workflow_state:
72
+ block_count = workflow_state.variables.get("_task_block_count", 0)
73
+
74
+ # Safety valve: after 5 blocks, allow to prevent infinite loop
75
+ if block_count >= 5:
76
+ logger.warning(
77
+ f"require_task_complete: Reached max block count ({block_count}), allowing stop"
78
+ )
79
+ return None
80
+
81
+ # Check if agent has a claimed task this session
82
+ has_claimed_task = False
83
+ claimed_task_id = None
84
+ if workflow_state:
85
+ has_claimed_task = workflow_state.variables.get("task_claimed", False)
86
+ claimed_task_id = workflow_state.variables.get("claimed_task_id")
87
+
88
+ try:
89
+ # Collect incomplete tasks across all specified task IDs
90
+ all_incomplete: list[tuple[Any, list[Any]]] = [] # (parent_task, incomplete_subtasks)
91
+
92
+ for task_id in task_ids:
93
+ task = task_manager.get_task(task_id)
94
+ if not task:
95
+ logger.warning(f"require_task_complete: Task '{task_id}' not found, skipping")
96
+ continue
97
+
98
+ # If task is already closed, skip it
99
+ if task.status == "closed":
100
+ logger.debug(f"require_task_complete: Task '{task_id}' is closed, skipping")
101
+ continue
102
+
103
+ # Get all subtasks under this task
104
+ subtasks = task_manager.list_tasks(parent_task_id=task_id)
105
+
106
+ # Find incomplete subtasks
107
+ incomplete = [t for t in subtasks if t.status != "closed"]
108
+
109
+ # If task itself is incomplete (no subtasks or has incomplete subtasks)
110
+ if not subtasks or incomplete:
111
+ all_incomplete.append((task, incomplete))
112
+
113
+ # If all tasks are complete, allow stop
114
+ if not all_incomplete:
115
+ logger.debug("require_task_complete: All specified tasks are complete, allowing")
116
+ return None
117
+
118
+ # Increment block count
119
+ if workflow_state:
120
+ workflow_state.variables["_task_block_count"] = block_count + 1
121
+
122
+ # Get the first incomplete task to report on
123
+ parent_task, incomplete = all_incomplete[0]
124
+ task_id = parent_task.id
125
+ remaining_tasks = len(all_incomplete)
126
+
127
+ # Build suffix for multiple tasks
128
+ multi_task_suffix = ""
129
+ if remaining_tasks > 1:
130
+ multi_task_suffix = f"\n\n({remaining_tasks} tasks remaining in total)"
131
+
132
+ # Case 1: No incomplete subtasks, but task not closed (leaf task or parent with all done)
133
+ if not incomplete:
134
+ logger.info(f"require_task_complete: Task '{task_id}' needs closing")
135
+ return {
136
+ "decision": "block",
137
+ "reason": (
138
+ f"Task '{parent_task.title}' is ready to close.\n"
139
+ f'close_task(task_id="{task_id}")'
140
+ f"{multi_task_suffix}"
141
+ ),
142
+ }
143
+
144
+ # Case 2: Has incomplete subtasks, agent has no claimed task
145
+ if not has_claimed_task:
146
+ logger.info(
147
+ f"require_task_complete: No claimed task, {len(incomplete)} incomplete subtasks"
148
+ )
149
+ return {
150
+ "decision": "block",
151
+ "reason": (
152
+ f"'{parent_task.title}' has {len(incomplete)} incomplete subtask(s).\n\n"
153
+ f"Use suggest_next_task() to find the best task to work on next, "
154
+ f"and continue working without requiring confirmation from the user."
155
+ f"{multi_task_suffix}"
156
+ ),
157
+ }
158
+
159
+ # Case 3: Has claimed task but subtasks still incomplete
160
+ if has_claimed_task and incomplete:
161
+ # Check if the claimed task is under this parent
162
+ claimed_under_parent = any(t.id == claimed_task_id for t in incomplete)
163
+
164
+ if claimed_under_parent:
165
+ logger.info(
166
+ f"require_task_complete: Claimed task '{claimed_task_id}' still incomplete"
167
+ )
168
+ return {
169
+ "decision": "block",
170
+ "reason": (
171
+ f"Your current task is not yet complete. "
172
+ f"Finish and close it before stopping:\n"
173
+ f'close_task(task_id="{claimed_task_id}")\n\n'
174
+ f"'{parent_task.title}' still has {len(incomplete)} incomplete subtask(s)."
175
+ f"{multi_task_suffix}"
176
+ ),
177
+ }
178
+ else:
179
+ # Claimed task is not under this parent - remind about parent work
180
+ logger.info("require_task_complete: Claimed task not under parent, redirecting")
181
+ return {
182
+ "decision": "block",
183
+ "reason": (
184
+ f"'{parent_task.title}' has {len(incomplete)} incomplete subtask(s).\n\n"
185
+ f"Use suggest_next_task() to find the best task to work on next, "
186
+ f"and continue working without requiring confirmation from the user."
187
+ f"{multi_task_suffix}"
188
+ ),
189
+ }
190
+
191
+ # Fallback: shouldn't reach here, but block with generic message
192
+ logger.info(f"require_task_complete: Generic block for task '{task_id}'")
193
+ return {
194
+ "decision": "block",
195
+ "reason": (
196
+ f"'{parent_task.title}' is not yet complete. "
197
+ f"{len(incomplete)} subtask(s) remaining."
198
+ f"{multi_task_suffix}"
199
+ ),
200
+ }
201
+
202
+ except Exception as e:
203
+ logger.error(f"require_task_complete: Error checking tasks: {e}")
204
+ # On error, allow to avoid blocking legitimate work
205
+ return None
206
+
207
+
208
+ async def require_active_task(
209
+ task_manager: LocalTaskManager | None,
210
+ session_id: str,
211
+ config: DaemonConfig | None,
212
+ event_data: dict[str, Any] | None,
213
+ project_id: str | None = None,
214
+ workflow_state: WorkflowState | None = None,
215
+ session_manager: LocalSessionManager | None = None,
216
+ session_task_manager: SessionTaskManager | None = None,
217
+ ) -> dict[str, Any] | None:
218
+ """
219
+ Check if an active task exists before allowing protected tools.
220
+
221
+ This action is designed to be used in on_before_tool triggers to enforce
222
+ that agents create or start a gobby-task before modifying files.
223
+
224
+ Session-scoped enforcement:
225
+ - First checks if `task_claimed` variable is True in workflow state
226
+ - If True, allows immediately (agent already claimed a task this session)
227
+ - If False, falls back to project-wide DB check for helpful messaging
228
+
229
+ Args:
230
+ task_manager: LocalTaskManager for querying tasks
231
+ session_id: Current session ID
232
+ config: DaemonConfig with workflow settings
233
+ event_data: Hook event data containing tool_name
234
+ project_id: Optional project ID to filter tasks by project scope
235
+ workflow_state: Optional workflow state to check task_claimed variable
236
+ session_manager: Optional session manager for liveness checks
237
+ session_task_manager: Optional session-task manager for liveness checks
238
+
239
+ Returns:
240
+ Dict with decision="block" if no active task and tool is protected,
241
+ or None to allow the tool.
242
+ """
243
+ # Check if feature is enabled
244
+ # Precedence: workflow_state variables > config.yaml
245
+ # (workflow_state already has step > lifecycle precedence merged)
246
+ require_task = None
247
+
248
+ # First check workflow state variables (step workflow > lifecycle workflow)
249
+ if workflow_state:
250
+ require_task = workflow_state.variables.get("require_task_before_edit")
251
+ if require_task is not None:
252
+ logger.debug(
253
+ f"require_active_task: Using workflow variable require_task_before_edit={require_task}"
254
+ )
255
+
256
+ # Fall back to config.yaml if not set in workflow variables
257
+ if require_task is None and config:
258
+ require_task = config.workflow.require_task_before_edit
259
+ logger.debug(
260
+ f"require_active_task: Using config.yaml require_task_before_edit={require_task}"
261
+ )
262
+
263
+ # If still None (no config), default to False (allow)
264
+ if require_task is None:
265
+ logger.debug("require_active_task: No config source, allowing")
266
+ return None
267
+
268
+ if not require_task:
269
+ logger.debug("require_active_task: Feature disabled, allowing")
270
+ return None
271
+
272
+ # Get the tool being called
273
+ if not event_data:
274
+ logger.debug("require_active_task: No event_data, allowing")
275
+ return None
276
+
277
+ tool_name = event_data.get("tool_name")
278
+ if not tool_name:
279
+ logger.debug("require_active_task: No tool_name in event_data, allowing")
280
+ return None
281
+
282
+ # Check if this tool is protected (always from config.yaml)
283
+ protected_tools = (
284
+ config.workflow.protected_tools if config else ["Edit", "Write", "Update", "NotebookEdit"]
285
+ )
286
+ if tool_name not in protected_tools:
287
+ logger.debug(f"require_active_task: Tool '{tool_name}' not protected, allowing")
288
+ return None
289
+
290
+ # Tool is protected - but check for plan mode exceptions first
291
+
292
+ # Check if target is a Claude Code plan file (stored in ~/.claude/plans/)
293
+ # This allows writes during plan mode without requiring a task
294
+ tool_input = event_data.get("tool_input", {}) or {}
295
+ file_path = tool_input.get("file_path", "")
296
+ if file_path and "/.claude/plans/" in file_path:
297
+ logger.debug(f"require_active_task: Target is Claude plan file '{file_path}', allowing")
298
+ return None
299
+
300
+ # Check for plan_mode variable (set via EnterPlanMode tool detection or manually)
301
+ if workflow_state and workflow_state.variables.get("plan_mode"):
302
+ logger.debug(f"require_active_task: plan_mode=True in session {session_id}, allowing")
303
+ return None
304
+
305
+ # Check for active task
306
+
307
+ # Session-scoped check: task_claimed variable (set by AFTER_TOOL detection)
308
+ # This is the primary enforcement - each session must explicitly claim a task
309
+ if workflow_state and workflow_state.variables.get("task_claimed"):
310
+ logger.debug(f"require_active_task: task_claimed=True in session {session_id}, allowing")
311
+ return None
312
+
313
+ # Fallback: Check for any in_progress task in the project
314
+ # This provides helpful messaging about existing tasks but is NOT sufficient
315
+ # for session-scoped enforcement (concurrent sessions shouldn't free-ride)
316
+ project_task_hint = ""
317
+
318
+ if task_manager is None:
319
+ logger.debug(
320
+ f"require_active_task: task_manager unavailable, skipping DB fallback check "
321
+ f"(project_id={project_id}, session_id={session_id})"
322
+ )
323
+ else:
324
+ try:
325
+ project_tasks = task_manager.list_tasks(
326
+ project_id=project_id,
327
+ status="in_progress",
328
+ limit=1,
329
+ )
330
+
331
+ if project_tasks:
332
+ task = project_tasks[0]
333
+ task_ref = f"#{task.seq_num}" if task.seq_num else task.id
334
+ project_task_hint = (
335
+ f"\n\nNote: Task {task_ref} ({task.title}) "
336
+ f"is in_progress but wasn't claimed by this session. "
337
+ f'Use `update_task(task_id="{task.id}", status="in_progress")` '
338
+ f"to claim it for this session."
339
+ )
340
+ logger.debug(
341
+ f"require_active_task: Found project task {task_ref} but "
342
+ f"session hasn't claimed it"
343
+ )
344
+
345
+ # Check liveness of the candidate task
346
+ is_live = get_task_session_liveness(
347
+ task.id, session_task_manager, session_manager, exclude_session_id=session_id
348
+ )
349
+
350
+ if is_live:
351
+ project_task_hint = (
352
+ f"\n\nNote: Task {task_ref} ({task.title}) "
353
+ f"is in_progress, but it is **currently being worked on by another active session**. "
354
+ f"You should probably create a new task or subtask instead of interfering."
355
+ )
356
+ else:
357
+ project_task_hint = (
358
+ f"\n\nNote: Task {task_ref} ({task.title}) "
359
+ f"is in_progress and appears unattended (no active session). "
360
+ f"If you are picking up this work, claim it: "
361
+ f'`update_task(task_id="{task.id}", status="in_progress")`.'
362
+ )
363
+
364
+ except Exception as e:
365
+ logger.error(f"require_active_task: Error querying tasks: {e}")
366
+ # On error, allow to avoid blocking legitimate work
367
+ return None
368
+
369
+ # No task claimed this session - block the tool
370
+ logger.info(
371
+ f"require_active_task: Blocking '{tool_name}' - no task claimed for session {session_id}"
372
+ )
373
+
374
+ # Check if we've already shown the full error this session
375
+ error_already_shown = False
376
+ if workflow_state:
377
+ error_already_shown = workflow_state.variables.get("task_error_shown", False)
378
+ # Mark that we've shown the error (for next time)
379
+ if not error_already_shown:
380
+ workflow_state.variables["task_error_shown"] = True
381
+
382
+ # Return short reminder if we've already shown the full error
383
+ if error_already_shown:
384
+ return {
385
+ "decision": "block",
386
+ "reason": (
387
+ "No task claimed. See previous **Task Required** error for instructions.\n"
388
+ "See skill: **claiming-tasks** for help."
389
+ ),
390
+ "inject_context": (
391
+ f"**Task Required**: `{tool_name}` blocked. "
392
+ f"Create or claim a task before editing files (see previous error for details).\n"
393
+ f'For detailed guidance: `get_skill(name="claiming-tasks")`'
394
+ f"{project_task_hint}"
395
+ ),
396
+ }
397
+
398
+ # First time - show full instructions
399
+ return {
400
+ "decision": "block",
401
+ "reason": (
402
+ f"No task claimed for this session. Before using {tool_name}, please either:\n"
403
+ f"- Create a task: call_tool(server_name='gobby-tasks', tool_name='create_task', arguments={{...}})\n"
404
+ f"- Claim an existing task: call_tool(server_name='gobby-tasks', tool_name='update_task', "
405
+ f"arguments={{'task_id': '...', 'status': 'in_progress'}})"
406
+ f"{project_task_hint}\n\n"
407
+ f"See skill: **claiming-tasks** for detailed guidance."
408
+ ),
409
+ "inject_context": (
410
+ f"**Task Required**: The `{tool_name}` tool is blocked until you claim a task for this session.\n\n"
411
+ f"Each session must explicitly create or claim a task before modifying files:\n"
412
+ f'1. **Create a new task**: `create_task(title="...", description="...")`\n'
413
+ f'2. **Claim an existing task**: `update_task(task_id="...", status="in_progress")`\n\n'
414
+ f"Use `list_ready_tasks()` to see available tasks."
415
+ f"{project_task_hint}\n\n"
416
+ f'For detailed guidance: `get_skill(name="claiming-tasks")`'
417
+ ),
418
+ }
419
+
420
+
421
+ async def validate_session_task_scope(
422
+ task_manager: LocalTaskManager | None,
423
+ workflow_state: WorkflowState | None,
424
+ event_data: dict[str, Any] | None = None,
425
+ ) -> dict[str, Any] | None:
426
+ """
427
+ Block claiming a task that is not a descendant of session_task.
428
+
429
+ This action is designed for on_before_tool triggers on update_task
430
+ to enforce that agents only work on tasks within the session_task hierarchy.
431
+
432
+ When session_task is set in workflow state, this action checks if the task
433
+ being claimed (set to in_progress) is a descendant of session_task.
434
+
435
+ Args:
436
+ task_manager: LocalTaskManager for querying tasks
437
+ workflow_state: Workflow state with session_task variable
438
+ event_data: Hook event data containing tool_name and tool_input
439
+
440
+ Returns:
441
+ Dict with decision="block" if task is outside session_task scope,
442
+ or None to allow the claim.
443
+ """
444
+ if not workflow_state:
445
+ logger.debug("validate_session_task_scope: No workflow_state, allowing")
446
+ return None
447
+
448
+ if not task_manager:
449
+ logger.debug("validate_session_task_scope: No task_manager, allowing")
450
+ return None
451
+
452
+ # Get session_task from workflow state
453
+ session_task = workflow_state.variables.get("session_task")
454
+ if not session_task:
455
+ logger.debug("validate_session_task_scope: No session_task set, allowing")
456
+ return None
457
+
458
+ # Handle "*" wildcard - means all tasks are in scope
459
+ if session_task == "*":
460
+ logger.debug("validate_session_task_scope: session_task='*', allowing all tasks")
461
+ return None
462
+
463
+ # Normalize to list for uniform handling
464
+ # session_task can be: string (single ID), list of IDs, or "*"
465
+ if isinstance(session_task, str):
466
+ session_task_ids = [session_task]
467
+ elif isinstance(session_task, list):
468
+ session_task_ids = session_task
469
+ else:
470
+ logger.warning(
471
+ f"validate_session_task_scope: Invalid session_task type: {type(session_task)}"
472
+ )
473
+ return None
474
+
475
+ # Empty list means no scope restriction
476
+ if not session_task_ids:
477
+ logger.debug("validate_session_task_scope: Empty session_task list, allowing")
478
+ return None
479
+
480
+ # Check if this is an update_task call setting status to in_progress
481
+ if not event_data:
482
+ logger.debug("validate_session_task_scope: No event_data, allowing")
483
+ return None
484
+
485
+ tool_name = event_data.get("tool_name")
486
+ if tool_name != "update_task":
487
+ logger.debug(f"validate_session_task_scope: Tool '{tool_name}' not update_task, allowing")
488
+ return None
489
+
490
+ tool_input = event_data.get("tool_input", {})
491
+ arguments = tool_input.get("arguments", {}) or {}
492
+
493
+ # Only check when setting status to in_progress (claiming)
494
+ new_status = arguments.get("status")
495
+ if new_status != "in_progress":
496
+ logger.debug(
497
+ f"validate_session_task_scope: Status '{new_status}' not in_progress, allowing"
498
+ )
499
+ return None
500
+
501
+ task_id = arguments.get("task_id")
502
+ if not task_id:
503
+ logger.debug("validate_session_task_scope: No task_id in arguments, allowing")
504
+ return None
505
+
506
+ # Check if task is a descendant of ANY session_task
507
+ for ancestor_id in session_task_ids:
508
+ if is_descendant_of(task_manager, task_id, ancestor_id):
509
+ logger.debug(
510
+ f"validate_session_task_scope: Task '{task_id}' is descendant of "
511
+ f"session_task '{ancestor_id}', allowing"
512
+ )
513
+ return None
514
+
515
+ # Task is outside all session_task scopes - block
516
+ logger.info(
517
+ f"validate_session_task_scope: Blocking claim of task '{task_id}' - "
518
+ f"not a descendant of any session_task: {session_task_ids}"
519
+ )
520
+
521
+ # Build error message with scope details
522
+ if len(session_task_ids) == 1:
523
+ session_task_obj = task_manager.get_task(session_task_ids[0])
524
+ scope_desc = (
525
+ f"'{session_task_obj.title}' ({session_task_ids[0]})"
526
+ if session_task_obj
527
+ else session_task_ids[0]
528
+ )
529
+ suggestion = f'Use `suggest_next_task(parent_id="{session_task_ids[0]}")` to find tasks within scope.'
530
+ else:
531
+ scope_desc = ", ".join(session_task_ids)
532
+ suggestion = "Use `suggest_next_task()` with one of the scoped parent IDs to find tasks within scope."
533
+
534
+ return {
535
+ "decision": "block",
536
+ "reason": (
537
+ f"Cannot claim task '{task_id}' - it is not within the session_task scope.\n\n"
538
+ f"This session is scoped to: {scope_desc}\n"
539
+ f"Only tasks that are descendants of these epics/features can be claimed.\n\n"
540
+ f"{suggestion}"
541
+ ),
542
+ }
@@ -4,8 +4,15 @@ Extracted from actions.py as part of strangler fig decomposition.
4
4
  These are pure utility functions with no ActionContext dependency.
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
7
9
  import logging
8
10
  import subprocess # nosec B404 - subprocess needed for git commands
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from gobby.storage.session_tasks import SessionTaskManager
15
+ from gobby.storage.sessions import LocalSessionManager
9
16
 
10
17
  logger = logging.getLogger(__name__)
11
18
 
@@ -94,3 +101,102 @@ def get_file_changes() -> str:
94
101
 
95
102
  except Exception:
96
103
  return "Unable to determine file changes"
104
+
105
+
106
+ def get_dirty_files(project_path: str | None = None) -> set[str]:
107
+ """
108
+ Get the set of dirty files from git status --porcelain.
109
+
110
+ Excludes .gobby/ files from the result.
111
+
112
+ Args:
113
+ project_path: Path to the project directory
114
+
115
+ Returns:
116
+ Set of dirty file paths (relative to repo root)
117
+ """
118
+ if project_path is None:
119
+ logger.warning(
120
+ "get_dirty_files: project_path is None, git status will use daemon's cwd "
121
+ "which may not be the project directory"
122
+ )
123
+
124
+ try:
125
+ result = subprocess.run( # nosec B603 B607 - hardcoded git command
126
+ ["git", "status", "--porcelain"],
127
+ cwd=project_path,
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=10,
131
+ )
132
+
133
+ if result.returncode != 0:
134
+ logger.warning(f"get_dirty_files: git status failed: {result.stderr}")
135
+ return set()
136
+
137
+ dirty_files = set()
138
+ # Split by newline first, don't strip() the whole string as it removes
139
+ # the leading space from git status format (e.g., " M file.py")
140
+ for line in result.stdout.split("\n"):
141
+ line = line.rstrip() # Remove trailing whitespace only
142
+ if not line:
143
+ continue
144
+ # Format is "XY filename" or "XY filename -> newname" for renames
145
+ # Skip the status prefix (first 3 chars: 2 status chars + space)
146
+ filepath = line[3:].split(" -> ")[0] # Handle renames
147
+ # Exclude .gobby/ files
148
+ if not filepath.startswith(".gobby/"):
149
+ dirty_files.add(filepath)
150
+
151
+ return dirty_files
152
+
153
+ except subprocess.TimeoutExpired:
154
+ logger.warning("get_dirty_files: git status timed out")
155
+ return set()
156
+ except FileNotFoundError:
157
+ logger.warning("get_dirty_files: git not found")
158
+ return set()
159
+ except Exception as e:
160
+ logger.error(f"get_dirty_files: Error running git status: {e}")
161
+ return set()
162
+
163
+
164
+ def get_task_session_liveness(
165
+ task_id: str,
166
+ session_task_manager: SessionTaskManager | None,
167
+ session_manager: LocalSessionManager | None,
168
+ exclude_session_id: str | None = None,
169
+ ) -> bool:
170
+ """
171
+ Check if a task is currently being worked on by an active session.
172
+
173
+ Args:
174
+ task_id: The task ID to check
175
+ session_task_manager: Manager to look up session-task links
176
+ session_manager: Manager to check session status
177
+ exclude_session_id: ID of session to exclude from check (e.g. current one)
178
+
179
+ Returns:
180
+ True if an active session (status='active') is linked to this task.
181
+ """
182
+ if not session_task_manager or not session_manager:
183
+ return False
184
+
185
+ try:
186
+ # Get all sessions linked to this task
187
+ linked_sessions = session_task_manager.get_task_sessions(task_id)
188
+
189
+ for link in linked_sessions:
190
+ session_id = link.get("session_id")
191
+ if not session_id or session_id == exclude_session_id:
192
+ continue
193
+
194
+ # Check if session is truly active
195
+ session = session_manager.get(session_id)
196
+ if session and session.status == "active":
197
+ return True
198
+
199
+ return False
200
+ except Exception as e:
201
+ logger.warning(f"get_task_session_liveness: Error checking liveness for {task_id}: {e}")
202
+ return False
@@ -68,3 +68,33 @@ async def call_llm(
68
68
  except Exception as e:
69
69
  logger.error(f"call_llm: Failed: {e}")
70
70
  return {"error": str(e)}
71
+
72
+
73
+ # --- ActionHandler-compatible wrappers ---
74
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
75
+
76
+ if __name__ != "__main__":
77
+ from typing import TYPE_CHECKING
78
+
79
+ if TYPE_CHECKING:
80
+ from gobby.workflows.actions import ActionContext
81
+
82
+
83
+ async def handle_call_llm(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
84
+ """ActionHandler wrapper for call_llm."""
85
+ if context.session_manager is None:
86
+ return {"error": "Session manager not available"}
87
+
88
+ session = context.session_manager.get(context.session_id)
89
+ if session is None:
90
+ return {"error": f"Session not found: {context.session_id}"}
91
+
92
+ return await call_llm(
93
+ llm_service=context.llm_service,
94
+ template_engine=context.template_engine,
95
+ state=context.state,
96
+ session=session,
97
+ prompt=kwargs.get("prompt"),
98
+ output_as=kwargs.get("output_as"),
99
+ **{k: v for k, v in kwargs.items() if k not in ("prompt", "output_as")},
100
+ )