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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +96 -35
- 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/adapters/gemini.py +140 -38
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +525 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +415 -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/macos.py +26 -1
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +0 -2
- 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/memory.py +185 -0
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/clones/git.py +177 -0
- 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/skills.py +31 -0
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +150 -8
- gobby/hooks/hook_manager.py +21 -3
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +4 -2
- gobby/mcp_proxy/registries.py +22 -8
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +76 -740
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- 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 +239 -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 +503 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +455 -0
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -350
- gobby/memory/extractor.py +15 -1
- 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/runner.py +13 -0
- 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 +51 -4
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/manager.py +9 -0
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +30 -2
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +174 -368
- gobby/storage/sessions.py +45 -7
- gobby/storage/skills.py +80 -7
- 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/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +281 -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/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +91 -0
- gobby/workflows/safe_evaluator.py +191 -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 +217 -51
- 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.8.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/cli/tui.py +0 -34
- 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/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {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
|
-
#
|
|
50
|
-
tool_output = event.data.get("
|
|
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
|
-
#
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
# Claude
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
273
|
+
# Use normalized tool_output (adapters normalize tool_result/tool_response)
|
|
274
|
+
tool_output = event.data.get("tool_output") or {}
|
|
275
275
|
|
|
276
|
-
|
|
277
|
-
|
|
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
|