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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +45 -2
- gobby/hooks/hook_manager.py +2 -2
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +2 -0
- gobby/mcp_proxy/registries.py +1 -4
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -343
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/skills/parser.py +30 -2
- gobby/storage/migrations.py +159 -372
- gobby/storage/sessions.py +43 -7
- gobby/storage/skills.py +37 -4
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +80 -0
- gobby/workflows/safe_evaluator.py +183 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +94 -1
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/mcp_proxy/tools/session_messages.py +0 -1055
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {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
|