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.
Files changed (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -1,15 +1,15 @@
1
- """
2
- Task enforcement actions for workflow engine.
1
+ """Task policy enforcement for workflow engine.
3
2
 
4
- Provides actions that enforce task tracking before allowing certain tools,
5
- and enforce task completion before allowing agent to stop.
3
+ Provides actions that enforce task tracking and scoping requirements.
6
4
  """
7
5
 
6
+ from __future__ import annotations
7
+
8
8
  import logging
9
- import subprocess # nosec B404 - subprocess needed for git commands
10
9
  from typing import TYPE_CHECKING, Any
11
10
 
12
11
  from gobby.mcp_proxy.tools.task_readiness import is_descendant_of
12
+ from gobby.workflows.git_utils import get_task_session_liveness
13
13
 
14
14
  if TYPE_CHECKING:
15
15
  from gobby.config.app import DaemonConfig
@@ -21,378 +21,13 @@ if TYPE_CHECKING:
21
21
  logger = logging.getLogger(__name__)
22
22
 
23
23
 
24
- def _get_dirty_files(project_path: str | None = None) -> set[str]:
25
- """
26
- Get the set of dirty files from git status --porcelain.
27
-
28
- Excludes .gobby/ files from the result.
29
-
30
- Args:
31
- project_path: Path to the project directory
32
-
33
- Returns:
34
- Set of dirty file paths (relative to repo root)
35
- """
36
- if project_path is None:
37
- logger.warning(
38
- "_get_dirty_files: project_path is None, git status will use daemon's cwd "
39
- "which may not be the project directory"
40
- )
41
-
42
- try:
43
- result = subprocess.run( # nosec B603 B607 - hardcoded git command
44
- ["git", "status", "--porcelain"],
45
- cwd=project_path,
46
- capture_output=True,
47
- text=True,
48
- timeout=10,
49
- )
50
-
51
- if result.returncode != 0:
52
- logger.warning(f"_get_dirty_files: git status failed: {result.stderr}")
53
- return set()
54
-
55
- dirty_files = set()
56
- # Split by newline first, don't strip() the whole string as it removes
57
- # the leading space from git status format (e.g., " M file.py")
58
- for line in result.stdout.split("\n"):
59
- line = line.rstrip() # Remove trailing whitespace only
60
- if not line:
61
- continue
62
- # Format is "XY filename" or "XY filename -> newname" for renames
63
- # Skip the status prefix (first 3 chars: 2 status chars + space)
64
- filepath = line[3:].split(" -> ")[0] # Handle renames
65
- # Exclude .gobby/ files
66
- if not filepath.startswith(".gobby/"):
67
- dirty_files.add(filepath)
68
-
69
- return dirty_files
70
-
71
- except subprocess.TimeoutExpired:
72
- logger.warning("_get_dirty_files: git status timed out")
73
- return set()
74
- except FileNotFoundError:
75
- logger.warning("_get_dirty_files: git not found")
76
- return set()
77
- except Exception as e:
78
- logger.error(f"_get_dirty_files: Error running git status: {e}")
79
- return set()
80
-
81
-
82
- def _get_task_session_liveness(
83
- task_id: str,
84
- session_task_manager: "SessionTaskManager | None",
85
- session_manager: "LocalSessionManager | None",
86
- exclude_session_id: str | None = None,
87
- ) -> bool:
88
- """
89
- Check if a task is currently being worked on by an active session.
90
-
91
- Args:
92
- task_id: The task ID to check
93
- session_task_manager: Manager to look up session-task links
94
- session_manager: Manager to check session status
95
- exclude_session_id: ID of session to exclude from check (e.g. current one)
96
-
97
- Returns:
98
- True if an active session (status='active') is linked to this task.
99
- """
100
- if not session_task_manager or not session_manager:
101
- return False
102
-
103
- try:
104
- # Get all sessions linked to this task
105
- linked_sessions = session_task_manager.get_task_sessions(task_id)
106
-
107
- for link in linked_sessions:
108
- session_id = link.get("session_id")
109
- if not session_id or session_id == exclude_session_id:
110
- continue
111
-
112
- # Check if session is truly active
113
- session = session_manager.get(session_id)
114
- if session and session.status == "active":
115
- return True
116
-
117
- return False
118
- except Exception as e:
119
- logger.warning(f"_get_task_session_liveness: Error checking liveness for {task_id}: {e}")
120
- return False
121
-
122
-
123
- async def capture_baseline_dirty_files(
124
- workflow_state: "WorkflowState | None",
125
- project_path: str | None = None,
126
- ) -> dict[str, Any] | None:
127
- """
128
- Capture current dirty files as baseline for session-aware detection.
129
-
130
- Called on session_start to record pre-existing dirty files. The
131
- require_commit_before_stop action will compare against this baseline
132
- to detect only NEW dirty files made during the session.
133
-
134
- Args:
135
- workflow_state: Workflow state to store baseline in
136
- project_path: Path to the project directory for git status check
137
-
138
- Returns:
139
- Dict with captured baseline info, or None if no workflow_state
140
- """
141
- if not workflow_state:
142
- logger.debug("capture_baseline_dirty_files: No workflow_state, skipping")
143
- return None
144
-
145
- dirty_files = _get_dirty_files(project_path)
146
-
147
- # Store as a list in workflow state (sets aren't JSON serializable)
148
- workflow_state.variables["baseline_dirty_files"] = list(dirty_files)
149
-
150
- # Log for debugging baseline capture issues
151
- files_preview = list(dirty_files)[:5]
152
- logger.info(
153
- f"capture_baseline_dirty_files: project_path={project_path}, "
154
- f"captured {len(dirty_files)} files: {files_preview}"
155
- )
156
-
157
- return {
158
- "baseline_captured": True,
159
- "file_count": len(dirty_files),
160
- "files": list(dirty_files),
161
- }
162
-
163
-
164
- async def require_commit_before_stop(
165
- workflow_state: "WorkflowState | None",
166
- project_path: str | None = None,
167
- task_manager: "LocalTaskManager | None" = None,
168
- ) -> dict[str, Any] | None:
169
- """
170
- Block stop if there's an in_progress task with uncommitted changes.
171
-
172
- This action is designed for on_stop triggers to enforce that agents
173
- commit their work and close tasks before stopping.
174
-
175
- Args:
176
- workflow_state: Workflow state with variables (claimed_task_id, etc.)
177
- project_path: Path to the project directory for git status check
178
- task_manager: LocalTaskManager to verify task status
179
-
180
- Returns:
181
- Dict with decision="block" and reason if task has uncommitted changes,
182
- or None to allow the stop.
183
- """
184
- if not workflow_state:
185
- logger.debug("require_commit_before_stop: No workflow_state, allowing")
186
- return None
187
-
188
- claimed_task_id = workflow_state.variables.get("claimed_task_id")
189
- if not claimed_task_id:
190
- logger.debug("require_commit_before_stop: No claimed task, allowing")
191
- return None
192
-
193
- # Verify the task is actually still in_progress (not just cached in workflow state)
194
- if task_manager:
195
- task = task_manager.get_task(claimed_task_id)
196
- if not task or task.status != "in_progress":
197
- # Task was changed - clear the stale workflow state
198
- logger.debug(
199
- f"require_commit_before_stop: Task '{claimed_task_id}' is no longer "
200
- f"in_progress (status={task.status if task else 'not found'}), clearing state"
201
- )
202
- workflow_state.variables["claimed_task_id"] = None
203
- workflow_state.variables["task_claimed"] = False
204
- return None
205
-
206
- # Check for uncommitted changes using baseline-aware comparison
207
- current_dirty = _get_dirty_files(project_path)
208
-
209
- if not current_dirty:
210
- logger.debug("require_commit_before_stop: No uncommitted changes, allowing")
211
- return None
212
-
213
- # Get baseline dirty files captured at session start
214
- baseline_dirty = set(workflow_state.variables.get("baseline_dirty_files", []))
215
-
216
- # Calculate NEW dirty files (not in baseline)
217
- new_dirty = current_dirty - baseline_dirty
218
-
219
- if not new_dirty:
220
- logger.debug(
221
- f"require_commit_before_stop: All {len(current_dirty)} dirty files were pre-existing "
222
- f"(in baseline), allowing"
223
- )
224
- return None
225
-
226
- logger.debug(
227
- f"require_commit_before_stop: Found {len(new_dirty)} new dirty files "
228
- f"(baseline had {len(baseline_dirty)}, current has {len(current_dirty)})"
229
- )
230
-
231
- # Track how many times we've blocked to prevent infinite loops
232
- block_count = workflow_state.variables.get("_commit_block_count", 0)
233
- if block_count >= 3:
234
- logger.warning(
235
- f"require_commit_before_stop: Reached max block count ({block_count}), allowing"
236
- )
237
- return None
238
-
239
- workflow_state.variables["_commit_block_count"] = block_count + 1
240
-
241
- # Block - agent needs to commit and close
242
- logger.info(
243
- f"require_commit_before_stop: Blocking stop - task '{claimed_task_id}' "
244
- f"has {len(new_dirty)} uncommitted changes"
245
- )
246
-
247
- # Build list of new dirty files for the message (limit to 10 for readability)
248
- new_dirty_list = sorted(new_dirty)[:10]
249
- files_display = "\n".join(f" - {f}" for f in new_dirty_list)
250
- if len(new_dirty) > 10:
251
- files_display += f"\n ... and {len(new_dirty) - 10} more files"
252
-
253
- return {
254
- "decision": "block",
255
- "reason": (
256
- f"Task '{claimed_task_id}' is in_progress with {len(new_dirty)} uncommitted "
257
- f"changes made during this session:\n{files_display}\n\n"
258
- f"Before stopping, commit your changes and close the task:\n"
259
- f"1. Commit with [{claimed_task_id}] in the message\n"
260
- f'2. Close the task: close_task(task_id="{claimed_task_id}", commit_sha="...")'
261
- ),
262
- }
263
-
264
-
265
- async def require_task_review_or_close_before_stop(
266
- workflow_state: "WorkflowState | None",
267
- task_manager: "LocalTaskManager | None" = None,
268
- project_id: str | None = None,
269
- **kwargs: Any,
270
- ) -> dict[str, Any] | None:
271
- """Block stop if session has an in_progress task.
272
-
273
- Agents must close their task (or send to review) before stopping.
274
- The close_task() validation already requires a commit, so we don't
275
- need to check for uncommitted changes here - that's handled by
276
- require_commit_before_stop if needed.
277
-
278
- Checks both:
279
- 1. claimed_task_id - task explicitly claimed via update_task(status="in_progress")
280
- 2. session_task - task(s) assigned via set_variable (fallback if no claimed_task_id)
281
-
282
- Args:
283
- workflow_state: Workflow state with variables (claimed_task_id, etc.)
284
- task_manager: LocalTaskManager to verify task status
285
- project_id: Project ID for resolving task references (#N, N formats)
286
- **kwargs: Accepts additional kwargs for compatibility
287
-
288
- Returns:
289
- Dict with decision="block" and reason if task is still in_progress,
290
- or None to allow the stop.
291
- """
292
- if not workflow_state:
293
- logger.debug("require_task_review_or_close_before_stop: No workflow_state, allowing")
294
- return None
295
-
296
- # 1. Check claimed_task_id first (existing behavior)
297
- claimed_task_id = workflow_state.variables.get("claimed_task_id")
298
-
299
- # 2. If no claimed task, fall back to session_task
300
- if not claimed_task_id and task_manager:
301
- session_task = workflow_state.variables.get("session_task")
302
- if session_task and session_task != "*":
303
- # Normalize to list
304
- task_ids = [session_task] if isinstance(session_task, str) else session_task
305
-
306
- if isinstance(task_ids, list):
307
- for task_id in task_ids:
308
- try:
309
- task = task_manager.get_task(task_id, project_id=project_id)
310
- except ValueError:
311
- continue
312
- if task and task.status == "in_progress":
313
- claimed_task_id = task_id
314
- logger.debug(
315
- f"require_task_review_or_close_before_stop: Found in_progress "
316
- f"session_task '{task_id}'"
317
- )
318
- break
319
- # Also check subtasks
320
- if task:
321
- subtasks = task_manager.list_tasks(parent_task_id=task.id)
322
- for subtask in subtasks:
323
- if subtask.status == "in_progress":
324
- claimed_task_id = subtask.id
325
- logger.debug(
326
- f"require_task_review_or_close_before_stop: Found in_progress "
327
- f"subtask '{subtask.id}' under session_task '{task_id}'"
328
- )
329
- break
330
- if claimed_task_id:
331
- break
332
-
333
- if not claimed_task_id:
334
- logger.debug("require_task_review_or_close_before_stop: No claimed task, allowing")
335
- return None
336
-
337
- if not task_manager:
338
- logger.debug("require_task_review_or_close_before_stop: No task_manager, allowing")
339
- return None
340
-
341
- try:
342
- task = task_manager.get_task(claimed_task_id, project_id=project_id)
343
- if not task:
344
- # Task not found - clear stale workflow state and allow
345
- logger.debug(
346
- f"require_task_review_or_close_before_stop: Task '{claimed_task_id}' not found, "
347
- f"clearing state"
348
- )
349
- workflow_state.variables["claimed_task_id"] = None
350
- workflow_state.variables["task_claimed"] = False
351
- return None
352
-
353
- if task.status != "in_progress":
354
- # Task is closed or in review - allow stop
355
- logger.debug(
356
- f"require_task_review_or_close_before_stop: Task '{claimed_task_id}' "
357
- f"status={task.status}, allowing"
358
- )
359
- # Clear stale workflow state
360
- workflow_state.variables["claimed_task_id"] = None
361
- workflow_state.variables["task_claimed"] = False
362
- return None
363
-
364
- # Task is still in_progress - block the stop
365
- logger.info(
366
- f"require_task_review_or_close_before_stop: Blocking stop - task "
367
- f"'{claimed_task_id}' is still in_progress"
368
- )
369
-
370
- return {
371
- "decision": "block",
372
- "reason": (
373
- f"Task '{claimed_task_id}' is still in_progress. "
374
- f"Close it with close_task() before stopping, or set to review "
375
- f"if user intervention is needed."
376
- ),
377
- "task_id": claimed_task_id,
378
- "task_status": task.status,
379
- }
380
-
381
- except Exception as e:
382
- logger.warning(
383
- f"require_task_review_or_close_before_stop: Failed to check task status: {e}"
384
- )
385
- # Allow stop if we can't check - don't block on errors
386
- return None
387
-
388
-
389
24
  async def require_task_complete(
390
- task_manager: "LocalTaskManager | None",
25
+ task_manager: LocalTaskManager | None,
391
26
  session_id: str,
392
27
  task_ids: list[str] | None,
393
28
  event_data: dict[str, Any] | None = None,
394
29
  project_id: str | None = None,
395
- workflow_state: "WorkflowState | None" = None,
30
+ workflow_state: WorkflowState | None = None,
396
31
  ) -> dict[str, Any] | None:
397
32
  """
398
33
  Block agent from stopping until task(s) (and their subtasks) are complete.
@@ -406,10 +41,10 @@ async def require_task_complete(
406
41
  - Wildcard mode handled by caller (passes ready tasks as list)
407
42
 
408
43
  Logic per task:
409
- 1. If task has incomplete subtasks and agent has no claimed task suggest next subtask
410
- 2. If task has incomplete subtasks and agent has claimed task remind to finish it
411
- 3. If all subtasks done but task not closed remind to close the task
412
- 4. If task is closed move to next task in list
44
+ 1. If task has incomplete subtasks and agent has no claimed task -> suggest next subtask
45
+ 2. If task has incomplete subtasks and agent has claimed task -> remind to finish it
46
+ 3. If all subtasks done but task not closed -> remind to close the task
47
+ 4. If task is closed -> move to next task in list
413
48
 
414
49
  Args:
415
50
  task_manager: LocalTaskManager for querying tasks
@@ -571,14 +206,14 @@ async def require_task_complete(
571
206
 
572
207
 
573
208
  async def require_active_task(
574
- task_manager: "LocalTaskManager | None",
209
+ task_manager: LocalTaskManager | None,
575
210
  session_id: str,
576
- config: "DaemonConfig | None",
211
+ config: DaemonConfig | None,
577
212
  event_data: dict[str, Any] | None,
578
213
  project_id: str | None = None,
579
- workflow_state: "WorkflowState | None" = None,
580
- session_manager: "LocalSessionManager | None" = None,
581
- session_task_manager: "SessionTaskManager | None" = None,
214
+ workflow_state: WorkflowState | None = None,
215
+ session_manager: LocalSessionManager | None = None,
216
+ session_task_manager: SessionTaskManager | None = None,
582
217
  ) -> dict[str, Any] | None:
583
218
  """
584
219
  Check if an active task exists before allowing protected tools.
@@ -708,7 +343,7 @@ async def require_active_task(
708
343
  )
709
344
 
710
345
  # Check liveness of the candidate task
711
- is_live = _get_task_session_liveness(
346
+ is_live = get_task_session_liveness(
712
347
  task.id, session_task_manager, session_manager, exclude_session_id=session_id
713
348
  )
714
349
 
@@ -748,10 +383,14 @@ async def require_active_task(
748
383
  if error_already_shown:
749
384
  return {
750
385
  "decision": "block",
751
- "reason": "No task claimed. See previous **Task Required** error for instructions.",
386
+ "reason": (
387
+ "No task claimed. See previous **Task Required** error for instructions.\n"
388
+ "See skill: **claiming-tasks** for help."
389
+ ),
752
390
  "inject_context": (
753
391
  f"**Task Required**: `{tool_name}` blocked. "
754
- f"Create or claim a task before editing files (see previous error for details)."
392
+ f"Create or claim a task before editing files (see previous error for details).\n"
393
+ f'For detailed guidance: `get_skill(name="claiming-tasks")`'
755
394
  f"{project_task_hint}"
756
395
  ),
757
396
  }
@@ -764,7 +403,8 @@ async def require_active_task(
764
403
  f"- Create a task: call_tool(server_name='gobby-tasks', tool_name='create_task', arguments={{...}})\n"
765
404
  f"- Claim an existing task: call_tool(server_name='gobby-tasks', tool_name='update_task', "
766
405
  f"arguments={{'task_id': '...', 'status': 'in_progress'}})"
767
- f"{project_task_hint}"
406
+ f"{project_task_hint}\n\n"
407
+ f"See skill: **claiming-tasks** for detailed guidance."
768
408
  ),
769
409
  "inject_context": (
770
410
  f"**Task Required**: The `{tool_name}` tool is blocked until you claim a task for this session.\n\n"
@@ -772,14 +412,15 @@ async def require_active_task(
772
412
  f'1. **Create a new task**: `create_task(title="...", description="...")`\n'
773
413
  f'2. **Claim an existing task**: `update_task(task_id="...", status="in_progress")`\n\n'
774
414
  f"Use `list_ready_tasks()` to see available tasks."
775
- f"{project_task_hint}"
415
+ f"{project_task_hint}\n\n"
416
+ f'For detailed guidance: `get_skill(name="claiming-tasks")`'
776
417
  ),
777
418
  }
778
419
 
779
420
 
780
421
  async def validate_session_task_scope(
781
- task_manager: "LocalTaskManager | None",
782
- workflow_state: "WorkflowState | None",
422
+ task_manager: LocalTaskManager | None,
423
+ workflow_state: WorkflowState | None,
783
424
  event_data: dict[str, Any] | None = None,
784
425
  ) -> dict[str, Any] | None:
785
426
  """
gobby/workflows/engine.py CHANGED
@@ -15,7 +15,12 @@ from .audit_helpers import (
15
15
  log_transition,
16
16
  )
17
17
  from .definitions import WorkflowDefinition, WorkflowState
18
- from .detection_helpers import detect_mcp_call, detect_plan_mode, detect_task_claim
18
+ from .detection_helpers import (
19
+ detect_mcp_call,
20
+ detect_plan_mode,
21
+ detect_plan_mode_from_context,
22
+ detect_task_claim,
23
+ )
19
24
  from .evaluator import ConditionEvaluator
20
25
  from .lifecycle_evaluator import (
21
26
  evaluate_all_lifecycle_workflows as _evaluate_all_lifecycle_workflows,
@@ -375,6 +380,7 @@ class WorkflowEngine:
375
380
  evaluator=self.evaluator,
376
381
  detect_task_claim_fn=self._detect_task_claim,
377
382
  detect_plan_mode_fn=self._detect_plan_mode,
383
+ detect_plan_mode_from_context_fn=self._detect_plan_mode_from_context,
378
384
  check_premature_stop_fn=self._check_premature_stop,
379
385
  context_data=context_data,
380
386
  )
@@ -474,12 +480,17 @@ class WorkflowEngine:
474
480
  def _detect_task_claim(self, event: HookEvent, state: WorkflowState) -> None:
475
481
  """Detect gobby-tasks calls that claim or release a task for this session."""
476
482
  session_task_manager = getattr(self.action_executor, "session_task_manager", None)
477
- detect_task_claim(event, state, session_task_manager)
483
+ task_manager = getattr(self.action_executor, "task_manager", None)
484
+ detect_task_claim(event, state, session_task_manager, task_manager)
478
485
 
479
486
  def _detect_plan_mode(self, event: HookEvent, state: WorkflowState) -> None:
480
487
  """Detect Claude Code plan mode entry/exit and set workflow variable."""
481
488
  detect_plan_mode(event, state)
482
489
 
490
+ def _detect_plan_mode_from_context(self, event: HookEvent, state: WorkflowState) -> None:
491
+ """Detect plan mode from system reminders in user prompt."""
492
+ detect_plan_mode_from_context(event, state)
493
+
483
494
  def _detect_mcp_call(self, event: HookEvent, state: WorkflowState) -> None:
484
495
  """Track MCP tool calls by server/tool for workflow conditions."""
485
496
  detect_mcp_call(event, state)
@@ -4,8 +4,15 @@ Extracted from actions.py as part of strangler fig decomposition.
4
4
  These are pure utility functions with no ActionContext dependency.
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
7
9
  import logging
8
10
  import subprocess # nosec B404 - subprocess needed for git commands
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from gobby.storage.session_tasks import SessionTaskManager
15
+ from gobby.storage.sessions import LocalSessionManager
9
16
 
10
17
  logger = logging.getLogger(__name__)
11
18
 
@@ -94,3 +101,102 @@ def get_file_changes() -> str:
94
101
 
95
102
  except Exception:
96
103
  return "Unable to determine file changes"
104
+
105
+
106
+ def get_dirty_files(project_path: str | None = None) -> set[str]:
107
+ """
108
+ Get the set of dirty files from git status --porcelain.
109
+
110
+ Excludes .gobby/ files from the result.
111
+
112
+ Args:
113
+ project_path: Path to the project directory
114
+
115
+ Returns:
116
+ Set of dirty file paths (relative to repo root)
117
+ """
118
+ if project_path is None:
119
+ logger.warning(
120
+ "get_dirty_files: project_path is None, git status will use daemon's cwd "
121
+ "which may not be the project directory"
122
+ )
123
+
124
+ try:
125
+ result = subprocess.run( # nosec B603 B607 - hardcoded git command
126
+ ["git", "status", "--porcelain"],
127
+ cwd=project_path,
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=10,
131
+ )
132
+
133
+ if result.returncode != 0:
134
+ logger.warning(f"get_dirty_files: git status failed: {result.stderr}")
135
+ return set()
136
+
137
+ dirty_files = set()
138
+ # Split by newline first, don't strip() the whole string as it removes
139
+ # the leading space from git status format (e.g., " M file.py")
140
+ for line in result.stdout.split("\n"):
141
+ line = line.rstrip() # Remove trailing whitespace only
142
+ if not line:
143
+ continue
144
+ # Format is "XY filename" or "XY filename -> newname" for renames
145
+ # Skip the status prefix (first 3 chars: 2 status chars + space)
146
+ filepath = line[3:].split(" -> ")[0] # Handle renames
147
+ # Exclude .gobby/ files
148
+ if not filepath.startswith(".gobby/"):
149
+ dirty_files.add(filepath)
150
+
151
+ return dirty_files
152
+
153
+ except subprocess.TimeoutExpired:
154
+ logger.warning("get_dirty_files: git status timed out")
155
+ return set()
156
+ except FileNotFoundError:
157
+ logger.warning("get_dirty_files: git not found")
158
+ return set()
159
+ except Exception as e:
160
+ logger.error(f"get_dirty_files: Error running git status: {e}")
161
+ return set()
162
+
163
+
164
+ def get_task_session_liveness(
165
+ task_id: str,
166
+ session_task_manager: SessionTaskManager | None,
167
+ session_manager: LocalSessionManager | None,
168
+ exclude_session_id: str | None = None,
169
+ ) -> bool:
170
+ """
171
+ Check if a task is currently being worked on by an active session.
172
+
173
+ Args:
174
+ task_id: The task ID to check
175
+ session_task_manager: Manager to look up session-task links
176
+ session_manager: Manager to check session status
177
+ exclude_session_id: ID of session to exclude from check (e.g. current one)
178
+
179
+ Returns:
180
+ True if an active session (status='active') is linked to this task.
181
+ """
182
+ if not session_task_manager or not session_manager:
183
+ return False
184
+
185
+ try:
186
+ # Get all sessions linked to this task
187
+ linked_sessions = session_task_manager.get_task_sessions(task_id)
188
+
189
+ for link in linked_sessions:
190
+ session_id = link.get("session_id")
191
+ if not session_id or session_id == exclude_session_id:
192
+ continue
193
+
194
+ # Check if session is truly active
195
+ session = session_manager.get(session_id)
196
+ if session and session.status == "active":
197
+ return True
198
+
199
+ return False
200
+ except Exception as e:
201
+ logger.warning(f"get_task_session_liveness: Error checking liveness for {task_id}: {e}")
202
+ return False