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
@@ -4,6 +4,7 @@ Extracted from actions.py as part of strangler fig decomposition.
4
4
  These functions handle file artifact capture and reading.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import glob
8
9
  import logging
9
10
  import os
@@ -101,3 +102,33 @@ def read_artifact(
101
102
  except Exception as e:
102
103
  logger.error(f"read_artifact: Failed to read {filepath}: {e}")
103
104
  return None
105
+
106
+
107
+ # --- ActionHandler-compatible wrappers ---
108
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
109
+
110
+ if __name__ != "__main__":
111
+ from typing import TYPE_CHECKING
112
+
113
+ if TYPE_CHECKING:
114
+ from gobby.workflows.actions import ActionContext
115
+
116
+
117
+ async def handle_capture_artifact(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
118
+ """ActionHandler wrapper for capture_artifact."""
119
+ return await asyncio.to_thread(
120
+ capture_artifact,
121
+ state=context.state,
122
+ pattern=kwargs.get("pattern"),
123
+ save_as=kwargs.get("as"),
124
+ )
125
+
126
+
127
+ async def handle_read_artifact(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
128
+ """ActionHandler wrapper for read_artifact."""
129
+ return await asyncio.to_thread(
130
+ read_artifact,
131
+ state=context.state,
132
+ pattern=kwargs.get("pattern"),
133
+ variable_name=kwargs.get("as"),
134
+ )
@@ -284,3 +284,14 @@ def get_progress_summary(
284
284
  "last_event_at": (summary.last_event_at.isoformat() if summary.last_event_at else None),
285
285
  "events_by_type": {k.value: v for k, v in summary.events_by_type.items()},
286
286
  }
287
+
288
+
289
+ # --- ActionHandler-compatible wrappers ---
290
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
291
+ # Note: These handlers require executor access for progress_tracker and stuck_detector,
292
+ # so they are created as closures inside ActionExecutor._register_defaults().
293
+
294
+ # No wrapper functions are defined in this file. The actual handler implementations
295
+ # are closures created in ActionExecutor._register_defaults() which capture the
296
+ # executor's self.progress_tracker and self.stuck_detector references. See that
297
+ # method for the actual implementations and where these components are hooked up.
@@ -6,10 +6,14 @@ These functions handle context injection, message injection, and handoff extract
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import json
10
11
  import logging
11
12
  from pathlib import Path
12
- from typing import Any
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from gobby.workflows.actions import ActionContext
13
17
 
14
18
  from gobby.workflows.git_utils import get_git_status, get_recent_git_commits
15
19
 
@@ -435,3 +439,48 @@ def format_handoff_as_markdown(ctx: Any, prompt_template: str | None = None) ->
435
439
  sections.append("\n".join(lines))
436
440
 
437
441
  return "\n\n".join(sections)
442
+
443
+
444
+ # --- ActionHandler-compatible wrappers ---
445
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
446
+
447
+
448
+ async def handle_inject_context(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
449
+ """ActionHandler wrapper for inject_context."""
450
+ return await asyncio.to_thread(
451
+ inject_context,
452
+ session_manager=context.session_manager,
453
+ session_id=context.session_id,
454
+ state=context.state,
455
+ template_engine=context.template_engine,
456
+ source=kwargs.get("source"),
457
+ template=kwargs.get("template"),
458
+ require=kwargs.get("require", False),
459
+ )
460
+
461
+
462
+ async def handle_inject_message(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
463
+ """ActionHandler wrapper for inject_message."""
464
+ return await asyncio.to_thread(
465
+ inject_message,
466
+ session_manager=context.session_manager,
467
+ session_id=context.session_id,
468
+ state=context.state,
469
+ template_engine=context.template_engine,
470
+ content=kwargs.get("content"),
471
+ **{k: v for k, v in kwargs.items() if k != "content"},
472
+ )
473
+
474
+
475
+ async def handle_extract_handoff_context(
476
+ context: ActionContext, **kwargs: Any
477
+ ) -> dict[str, Any] | None:
478
+ """ActionHandler wrapper for extract_handoff_context."""
479
+ return await asyncio.to_thread(
480
+ extract_handoff_context,
481
+ session_manager=context.session_manager,
482
+ session_id=context.session_id,
483
+ config=context.config,
484
+ db=context.db,
485
+ worktree_manager=kwargs.get("worktree_manager"),
486
+ )
@@ -7,7 +7,7 @@ and update workflow state variables accordingly.
7
7
  """
8
8
 
9
9
  import logging
10
- from typing import TYPE_CHECKING
10
+ from typing import TYPE_CHECKING, Any
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from gobby.hooks.events import HookEvent
@@ -44,30 +44,24 @@ def detect_task_claim(
44
44
  if not event.data:
45
45
  return
46
46
 
47
- tool_name = event.data.get("tool_name", "")
48
47
  tool_input = event.data.get("tool_input", {}) or {}
49
- # Claude Code sends "tool_result", but we also check "tool_output" for compatibility
50
- tool_output = event.data.get("tool_result") or event.data.get("tool_output") or {}
51
-
52
- # Check if this is a gobby-tasks call via MCP proxy
53
- # Tool name could be "call_tool" (from legacy) or "mcp__gobby__call_tool" (direct)
54
- if tool_name not in ("call_tool", "mcp__gobby__call_tool"):
55
- return
48
+ # Use normalized tool_output (adapters normalize tool_result/tool_response)
49
+ tool_output = event.data.get("tool_output") or {}
56
50
 
57
- # Check server is gobby-tasks
58
- server_name = tool_input.get("server_name", "")
51
+ # Use normalized MCP fields from adapter layer
52
+ # Adapters extract these from CLI-specific formats
53
+ server_name = event.data.get("mcp_server", "")
59
54
  if server_name != "gobby-tasks":
60
55
  return
61
56
 
62
- # Check inner tool name
63
- inner_tool_name = tool_input.get("tool_name", "")
57
+ inner_tool_name = event.data.get("mcp_tool", "")
64
58
 
65
59
  # Handle close_task - clears task_claimed when task is closed
66
60
  # Note: Claude Code doesn't include tool_result in post-tool-use hooks, so for CC
67
61
  # the workflow state is updated directly in the MCP proxy's close_task function.
68
62
  # This detection provides a fallback for CLIs that do report tool results (Gemini/Codex).
69
63
  if inner_tool_name == "close_task":
70
- tool_output = event.data.get("tool_result") or event.data.get("tool_output") or {}
64
+ # tool_output already normalized at top of function
71
65
 
72
66
  # If no tool output, skip - can't verify success
73
67
  # The MCP proxy's close_task handles state clearing for successful closes
@@ -254,6 +248,11 @@ def detect_mcp_call(event: "HookEvent", state: "WorkflowState") -> None:
254
248
  This enables workflow conditions like:
255
249
  when: "mcp_called('gobby-memory', 'recall')"
256
250
 
251
+ Uses normalized fields from adapters:
252
+ - mcp_server: The MCP server name (normalized from both Claude and Gemini formats)
253
+ - mcp_tool: The tool name on the server (normalized from both formats)
254
+ - tool_output: The tool result (normalized from tool_result/tool_response)
255
+
257
256
  Args:
258
257
  event: The AFTER_TOOL hook event
259
258
  state: Current workflow state (modified in place)
@@ -261,21 +260,36 @@ def detect_mcp_call(event: "HookEvent", state: "WorkflowState") -> None:
261
260
  if not event.data:
262
261
  return
263
262
 
264
- tool_name = event.data.get("tool_name", "")
265
- tool_input = event.data.get("tool_input", {}) or {}
266
- # Claude Code sends "tool_result", but we also check "tool_output" for compatibility
267
- tool_output = event.data.get("tool_result") or event.data.get("tool_output") or {}
263
+ # Use normalized fields from adapter layer
264
+ # Adapters extract these from CLI-specific formats:
265
+ # - Claude: tool_input.server_name/tool_name mcp_server/mcp_tool
266
+ # - Gemini: mcp_context.server_name/tool_name mcp_server/mcp_tool
267
+ server_name = event.data.get("mcp_server", "")
268
+ inner_tool = event.data.get("mcp_tool", "")
268
269
 
269
- # Check for MCP proxy call
270
- if tool_name not in ("call_tool", "mcp__gobby__call_tool"):
270
+ if not server_name or not inner_tool:
271
271
  return
272
272
 
273
- server_name = tool_input.get("server_name", "")
274
- inner_tool = tool_input.get("tool_name", "")
273
+ # Use normalized tool_output (adapters normalize tool_result/tool_response)
274
+ tool_output = event.data.get("tool_output") or {}
275
275
 
276
- if not server_name or not inner_tool:
277
- return
276
+ _track_mcp_call(state, server_name, inner_tool, tool_output)
277
+
278
+
279
+ def _track_mcp_call(
280
+ state: "WorkflowState",
281
+ server_name: str,
282
+ inner_tool: str,
283
+ tool_output: dict[str, Any] | Any,
284
+ ) -> None:
285
+ """Track a successful MCP call in workflow state.
278
286
 
287
+ Args:
288
+ state: Current workflow state (modified in place)
289
+ server_name: MCP server name (e.g., "gobby-sessions")
290
+ inner_tool: Tool name on the server (e.g., "get_current_session")
291
+ tool_output: Tool output to check for errors
292
+ """
279
293
  # Check if call succeeded (skip tracking failed calls)
280
294
  if isinstance(tool_output, dict):
281
295
  if tool_output.get("error") or tool_output.get("status") == "error":
@@ -0,0 +1,47 @@
1
+ """Task enforcement actions for workflow engine.
2
+
3
+ This package provides actions that enforce task tracking before allowing
4
+ certain tools, and enforce task completion before allowing agent to stop.
5
+ """
6
+
7
+ from gobby.workflows.enforcement.blocking import block_tools
8
+ from gobby.workflows.enforcement.commit_policy import (
9
+ capture_baseline_dirty_files,
10
+ require_commit_before_stop,
11
+ require_task_review_or_close_before_stop,
12
+ )
13
+ from gobby.workflows.enforcement.handlers import (
14
+ handle_block_tools,
15
+ handle_capture_baseline_dirty_files,
16
+ handle_require_active_task,
17
+ handle_require_commit_before_stop,
18
+ handle_require_task_complete,
19
+ handle_require_task_review_or_close_before_stop,
20
+ handle_validate_session_task_scope,
21
+ )
22
+ from gobby.workflows.enforcement.task_policy import (
23
+ require_active_task,
24
+ require_task_complete,
25
+ validate_session_task_scope,
26
+ )
27
+
28
+ __all__ = [
29
+ # Blocking
30
+ "block_tools",
31
+ # Commit policy
32
+ "capture_baseline_dirty_files",
33
+ "require_commit_before_stop",
34
+ "require_task_review_or_close_before_stop",
35
+ # Task policy
36
+ "require_active_task",
37
+ "require_task_complete",
38
+ "validate_session_task_scope",
39
+ # Handlers
40
+ "handle_block_tools",
41
+ "handle_capture_baseline_dirty_files",
42
+ "handle_require_active_task",
43
+ "handle_require_commit_before_stop",
44
+ "handle_require_task_complete",
45
+ "handle_require_task_review_or_close_before_stop",
46
+ "handle_validate_session_task_scope",
47
+ ]
@@ -0,0 +1,281 @@
1
+ """Tool blocking enforcement for workflow engine.
2
+
3
+ Provides configurable tool blocking based on workflow state and conditions.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ from collections.abc import Callable
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from gobby.workflows.git_utils import get_dirty_files
14
+ from gobby.workflows.safe_evaluator import LazyBool, SafeExpressionEvaluator
15
+
16
+ if TYPE_CHECKING:
17
+ from gobby.storage.tasks import LocalTaskManager
18
+ from gobby.workflows.definitions import WorkflowState
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _is_plan_file(file_path: str, source: str | None = None) -> bool:
24
+ """Check if file path is a Claude Code plan file (platform-agnostic).
25
+
26
+ Only exempts plan files for Claude Code sessions to avoid accidental
27
+ exemptions for Gemini/Codex users.
28
+
29
+ The pattern `/.claude/plans/` matches paths like:
30
+ - Unix: /Users/xxx/.claude/plans/file.md (the / comes from xxx/)
31
+ - Windows: C:/Users/xxx/.claude/plans/file.md (after normalization)
32
+
33
+ Args:
34
+ file_path: The file path being edited
35
+ source: CLI source (e.g., "claude", "gemini", "codex")
36
+
37
+ Returns:
38
+ True if this is a CC plan file that should be exempt from task requirement
39
+ """
40
+ if not file_path:
41
+ return False
42
+ # Only exempt for Claude Code sessions
43
+ if source != "claude":
44
+ return False
45
+ # Normalize path separators (Windows backslash to forward slash)
46
+ normalized = file_path.replace("\\", "/")
47
+ return "/.claude/plans/" in normalized
48
+
49
+
50
+ def _evaluate_block_condition(
51
+ condition: str | None,
52
+ workflow_state: WorkflowState | None,
53
+ event_data: dict[str, Any] | None = None,
54
+ tool_input: dict[str, Any] | None = None,
55
+ session_has_dirty_files: LazyBool | bool = False,
56
+ task_has_commits: LazyBool | bool = False,
57
+ source: str | None = None,
58
+ ) -> bool:
59
+ """
60
+ Evaluate a blocking rule condition against workflow state.
61
+
62
+ Supports simple Python expressions with access to:
63
+ - variables: workflow state variables dict
64
+ - task_claimed: shorthand for variables.get('task_claimed')
65
+ - plan_mode: shorthand for variables.get('plan_mode')
66
+ - tool_input: the tool's input arguments (for MCP tool checks)
67
+ - session_has_dirty_files: whether session has NEW dirty files (beyond baseline)
68
+ - task_has_commits: whether the current task has linked commits
69
+ - source: CLI source (e.g., "claude", "gemini", "codex")
70
+
71
+ Args:
72
+ condition: Python expression to evaluate
73
+ workflow_state: Current workflow state
74
+ event_data: Optional hook event data
75
+ tool_input: Tool input arguments (for MCP tools, this is the 'arguments' field)
76
+ session_has_dirty_files: Whether session has dirty files beyond baseline (lazy or bool)
77
+ task_has_commits: Whether claimed task has linked commits (lazy or bool)
78
+ source: CLI source identifier
79
+
80
+ Returns:
81
+ True if condition matches (tool should be blocked), False otherwise.
82
+ """
83
+ if not condition:
84
+ return True # No condition means always match
85
+
86
+ # Build evaluation context
87
+ variables = workflow_state.variables if workflow_state else {}
88
+ context = {
89
+ "variables": variables,
90
+ "task_claimed": variables.get("task_claimed", False),
91
+ "plan_mode": variables.get("plan_mode", False),
92
+ "event": event_data or {},
93
+ "tool_input": tool_input or {},
94
+ "session_has_dirty_files": session_has_dirty_files,
95
+ "task_has_commits": task_has_commits,
96
+ "source": source or "",
97
+ }
98
+
99
+ # Allowed functions for safe evaluation
100
+ allowed_funcs: dict[str, Callable[..., Any]] = {
101
+ "is_plan_file": _is_plan_file,
102
+ "bool": bool,
103
+ "str": str,
104
+ "int": int,
105
+ }
106
+
107
+ try:
108
+ evaluator = SafeExpressionEvaluator(context, allowed_funcs)
109
+ return evaluator.evaluate(condition)
110
+ except Exception as e:
111
+ # Fail-closed: block the tool if condition evaluation fails to prevent bypass
112
+ logger.error(
113
+ f"block_tools condition evaluation failed (blocking tool): condition='{condition}', "
114
+ f"variables={variables}, error={e}",
115
+ exc_info=True,
116
+ )
117
+ return True
118
+
119
+
120
+ async def block_tools(
121
+ rules: list[dict[str, Any]] | None = None,
122
+ event_data: dict[str, Any] | None = None,
123
+ workflow_state: WorkflowState | None = None,
124
+ project_path: str | None = None,
125
+ task_manager: LocalTaskManager | None = None,
126
+ source: str | None = None,
127
+ **kwargs: Any,
128
+ ) -> dict[str, Any] | None:
129
+ """
130
+ Unified tool blocking with multiple configurable rules.
131
+
132
+ Each rule can specify:
133
+ - tools: List of tool names to block (for native CC tools)
134
+ - mcp_tools: List of "server:tool" patterns to block (for MCP tools)
135
+ - when: Optional condition (evaluated against workflow state)
136
+ - reason: Block message to display
137
+
138
+ For MCP tools, the tool_name in event_data is "call_tool" or "mcp__gobby__call_tool",
139
+ and we look inside tool_input for server_name and tool_name.
140
+
141
+ Condition evaluation has access to:
142
+ - variables: workflow state variables
143
+ - task_claimed, plan_mode: shortcuts
144
+ - tool_input: the MCP tool's arguments (for checking commit_sha etc.)
145
+ - session_has_dirty_files: whether session has NEW dirty files beyond baseline
146
+ - task_has_commits: whether the claimed task has linked commits
147
+ - source: CLI source (e.g., "claude", "gemini", "codex")
148
+
149
+ Args:
150
+ rules: List of blocking rules
151
+ event_data: Hook event data with tool_name, tool_input
152
+ workflow_state: For evaluating conditions
153
+ project_path: Path to project for git status checks
154
+ task_manager: For checking task commit status
155
+ source: CLI source identifier (for is_plan_file checks)
156
+
157
+ Returns:
158
+ Dict with decision="block" and reason if blocked, None to allow.
159
+
160
+ Example rule (native tools):
161
+ {
162
+ "tools": ["TaskCreate", "TaskUpdate"],
163
+ "reason": "CC native task tools are disabled. Use gobby-tasks MCP tools."
164
+ }
165
+
166
+ Example rule with condition:
167
+ {
168
+ "tools": ["Edit", "Write", "NotebookEdit"],
169
+ "when": "not task_claimed and not plan_mode",
170
+ "reason": "Claim a task before using Edit, Write, or NotebookEdit tools."
171
+ }
172
+
173
+ Example rule (MCP tools):
174
+ {
175
+ "mcp_tools": ["gobby-tasks:close_task"],
176
+ "when": "not task_has_commits and not tool_input.get('commit_sha')",
177
+ "reason": "A commit is required before closing this task."
178
+ }
179
+ """
180
+ if not event_data or not rules:
181
+ return None
182
+
183
+ tool_name = event_data.get("tool_name")
184
+ if not tool_name:
185
+ return None
186
+
187
+ tool_input = event_data.get("tool_input", {}) or {}
188
+
189
+ # Create lazy thunks for expensive context values (git status, DB queries).
190
+ # These are only evaluated when actually referenced in a rule condition.
191
+
192
+ def _compute_session_has_dirty_files() -> bool:
193
+ """Lazy thunk: check for new dirty files beyond baseline."""
194
+ if not workflow_state:
195
+ return False
196
+ if project_path is None:
197
+ # Can't compute without project_path - avoid running git in wrong directory
198
+ logger.debug("_compute_session_has_dirty_files: project_path is None, returning False")
199
+ return False
200
+ baseline_dirty = set(workflow_state.variables.get("baseline_dirty_files", []))
201
+ current_dirty = get_dirty_files(project_path)
202
+ new_dirty = current_dirty - baseline_dirty
203
+ return len(new_dirty) > 0
204
+
205
+ def _compute_task_has_commits() -> bool:
206
+ """Lazy thunk: check if claimed task has linked commits."""
207
+ if not workflow_state or not task_manager:
208
+ return False
209
+ claimed_task_id = workflow_state.variables.get("claimed_task_id")
210
+ if not claimed_task_id:
211
+ return False
212
+ try:
213
+ task = task_manager.get_task(claimed_task_id)
214
+ return bool(task and task.commits)
215
+ except Exception:
216
+ return False # nosec B110 - best-effort check
217
+
218
+ # Wrap in LazyBool so they're only computed when used in boolean context
219
+ session_has_dirty_files: LazyBool | bool = LazyBool(_compute_session_has_dirty_files)
220
+ task_has_commits: LazyBool | bool = LazyBool(_compute_task_has_commits)
221
+
222
+ for rule in rules:
223
+ # Determine if this rule matches the current tool
224
+ rule_matches = False
225
+ mcp_tool_args: dict[str, Any] = {}
226
+
227
+ # Check native CC tools (Edit, Write, etc.)
228
+ if "tools" in rule:
229
+ tools = rule.get("tools", [])
230
+ if tool_name in tools:
231
+ rule_matches = True
232
+
233
+ # Check MCP tools (server:tool format)
234
+ elif "mcp_tools" in rule:
235
+ # MCP calls come in as "call_tool" or "mcp__gobby__call_tool"
236
+ if tool_name in ("call_tool", "mcp__gobby__call_tool"):
237
+ mcp_server = tool_input.get("server_name", "")
238
+ mcp_tool = tool_input.get("tool_name", "")
239
+ mcp_key = f"{mcp_server}:{mcp_tool}"
240
+
241
+ mcp_tools = rule.get("mcp_tools", [])
242
+ if mcp_key in mcp_tools:
243
+ rule_matches = True
244
+ # For MCP tools, the actual arguments are in tool_input.arguments
245
+ # Arguments may be a JSON string (Claude Code serialization) or dict
246
+ raw_args = tool_input.get("arguments")
247
+ if isinstance(raw_args, str):
248
+ try:
249
+ parsed = json.loads(raw_args)
250
+ mcp_tool_args = parsed if isinstance(parsed, dict) else {}
251
+ except (json.JSONDecodeError, TypeError):
252
+ mcp_tool_args = {}
253
+ elif isinstance(raw_args, dict):
254
+ mcp_tool_args = raw_args
255
+ else:
256
+ mcp_tool_args = {}
257
+
258
+ if not rule_matches:
259
+ continue
260
+
261
+ # Check optional condition
262
+ condition = rule.get("when")
263
+ if condition:
264
+ # For MCP tools, use the nested arguments for condition evaluation
265
+ eval_tool_input = mcp_tool_args if mcp_tool_args else tool_input
266
+ if not _evaluate_block_condition(
267
+ condition,
268
+ workflow_state,
269
+ event_data,
270
+ tool_input=eval_tool_input,
271
+ session_has_dirty_files=session_has_dirty_files,
272
+ task_has_commits=task_has_commits,
273
+ source=source,
274
+ ):
275
+ continue
276
+
277
+ reason = rule.get("reason", f"Tool '{tool_name}' is blocked.")
278
+ logger.info(f"block_tools: Blocking '{tool_name}' - {reason[:100]}")
279
+ return {"decision": "block", "reason": reason}
280
+
281
+ return None