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
@@ -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
+ )
@@ -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,269 @@
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 logging
9
+ from collections.abc import Callable
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from gobby.workflows.git_utils import get_dirty_files
13
+ from gobby.workflows.safe_evaluator import LazyBool, SafeExpressionEvaluator
14
+
15
+ if TYPE_CHECKING:
16
+ from gobby.storage.tasks import LocalTaskManager
17
+ from gobby.workflows.definitions import WorkflowState
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _is_plan_file(file_path: str, source: str | None = None) -> bool:
23
+ """Check if file path is a Claude Code plan file (platform-agnostic).
24
+
25
+ Only exempts plan files for Claude Code sessions to avoid accidental
26
+ exemptions for Gemini/Codex users.
27
+
28
+ The pattern `/.claude/plans/` matches paths like:
29
+ - Unix: /Users/xxx/.claude/plans/file.md (the / comes from xxx/)
30
+ - Windows: C:/Users/xxx/.claude/plans/file.md (after normalization)
31
+
32
+ Args:
33
+ file_path: The file path being edited
34
+ source: CLI source (e.g., "claude", "gemini", "codex")
35
+
36
+ Returns:
37
+ True if this is a CC plan file that should be exempt from task requirement
38
+ """
39
+ if not file_path:
40
+ return False
41
+ # Only exempt for Claude Code sessions
42
+ if source != "claude":
43
+ return False
44
+ # Normalize path separators (Windows backslash to forward slash)
45
+ normalized = file_path.replace("\\", "/")
46
+ return "/.claude/plans/" in normalized
47
+
48
+
49
+ def _evaluate_block_condition(
50
+ condition: str | None,
51
+ workflow_state: WorkflowState | None,
52
+ event_data: dict[str, Any] | None = None,
53
+ tool_input: dict[str, Any] | None = None,
54
+ session_has_dirty_files: LazyBool | bool = False,
55
+ task_has_commits: LazyBool | bool = False,
56
+ source: str | None = None,
57
+ ) -> bool:
58
+ """
59
+ Evaluate a blocking rule condition against workflow state.
60
+
61
+ Supports simple Python expressions with access to:
62
+ - variables: workflow state variables dict
63
+ - task_claimed: shorthand for variables.get('task_claimed')
64
+ - plan_mode: shorthand for variables.get('plan_mode')
65
+ - tool_input: the tool's input arguments (for MCP tool checks)
66
+ - session_has_dirty_files: whether session has NEW dirty files (beyond baseline)
67
+ - task_has_commits: whether the current task has linked commits
68
+ - source: CLI source (e.g., "claude", "gemini", "codex")
69
+
70
+ Args:
71
+ condition: Python expression to evaluate
72
+ workflow_state: Current workflow state
73
+ event_data: Optional hook event data
74
+ tool_input: Tool input arguments (for MCP tools, this is the 'arguments' field)
75
+ session_has_dirty_files: Whether session has dirty files beyond baseline (lazy or bool)
76
+ task_has_commits: Whether claimed task has linked commits (lazy or bool)
77
+ source: CLI source identifier
78
+
79
+ Returns:
80
+ True if condition matches (tool should be blocked), False otherwise.
81
+ """
82
+ if not condition:
83
+ return True # No condition means always match
84
+
85
+ # Build evaluation context
86
+ variables = workflow_state.variables if workflow_state else {}
87
+ context = {
88
+ "variables": variables,
89
+ "task_claimed": variables.get("task_claimed", False),
90
+ "plan_mode": variables.get("plan_mode", False),
91
+ "event": event_data or {},
92
+ "tool_input": tool_input or {},
93
+ "session_has_dirty_files": session_has_dirty_files,
94
+ "task_has_commits": task_has_commits,
95
+ "source": source or "",
96
+ }
97
+
98
+ # Allowed functions for safe evaluation
99
+ allowed_funcs: dict[str, Callable[..., Any]] = {
100
+ "is_plan_file": _is_plan_file,
101
+ "bool": bool,
102
+ "str": str,
103
+ "int": int,
104
+ }
105
+
106
+ try:
107
+ evaluator = SafeExpressionEvaluator(context, allowed_funcs)
108
+ return evaluator.evaluate(condition)
109
+ except Exception as e:
110
+ # Fail-closed: block the tool if condition evaluation fails to prevent bypass
111
+ logger.error(
112
+ f"block_tools condition evaluation failed (blocking tool): condition='{condition}', "
113
+ f"variables={variables}, error={e}",
114
+ exc_info=True,
115
+ )
116
+ return True
117
+
118
+
119
+ async def block_tools(
120
+ rules: list[dict[str, Any]] | None = None,
121
+ event_data: dict[str, Any] | None = None,
122
+ workflow_state: WorkflowState | None = None,
123
+ project_path: str | None = None,
124
+ task_manager: LocalTaskManager | None = None,
125
+ source: str | None = None,
126
+ **kwargs: Any,
127
+ ) -> dict[str, Any] | None:
128
+ """
129
+ Unified tool blocking with multiple configurable rules.
130
+
131
+ Each rule can specify:
132
+ - tools: List of tool names to block (for native CC tools)
133
+ - mcp_tools: List of "server:tool" patterns to block (for MCP tools)
134
+ - when: Optional condition (evaluated against workflow state)
135
+ - reason: Block message to display
136
+
137
+ For MCP tools, the tool_name in event_data is "call_tool" or "mcp__gobby__call_tool",
138
+ and we look inside tool_input for server_name and tool_name.
139
+
140
+ Condition evaluation has access to:
141
+ - variables: workflow state variables
142
+ - task_claimed, plan_mode: shortcuts
143
+ - tool_input: the MCP tool's arguments (for checking commit_sha etc.)
144
+ - session_has_dirty_files: whether session has NEW dirty files beyond baseline
145
+ - task_has_commits: whether the claimed task has linked commits
146
+ - source: CLI source (e.g., "claude", "gemini", "codex")
147
+
148
+ Args:
149
+ rules: List of blocking rules
150
+ event_data: Hook event data with tool_name, tool_input
151
+ workflow_state: For evaluating conditions
152
+ project_path: Path to project for git status checks
153
+ task_manager: For checking task commit status
154
+ source: CLI source identifier (for is_plan_file checks)
155
+
156
+ Returns:
157
+ Dict with decision="block" and reason if blocked, None to allow.
158
+
159
+ Example rule (native tools):
160
+ {
161
+ "tools": ["TaskCreate", "TaskUpdate"],
162
+ "reason": "CC native task tools are disabled. Use gobby-tasks MCP tools."
163
+ }
164
+
165
+ Example rule with condition:
166
+ {
167
+ "tools": ["Edit", "Write", "NotebookEdit"],
168
+ "when": "not task_claimed and not plan_mode",
169
+ "reason": "Claim a task before using Edit, Write, or NotebookEdit tools."
170
+ }
171
+
172
+ Example rule (MCP tools):
173
+ {
174
+ "mcp_tools": ["gobby-tasks:close_task"],
175
+ "when": "not task_has_commits and not tool_input.get('commit_sha')",
176
+ "reason": "A commit is required before closing this task."
177
+ }
178
+ """
179
+ if not event_data or not rules:
180
+ return None
181
+
182
+ tool_name = event_data.get("tool_name")
183
+ if not tool_name:
184
+ return None
185
+
186
+ tool_input = event_data.get("tool_input", {}) or {}
187
+
188
+ # Create lazy thunks for expensive context values (git status, DB queries).
189
+ # These are only evaluated when actually referenced in a rule condition.
190
+
191
+ def _compute_session_has_dirty_files() -> bool:
192
+ """Lazy thunk: check for new dirty files beyond baseline."""
193
+ if not workflow_state:
194
+ return False
195
+ if project_path is None:
196
+ # Can't compute without project_path - avoid running git in wrong directory
197
+ logger.debug("_compute_session_has_dirty_files: project_path is None, returning False")
198
+ return False
199
+ baseline_dirty = set(workflow_state.variables.get("baseline_dirty_files", []))
200
+ current_dirty = get_dirty_files(project_path)
201
+ new_dirty = current_dirty - baseline_dirty
202
+ return len(new_dirty) > 0
203
+
204
+ def _compute_task_has_commits() -> bool:
205
+ """Lazy thunk: check if claimed task has linked commits."""
206
+ if not workflow_state or not task_manager:
207
+ return False
208
+ claimed_task_id = workflow_state.variables.get("claimed_task_id")
209
+ if not claimed_task_id:
210
+ return False
211
+ try:
212
+ task = task_manager.get_task(claimed_task_id)
213
+ return bool(task and task.commits)
214
+ except Exception:
215
+ return False # nosec B110 - best-effort check
216
+
217
+ # Wrap in LazyBool so they're only computed when used in boolean context
218
+ session_has_dirty_files: LazyBool | bool = LazyBool(_compute_session_has_dirty_files)
219
+ task_has_commits: LazyBool | bool = LazyBool(_compute_task_has_commits)
220
+
221
+ for rule in rules:
222
+ # Determine if this rule matches the current tool
223
+ rule_matches = False
224
+ mcp_tool_args: dict[str, Any] = {}
225
+
226
+ # Check native CC tools (Edit, Write, etc.)
227
+ if "tools" in rule:
228
+ tools = rule.get("tools", [])
229
+ if tool_name in tools:
230
+ rule_matches = True
231
+
232
+ # Check MCP tools (server:tool format)
233
+ elif "mcp_tools" in rule:
234
+ # MCP calls come in as "call_tool" or "mcp__gobby__call_tool"
235
+ if tool_name in ("call_tool", "mcp__gobby__call_tool"):
236
+ mcp_server = tool_input.get("server_name", "")
237
+ mcp_tool = tool_input.get("tool_name", "")
238
+ mcp_key = f"{mcp_server}:{mcp_tool}"
239
+
240
+ mcp_tools = rule.get("mcp_tools", [])
241
+ if mcp_key in mcp_tools:
242
+ rule_matches = True
243
+ # For MCP tools, the actual arguments are in tool_input.arguments
244
+ mcp_tool_args = tool_input.get("arguments", {}) or {}
245
+
246
+ if not rule_matches:
247
+ continue
248
+
249
+ # Check optional condition
250
+ condition = rule.get("when")
251
+ if condition:
252
+ # For MCP tools, use the nested arguments for condition evaluation
253
+ eval_tool_input = mcp_tool_args if mcp_tool_args else tool_input
254
+ if not _evaluate_block_condition(
255
+ condition,
256
+ workflow_state,
257
+ event_data,
258
+ tool_input=eval_tool_input,
259
+ session_has_dirty_files=session_has_dirty_files,
260
+ task_has_commits=task_has_commits,
261
+ source=source,
262
+ ):
263
+ continue
264
+
265
+ reason = rule.get("reason", f"Tool '{tool_name}' is blocked.")
266
+ logger.info(f"block_tools: Blocking '{tool_name}' - {reason[:100]}")
267
+ return {"decision": "block", "reason": reason}
268
+
269
+ return None