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
|
@@ -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
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""ActionHandler wrappers for enforcement actions.
|
|
2
|
+
|
|
3
|
+
These handlers match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
|
|
4
|
+
They bridge the workflow engine to the core enforcement functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from gobby.workflows.enforcement.blocking import block_tools
|
|
13
|
+
from gobby.workflows.enforcement.commit_policy import (
|
|
14
|
+
capture_baseline_dirty_files,
|
|
15
|
+
require_commit_before_stop,
|
|
16
|
+
require_task_review_or_close_before_stop,
|
|
17
|
+
)
|
|
18
|
+
from gobby.workflows.enforcement.task_policy import (
|
|
19
|
+
require_active_task,
|
|
20
|
+
require_task_complete,
|
|
21
|
+
validate_session_task_scope,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from gobby.storage.tasks import LocalTaskManager
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"handle_block_tools",
|
|
31
|
+
"handle_capture_baseline_dirty_files",
|
|
32
|
+
"handle_require_active_task",
|
|
33
|
+
"handle_require_commit_before_stop",
|
|
34
|
+
"handle_require_task_complete",
|
|
35
|
+
"handle_require_task_review_or_close_before_stop",
|
|
36
|
+
"handle_validate_session_task_scope",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def handle_capture_baseline_dirty_files(
|
|
41
|
+
context: Any,
|
|
42
|
+
task_manager: LocalTaskManager | None = None,
|
|
43
|
+
**kwargs: Any,
|
|
44
|
+
) -> dict[str, Any] | None:
|
|
45
|
+
"""ActionHandler wrapper for capture_baseline_dirty_files.
|
|
46
|
+
|
|
47
|
+
Note: project_path comes from session's project lookup or event_data.cwd.
|
|
48
|
+
"""
|
|
49
|
+
from gobby.storage.projects import LocalProjectManager
|
|
50
|
+
|
|
51
|
+
# Get project path - prioritize session lookup over hook payload
|
|
52
|
+
project_path = None
|
|
53
|
+
|
|
54
|
+
# 1. Get from session's project (most reliable - session exists by now)
|
|
55
|
+
if context.session_id and context.session_manager:
|
|
56
|
+
session = context.session_manager.get(context.session_id)
|
|
57
|
+
if session and session.project_id:
|
|
58
|
+
project_mgr = LocalProjectManager(context.db)
|
|
59
|
+
project = project_mgr.get(session.project_id)
|
|
60
|
+
if project and project.repo_path:
|
|
61
|
+
project_path = project.repo_path
|
|
62
|
+
|
|
63
|
+
# 2. Fallback to event_data.cwd (from hook payload)
|
|
64
|
+
if not project_path and context.event_data:
|
|
65
|
+
project_path = context.event_data.get("cwd")
|
|
66
|
+
|
|
67
|
+
return await capture_baseline_dirty_files(
|
|
68
|
+
workflow_state=context.state,
|
|
69
|
+
project_path=project_path,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def handle_require_commit_before_stop(
|
|
74
|
+
context: Any,
|
|
75
|
+
task_manager: LocalTaskManager | None = None,
|
|
76
|
+
**kwargs: Any,
|
|
77
|
+
) -> dict[str, Any] | None:
|
|
78
|
+
"""ActionHandler wrapper for require_commit_before_stop.
|
|
79
|
+
|
|
80
|
+
Note: task_manager must be passed via closure from executor.
|
|
81
|
+
"""
|
|
82
|
+
from gobby.storage.projects import LocalProjectManager
|
|
83
|
+
|
|
84
|
+
# Get project path
|
|
85
|
+
project_path = None
|
|
86
|
+
|
|
87
|
+
if context.session_id and context.session_manager:
|
|
88
|
+
session = context.session_manager.get(context.session_id)
|
|
89
|
+
if session and session.project_id:
|
|
90
|
+
project_mgr = LocalProjectManager(context.db)
|
|
91
|
+
project = project_mgr.get(session.project_id)
|
|
92
|
+
if project and project.repo_path:
|
|
93
|
+
project_path = project.repo_path
|
|
94
|
+
|
|
95
|
+
if not project_path and context.event_data:
|
|
96
|
+
project_path = context.event_data.get("cwd")
|
|
97
|
+
|
|
98
|
+
return await require_commit_before_stop(
|
|
99
|
+
workflow_state=context.state,
|
|
100
|
+
project_path=project_path,
|
|
101
|
+
task_manager=task_manager,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def handle_require_task_review_or_close_before_stop(
|
|
106
|
+
context: Any,
|
|
107
|
+
task_manager: LocalTaskManager | None = None,
|
|
108
|
+
**kwargs: Any,
|
|
109
|
+
) -> dict[str, Any] | None:
|
|
110
|
+
"""ActionHandler wrapper for require_task_review_or_close_before_stop."""
|
|
111
|
+
project_id = None
|
|
112
|
+
if context.session_manager:
|
|
113
|
+
session = context.session_manager.get(context.session_id)
|
|
114
|
+
if session:
|
|
115
|
+
project_id = session.project_id
|
|
116
|
+
|
|
117
|
+
return await require_task_review_or_close_before_stop(
|
|
118
|
+
workflow_state=context.state,
|
|
119
|
+
task_manager=task_manager,
|
|
120
|
+
project_id=project_id,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def handle_validate_session_task_scope(
|
|
125
|
+
context: Any,
|
|
126
|
+
task_manager: LocalTaskManager | None = None,
|
|
127
|
+
**kwargs: Any,
|
|
128
|
+
) -> dict[str, Any] | None:
|
|
129
|
+
"""ActionHandler wrapper for validate_session_task_scope."""
|
|
130
|
+
return await validate_session_task_scope(
|
|
131
|
+
task_manager=task_manager,
|
|
132
|
+
workflow_state=context.state,
|
|
133
|
+
event_data=context.event_data,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def handle_block_tools(
|
|
138
|
+
context: Any,
|
|
139
|
+
task_manager: LocalTaskManager | None = None,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> dict[str, Any] | None:
|
|
142
|
+
"""ActionHandler wrapper for block_tools.
|
|
143
|
+
|
|
144
|
+
Passes task_manager via closure from register_defaults.
|
|
145
|
+
"""
|
|
146
|
+
from gobby.storage.projects import LocalProjectManager
|
|
147
|
+
|
|
148
|
+
# Get project_path for git dirty file checks
|
|
149
|
+
project_path = kwargs.get("project_path")
|
|
150
|
+
if not project_path and context.event_data:
|
|
151
|
+
project_path = context.event_data.get("cwd")
|
|
152
|
+
|
|
153
|
+
# Get source from session for is_plan_file checks
|
|
154
|
+
source = None
|
|
155
|
+
current_session = None
|
|
156
|
+
if context.session_manager:
|
|
157
|
+
current_session = context.session_manager.get(context.session_id)
|
|
158
|
+
if current_session:
|
|
159
|
+
source = current_session.source
|
|
160
|
+
|
|
161
|
+
# Fallback to session's project path
|
|
162
|
+
if not project_path and current_session and context.db:
|
|
163
|
+
project_mgr = LocalProjectManager(context.db)
|
|
164
|
+
project = project_mgr.get(current_session.project_id)
|
|
165
|
+
if project and project.repo_path:
|
|
166
|
+
project_path = project.repo_path
|
|
167
|
+
|
|
168
|
+
return await block_tools(
|
|
169
|
+
rules=kwargs.get("rules"),
|
|
170
|
+
event_data=context.event_data,
|
|
171
|
+
workflow_state=context.state,
|
|
172
|
+
project_path=project_path,
|
|
173
|
+
task_manager=task_manager,
|
|
174
|
+
source=source,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def handle_require_active_task(
|
|
179
|
+
context: Any,
|
|
180
|
+
task_manager: LocalTaskManager | None = None,
|
|
181
|
+
**kwargs: Any,
|
|
182
|
+
) -> dict[str, Any] | None:
|
|
183
|
+
"""ActionHandler wrapper for require_active_task.
|
|
184
|
+
|
|
185
|
+
DEPRECATED: Use block_tools action with rules instead.
|
|
186
|
+
Kept for backward compatibility with existing workflows.
|
|
187
|
+
"""
|
|
188
|
+
# Get project_id from session for project-scoped task filtering
|
|
189
|
+
current_session = None
|
|
190
|
+
project_id = None
|
|
191
|
+
if context.session_manager:
|
|
192
|
+
current_session = context.session_manager.get(context.session_id)
|
|
193
|
+
if current_session:
|
|
194
|
+
project_id = current_session.project_id
|
|
195
|
+
|
|
196
|
+
return await require_active_task(
|
|
197
|
+
task_manager=task_manager,
|
|
198
|
+
session_id=context.session_id,
|
|
199
|
+
config=context.config,
|
|
200
|
+
event_data=context.event_data,
|
|
201
|
+
project_id=project_id,
|
|
202
|
+
workflow_state=context.state,
|
|
203
|
+
session_manager=context.session_manager,
|
|
204
|
+
session_task_manager=context.session_task_manager,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def handle_require_task_complete(
|
|
209
|
+
context: Any,
|
|
210
|
+
task_manager: LocalTaskManager | None = None,
|
|
211
|
+
template_engine: Any | None = None,
|
|
212
|
+
**kwargs: Any,
|
|
213
|
+
) -> dict[str, Any] | None:
|
|
214
|
+
"""ActionHandler wrapper for require_task_complete.
|
|
215
|
+
|
|
216
|
+
Supports:
|
|
217
|
+
- Single task ID: "#47"
|
|
218
|
+
- List of task IDs: ["#47", "#48"]
|
|
219
|
+
- Wildcard: "*" - work until no ready tasks remain
|
|
220
|
+
"""
|
|
221
|
+
project_id = None
|
|
222
|
+
if context.session_manager and context.session_id:
|
|
223
|
+
session = context.session_manager.get(context.session_id)
|
|
224
|
+
if session:
|
|
225
|
+
project_id = session.project_id
|
|
226
|
+
|
|
227
|
+
# Get task_id from kwargs - may be a template that needs resolving
|
|
228
|
+
task_spec = kwargs.get("task_id")
|
|
229
|
+
|
|
230
|
+
# If it's a template reference like "{{ variables.session_task }}", resolve it
|
|
231
|
+
if task_spec and "{{" in str(task_spec) and template_engine:
|
|
232
|
+
task_spec = template_engine.render(
|
|
233
|
+
str(task_spec),
|
|
234
|
+
{"variables": context.state.variables if context.state else {}},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Handle different task_spec types:
|
|
238
|
+
# - None/empty: no enforcement
|
|
239
|
+
# - "*": wildcard - fetch ready tasks
|
|
240
|
+
# - list: multiple specific tasks
|
|
241
|
+
# - string: single task ID
|
|
242
|
+
task_ids: list[str] | None = None
|
|
243
|
+
|
|
244
|
+
if not task_spec:
|
|
245
|
+
return None
|
|
246
|
+
elif task_spec == "*":
|
|
247
|
+
# Wildcard: get all ready tasks for this project
|
|
248
|
+
if task_manager:
|
|
249
|
+
ready_tasks = task_manager.list_ready_tasks(
|
|
250
|
+
project_id=project_id,
|
|
251
|
+
limit=100,
|
|
252
|
+
)
|
|
253
|
+
task_ids = [t.id for t in ready_tasks]
|
|
254
|
+
if not task_ids:
|
|
255
|
+
# No ready tasks - allow stop
|
|
256
|
+
return None
|
|
257
|
+
elif isinstance(task_spec, list):
|
|
258
|
+
task_ids = task_spec
|
|
259
|
+
else:
|
|
260
|
+
task_ids = [str(task_spec)]
|
|
261
|
+
|
|
262
|
+
return await require_task_complete(
|
|
263
|
+
task_manager=task_manager,
|
|
264
|
+
session_id=context.session_id,
|
|
265
|
+
task_ids=task_ids,
|
|
266
|
+
event_data=context.event_data,
|
|
267
|
+
project_id=project_id,
|
|
268
|
+
workflow_state=context.state,
|
|
269
|
+
)
|