gobby 0.2.5__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/claude_code.py +13 -4
- 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/runner.py +8 -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/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +15 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +8 -8
- gobby/cli/installers/shared.py +175 -13
- gobby/cli/sessions.py +1 -1
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +12 -5
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +69 -91
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +9 -41
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +188 -2
- gobby/hooks/hook_manager.py +50 -4
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/skill_manager.py +130 -0
- gobby/hooks/webhooks.py +1 -1
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +98 -35
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +56 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -8
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +518 -0
- gobby/mcp_proxy/tools/memory.py +3 -26
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- 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 +616 -0
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -338
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +73 -285
- 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 +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +24 -12
- gobby/servers/routes/admin.py +294 -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 -1317
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +2 -0
- gobby/sessions/transcripts/claude.py +79 -10
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +286 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +162 -201
- gobby/storage/sessions.py +116 -7
- gobby/storage/skills.py +782 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +57 -7
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +40 -5
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +46 -35
- gobby/tools/summarizer.py +91 -10
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1135
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +93 -1
- gobby/workflows/detection_helpers.py +115 -31
- 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/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
- gobby/workflows/engine.py +13 -2
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/loader.py +19 -6
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +154 -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 +111 -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.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1292
- 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/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/session_messages.py +0 -1056
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- 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/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/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Commit policy enforcement for workflow engine.
|
|
2
|
+
|
|
3
|
+
Provides actions that enforce commit requirements before stopping or closing tasks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from gobby.workflows.git_utils import get_dirty_files
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from gobby.storage.tasks import LocalTaskManager
|
|
15
|
+
from gobby.workflows.definitions import WorkflowState
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def capture_baseline_dirty_files(
|
|
21
|
+
workflow_state: WorkflowState | None,
|
|
22
|
+
project_path: str | None = None,
|
|
23
|
+
) -> dict[str, Any] | None:
|
|
24
|
+
"""
|
|
25
|
+
Capture current dirty files as baseline for session-aware detection.
|
|
26
|
+
|
|
27
|
+
Called on session_start to record pre-existing dirty files. The
|
|
28
|
+
require_commit_before_stop action will compare against this baseline
|
|
29
|
+
to detect only NEW dirty files made during the session.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
workflow_state: Workflow state to store baseline in
|
|
33
|
+
project_path: Path to the project directory for git status check
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Dict with captured baseline info, or None if no workflow_state
|
|
37
|
+
"""
|
|
38
|
+
if not workflow_state:
|
|
39
|
+
logger.debug("capture_baseline_dirty_files: No workflow_state, skipping")
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
dirty_files = get_dirty_files(project_path)
|
|
43
|
+
|
|
44
|
+
# Store as a list in workflow state (sets aren't JSON serializable)
|
|
45
|
+
workflow_state.variables["baseline_dirty_files"] = list(dirty_files)
|
|
46
|
+
|
|
47
|
+
# Log for debugging baseline capture issues
|
|
48
|
+
files_preview = list(dirty_files)[:5]
|
|
49
|
+
logger.info(
|
|
50
|
+
f"capture_baseline_dirty_files: project_path={project_path}, "
|
|
51
|
+
f"captured {len(dirty_files)} files: {files_preview}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"baseline_captured": True,
|
|
56
|
+
"file_count": len(dirty_files),
|
|
57
|
+
"files": list(dirty_files),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def require_commit_before_stop(
|
|
62
|
+
workflow_state: WorkflowState | None,
|
|
63
|
+
project_path: str | None = None,
|
|
64
|
+
task_manager: LocalTaskManager | None = None,
|
|
65
|
+
) -> dict[str, Any] | None:
|
|
66
|
+
"""
|
|
67
|
+
Block stop if there's an in_progress task with uncommitted changes.
|
|
68
|
+
|
|
69
|
+
This action is designed for on_stop triggers to enforce that agents
|
|
70
|
+
commit their work and close tasks before stopping.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
workflow_state: Workflow state with variables (claimed_task_id, etc.)
|
|
74
|
+
project_path: Path to the project directory for git status check
|
|
75
|
+
task_manager: LocalTaskManager to verify task status
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dict with decision="block" and reason if task has uncommitted changes,
|
|
79
|
+
or None to allow the stop.
|
|
80
|
+
"""
|
|
81
|
+
if not workflow_state:
|
|
82
|
+
logger.debug("require_commit_before_stop: No workflow_state, allowing")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
claimed_task_id = workflow_state.variables.get("claimed_task_id")
|
|
86
|
+
if not claimed_task_id:
|
|
87
|
+
logger.debug("require_commit_before_stop: No claimed task, allowing")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# Verify the task is actually still in_progress (not just cached in workflow state)
|
|
91
|
+
if task_manager:
|
|
92
|
+
task = task_manager.get_task(claimed_task_id)
|
|
93
|
+
if not task or task.status != "in_progress":
|
|
94
|
+
# Task was changed - clear the stale workflow state
|
|
95
|
+
logger.debug(
|
|
96
|
+
f"require_commit_before_stop: Task '{claimed_task_id}' is no longer "
|
|
97
|
+
f"in_progress (status={task.status if task else 'not found'}), clearing state"
|
|
98
|
+
)
|
|
99
|
+
workflow_state.variables["claimed_task_id"] = None
|
|
100
|
+
workflow_state.variables["task_claimed"] = False
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Check for uncommitted changes using baseline-aware comparison
|
|
104
|
+
current_dirty = get_dirty_files(project_path)
|
|
105
|
+
|
|
106
|
+
if not current_dirty:
|
|
107
|
+
logger.debug("require_commit_before_stop: No uncommitted changes, allowing")
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
# Get baseline dirty files captured at session start
|
|
111
|
+
baseline_dirty = set(workflow_state.variables.get("baseline_dirty_files", []))
|
|
112
|
+
|
|
113
|
+
# Calculate NEW dirty files (not in baseline)
|
|
114
|
+
new_dirty = current_dirty - baseline_dirty
|
|
115
|
+
|
|
116
|
+
if not new_dirty:
|
|
117
|
+
logger.debug(
|
|
118
|
+
f"require_commit_before_stop: All {len(current_dirty)} dirty files were pre-existing "
|
|
119
|
+
f"(in baseline), allowing"
|
|
120
|
+
)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
logger.debug(
|
|
124
|
+
f"require_commit_before_stop: Found {len(new_dirty)} new dirty files "
|
|
125
|
+
f"(baseline had {len(baseline_dirty)}, current has {len(current_dirty)})"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Track how many times we've blocked to prevent infinite loops
|
|
129
|
+
block_count = workflow_state.variables.get("_commit_block_count", 0)
|
|
130
|
+
if block_count >= 3:
|
|
131
|
+
logger.warning(
|
|
132
|
+
f"require_commit_before_stop: Reached max block count ({block_count}), allowing"
|
|
133
|
+
)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
workflow_state.variables["_commit_block_count"] = block_count + 1
|
|
137
|
+
|
|
138
|
+
# Block - agent needs to commit and close
|
|
139
|
+
logger.info(
|
|
140
|
+
f"require_commit_before_stop: Blocking stop - task '{claimed_task_id}' "
|
|
141
|
+
f"has {len(new_dirty)} uncommitted changes"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Build list of new dirty files for the message (limit to 10 for readability)
|
|
145
|
+
new_dirty_list = sorted(new_dirty)[:10]
|
|
146
|
+
files_display = "\n".join(f" - {f}" for f in new_dirty_list)
|
|
147
|
+
if len(new_dirty) > 10:
|
|
148
|
+
files_display += f"\n ... and {len(new_dirty) - 10} more files"
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"decision": "block",
|
|
152
|
+
"reason": (
|
|
153
|
+
f"Task '{claimed_task_id}' is in_progress with {len(new_dirty)} uncommitted "
|
|
154
|
+
f"changes made during this session:\n{files_display}\n\n"
|
|
155
|
+
f"Before stopping, commit your changes and close the task:\n"
|
|
156
|
+
f"1. Commit with [{claimed_task_id}] in the message\n"
|
|
157
|
+
f'2. Close the task: close_task(task_id="{claimed_task_id}", commit_sha="...")'
|
|
158
|
+
),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def require_task_review_or_close_before_stop(
|
|
163
|
+
workflow_state: WorkflowState | None,
|
|
164
|
+
task_manager: LocalTaskManager | None = None,
|
|
165
|
+
project_id: str | None = None,
|
|
166
|
+
**kwargs: Any,
|
|
167
|
+
) -> dict[str, Any] | None:
|
|
168
|
+
"""Block stop if session has an in_progress task.
|
|
169
|
+
|
|
170
|
+
Agents must close their task (or send to review) before stopping.
|
|
171
|
+
The close_task() validation already requires a commit, so we don't
|
|
172
|
+
need to check for uncommitted changes here - that's handled by
|
|
173
|
+
require_commit_before_stop if needed.
|
|
174
|
+
|
|
175
|
+
Checks both:
|
|
176
|
+
1. claimed_task_id - task explicitly claimed via update_task(status="in_progress")
|
|
177
|
+
2. session_task - task(s) assigned via set_variable (fallback if no claimed_task_id)
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
workflow_state: Workflow state with variables (claimed_task_id, etc.)
|
|
181
|
+
task_manager: LocalTaskManager to verify task status
|
|
182
|
+
project_id: Project ID for resolving task references (#N, N formats)
|
|
183
|
+
**kwargs: Accepts additional kwargs for compatibility
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Dict with decision="block" and reason if task is still in_progress,
|
|
187
|
+
or None to allow the stop.
|
|
188
|
+
"""
|
|
189
|
+
if not workflow_state:
|
|
190
|
+
logger.debug("require_task_review_or_close_before_stop: No workflow_state, allowing")
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# 1. Check claimed_task_id first (existing behavior)
|
|
194
|
+
claimed_task_id = workflow_state.variables.get("claimed_task_id")
|
|
195
|
+
|
|
196
|
+
# 2. If no claimed task, fall back to session_task
|
|
197
|
+
if not claimed_task_id and task_manager:
|
|
198
|
+
session_task = workflow_state.variables.get("session_task")
|
|
199
|
+
if session_task and session_task != "*":
|
|
200
|
+
# Normalize to list
|
|
201
|
+
task_ids = [session_task] if isinstance(session_task, str) else session_task
|
|
202
|
+
|
|
203
|
+
if isinstance(task_ids, list):
|
|
204
|
+
for task_id in task_ids:
|
|
205
|
+
try:
|
|
206
|
+
task = task_manager.get_task(task_id, project_id=project_id)
|
|
207
|
+
except ValueError:
|
|
208
|
+
continue
|
|
209
|
+
if task and task.status == "in_progress":
|
|
210
|
+
claimed_task_id = task_id
|
|
211
|
+
logger.debug(
|
|
212
|
+
f"require_task_review_or_close_before_stop: Found in_progress "
|
|
213
|
+
f"session_task '{task_id}'"
|
|
214
|
+
)
|
|
215
|
+
break
|
|
216
|
+
# Also check subtasks
|
|
217
|
+
if task:
|
|
218
|
+
subtasks = task_manager.list_tasks(parent_task_id=task.id)
|
|
219
|
+
for subtask in subtasks:
|
|
220
|
+
if subtask.status == "in_progress":
|
|
221
|
+
claimed_task_id = subtask.id
|
|
222
|
+
logger.debug(
|
|
223
|
+
f"require_task_review_or_close_before_stop: Found in_progress "
|
|
224
|
+
f"subtask '{subtask.id}' under session_task '{task_id}'"
|
|
225
|
+
)
|
|
226
|
+
break
|
|
227
|
+
if claimed_task_id:
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
if not claimed_task_id:
|
|
231
|
+
logger.debug("require_task_review_or_close_before_stop: No claimed task, allowing")
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
if not task_manager:
|
|
235
|
+
logger.debug("require_task_review_or_close_before_stop: No task_manager, allowing")
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
task = task_manager.get_task(claimed_task_id, project_id=project_id)
|
|
240
|
+
if not task:
|
|
241
|
+
# Task not found - clear stale workflow state and allow
|
|
242
|
+
logger.debug(
|
|
243
|
+
f"require_task_review_or_close_before_stop: Task '{claimed_task_id}' not found, "
|
|
244
|
+
f"clearing state"
|
|
245
|
+
)
|
|
246
|
+
workflow_state.variables["claimed_task_id"] = None
|
|
247
|
+
workflow_state.variables["task_claimed"] = False
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
if task.status != "in_progress":
|
|
251
|
+
# Task is closed or in review - allow stop
|
|
252
|
+
logger.debug(
|
|
253
|
+
f"require_task_review_or_close_before_stop: Task '{claimed_task_id}' "
|
|
254
|
+
f"status={task.status}, allowing"
|
|
255
|
+
)
|
|
256
|
+
# Clear stale workflow state
|
|
257
|
+
workflow_state.variables["claimed_task_id"] = None
|
|
258
|
+
workflow_state.variables["task_claimed"] = False
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
# Task is still in_progress - block the stop
|
|
262
|
+
task_ref = f"#{task.seq_num}" if task.seq_num else task.id[:8]
|
|
263
|
+
logger.info(
|
|
264
|
+
f"require_task_review_or_close_before_stop: Blocking stop - task "
|
|
265
|
+
f"{task_ref} is still in_progress"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
"decision": "block",
|
|
270
|
+
"reason": (
|
|
271
|
+
f"\nTask {task_ref} is still in_progress. "
|
|
272
|
+
f"Close it with close_task() before stopping."
|
|
273
|
+
),
|
|
274
|
+
"task_id": claimed_task_id,
|
|
275
|
+
"task_status": task.status,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.warning(
|
|
280
|
+
f"require_task_review_or_close_before_stop: Failed to check task status: {e}"
|
|
281
|
+
)
|
|
282
|
+
# Allow stop if we can't check - don't block on errors
|
|
283
|
+
return None
|