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
@@ -4,6 +4,7 @@ Extracted from actions.py as part of strangler fig decomposition.
4
4
  These functions handle file artifact capture and reading.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import glob
8
9
  import logging
9
10
  import os
@@ -101,3 +102,33 @@ def read_artifact(
101
102
  except Exception as e:
102
103
  logger.error(f"read_artifact: Failed to read {filepath}: {e}")
103
104
  return None
105
+
106
+
107
+ # --- ActionHandler-compatible wrappers ---
108
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
109
+
110
+ if __name__ != "__main__":
111
+ from typing import TYPE_CHECKING
112
+
113
+ if TYPE_CHECKING:
114
+ from gobby.workflows.actions import ActionContext
115
+
116
+
117
+ async def handle_capture_artifact(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
118
+ """ActionHandler wrapper for capture_artifact."""
119
+ return await asyncio.to_thread(
120
+ capture_artifact,
121
+ state=context.state,
122
+ pattern=kwargs.get("pattern"),
123
+ save_as=kwargs.get("as"),
124
+ )
125
+
126
+
127
+ async def handle_read_artifact(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
128
+ """ActionHandler wrapper for read_artifact."""
129
+ return await asyncio.to_thread(
130
+ read_artifact,
131
+ state=context.state,
132
+ pattern=kwargs.get("pattern"),
133
+ variable_name=kwargs.get("as"),
134
+ )
@@ -284,3 +284,14 @@ def get_progress_summary(
284
284
  "last_event_at": (summary.last_event_at.isoformat() if summary.last_event_at else None),
285
285
  "events_by_type": {k.value: v for k, v in summary.events_by_type.items()},
286
286
  }
287
+
288
+
289
+ # --- ActionHandler-compatible wrappers ---
290
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
291
+ # Note: These handlers require executor access for progress_tracker and stuck_detector,
292
+ # so they are created as closures inside ActionExecutor._register_defaults().
293
+
294
+ # No wrapper functions are defined in this file. The actual handler implementations
295
+ # are closures created in ActionExecutor._register_defaults() which capture the
296
+ # executor's self.progress_tracker and self.stuck_detector references. See that
297
+ # method for the actual implementations and where these components are hooked up.
@@ -6,10 +6,14 @@ These functions handle context injection, message injection, and handoff extract
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import json
10
11
  import logging
11
12
  from pathlib import Path
12
- from typing import Any
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from gobby.workflows.actions import ActionContext
13
17
 
14
18
  from gobby.workflows.git_utils import get_git_status, get_recent_git_commits
15
19
 
@@ -304,6 +308,17 @@ def extract_handoff_context(
304
308
  except Exception as wt_err:
305
309
  logger.debug(f"Failed to get worktree context: {wt_err}")
306
310
 
311
+ # Add active skills from HookSkillManager
312
+ try:
313
+ from gobby.hooks.skill_manager import HookSkillManager
314
+
315
+ skill_manager = HookSkillManager()
316
+ core_skills = skill_manager.discover_core_skills()
317
+ always_apply_skills = [s.name for s in core_skills if s.is_always_apply()]
318
+ handoff_ctx.active_skills = always_apply_skills
319
+ except Exception as skill_err:
320
+ logger.debug(f"Failed to get active skills: {skill_err}")
321
+
307
322
  # Format as markdown (like /clear stores formatted summary)
308
323
  markdown = format_handoff_as_markdown(handoff_ctx)
309
324
 
@@ -320,6 +335,32 @@ def extract_handoff_context(
320
335
  return {"error": str(e)}
321
336
 
322
337
 
338
+ def recommend_skills_for_task(task: dict[str, Any] | None) -> list[str]:
339
+ """Recommend relevant skills based on task category.
340
+
341
+ Uses HookSkillManager to get skill recommendations based on the task's
342
+ category field. Returns always-apply skills if no category is set.
343
+
344
+ Args:
345
+ task: Task dict with optional 'category' field, or None.
346
+
347
+ Returns:
348
+ List of recommended skill names for this task.
349
+ """
350
+ if task is None:
351
+ return []
352
+
353
+ try:
354
+ from gobby.hooks.skill_manager import HookSkillManager
355
+
356
+ manager = HookSkillManager()
357
+ category = task.get("category")
358
+ return manager.recommend_skills(category=category)
359
+ except Exception as e:
360
+ logger.debug(f"Failed to recommend skills: {e}")
361
+ return []
362
+
363
+
323
364
  def format_handoff_as_markdown(ctx: Any, prompt_template: str | None = None) -> str:
324
365
  """Format HandoffContext as markdown for storage.
325
366
 
@@ -391,4 +432,55 @@ def format_handoff_as_markdown(ctx: Any, prompt_template: str | None = None) ->
391
432
  lines.append(f"- {activity}")
392
433
  sections.append("\n".join(lines))
393
434
 
435
+ # Active skills section
436
+ if hasattr(ctx, "active_skills") and ctx.active_skills:
437
+ lines = ["### Active Skills"]
438
+ lines.append(f"Skills available: {', '.join(ctx.active_skills)}")
439
+ sections.append("\n".join(lines))
440
+
394
441
  return "\n\n".join(sections)
442
+
443
+
444
+ # --- ActionHandler-compatible wrappers ---
445
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
446
+
447
+
448
+ async def handle_inject_context(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
449
+ """ActionHandler wrapper for inject_context."""
450
+ return await asyncio.to_thread(
451
+ inject_context,
452
+ session_manager=context.session_manager,
453
+ session_id=context.session_id,
454
+ state=context.state,
455
+ template_engine=context.template_engine,
456
+ source=kwargs.get("source"),
457
+ template=kwargs.get("template"),
458
+ require=kwargs.get("require", False),
459
+ )
460
+
461
+
462
+ async def handle_inject_message(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
463
+ """ActionHandler wrapper for inject_message."""
464
+ return await asyncio.to_thread(
465
+ inject_message,
466
+ session_manager=context.session_manager,
467
+ session_id=context.session_id,
468
+ state=context.state,
469
+ template_engine=context.template_engine,
470
+ content=kwargs.get("content"),
471
+ **{k: v for k, v in kwargs.items() if k != "content"},
472
+ )
473
+
474
+
475
+ async def handle_extract_handoff_context(
476
+ context: ActionContext, **kwargs: Any
477
+ ) -> dict[str, Any] | None:
478
+ """ActionHandler wrapper for extract_handoff_context."""
479
+ return await asyncio.to_thread(
480
+ extract_handoff_context,
481
+ session_manager=context.session_manager,
482
+ session_id=context.session_id,
483
+ config=context.config,
484
+ db=context.db,
485
+ worktree_manager=kwargs.get("worktree_manager"),
486
+ )
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from gobby.hooks.events import HookEvent
14
+ from gobby.storage.tasks import LocalTaskManager
14
15
  from gobby.tasks.session_tasks import SessionTaskManager
15
16
 
16
17
  from .definitions import WorkflowState
@@ -22,6 +23,7 @@ def detect_task_claim(
22
23
  event: "HookEvent",
23
24
  state: "WorkflowState",
24
25
  session_task_manager: "SessionTaskManager | None" = None,
26
+ task_manager: "LocalTaskManager | None" = None,
25
27
  ) -> None:
26
28
  """Detect gobby-tasks calls that claim or release a task for this session.
27
29
 
@@ -44,7 +46,8 @@ def detect_task_claim(
44
46
 
45
47
  tool_name = event.data.get("tool_name", "")
46
48
  tool_input = event.data.get("tool_input", {}) or {}
47
- tool_output = event.data.get("tool_output", {}) or {}
49
+ # Claude Code sends "tool_result", but we also check "tool_output" for compatibility
50
+ tool_output = event.data.get("tool_result") or event.data.get("tool_output") or {}
48
51
 
49
52
  # Check if this is a gobby-tasks call via MCP proxy
50
53
  # Tool name could be "call_tool" (from legacy) or "mcp__gobby__call_tool" (direct)
@@ -58,7 +61,34 @@ def detect_task_claim(
58
61
 
59
62
  # Check inner tool name
60
63
  inner_tool_name = tool_input.get("tool_name", "")
61
- if inner_tool_name not in ("create_task", "update_task", "close_task"):
64
+
65
+ # Handle close_task - clears task_claimed when task is closed
66
+ # Note: Claude Code doesn't include tool_result in post-tool-use hooks, so for CC
67
+ # the workflow state is updated directly in the MCP proxy's close_task function.
68
+ # This detection provides a fallback for CLIs that do report tool results (Gemini/Codex).
69
+ if inner_tool_name == "close_task":
70
+ tool_output = event.data.get("tool_result") or event.data.get("tool_output") or {}
71
+
72
+ # If no tool output, skip - can't verify success
73
+ # The MCP proxy's close_task handles state clearing for successful closes
74
+ if not tool_output:
75
+ return
76
+
77
+ # Check if close succeeded (not an error)
78
+ if isinstance(tool_output, dict):
79
+ if tool_output.get("error") or tool_output.get("status") == "error":
80
+ return
81
+ result = tool_output.get("result", {})
82
+ if isinstance(result, dict) and result.get("error"):
83
+ return
84
+
85
+ # Clear task_claimed on successful close
86
+ state.variables["task_claimed"] = False
87
+ state.variables["claimed_task_id"] = None
88
+ logger.info(f"Session {state.session_id}: task_claimed=False (detected close_task success)")
89
+ return
90
+
91
+ if inner_tool_name not in ("create_task", "update_task", "claim_task"):
62
92
  return
63
93
 
64
94
  # For update_task, only count if status is being set to in_progress
@@ -66,11 +96,9 @@ def detect_task_claim(
66
96
  arguments = tool_input.get("arguments", {}) or {}
67
97
  if arguments.get("status") != "in_progress":
68
98
  return
99
+ # claim_task always counts (it sets status to in_progress internally)
69
100
 
70
- # For close_task, we'll clear task_claimed after success check
71
- is_close_task = inner_tool_name == "close_task"
72
-
73
- # Check if the call succeeded (not an error)
101
+ # Check if the call succeeded (not an error) - for non-close_task operations
74
102
  # tool_output structure varies, but errors typically have "error" key
75
103
  # or the MCP response has "status": "error"
76
104
  if isinstance(tool_output, dict):
@@ -81,35 +109,26 @@ def detect_task_claim(
81
109
  if isinstance(result, dict) and result.get("error"):
82
110
  return
83
111
 
84
- # Handle close_task - clear the claim only if closing the claimed task
85
- if is_close_task:
86
- arguments = tool_input.get("arguments", {}) or {}
87
- closed_task_id = arguments.get("task_id")
88
- claimed_task_id = state.variables.get("claimed_task_id")
89
-
90
- # Only clear task_claimed if we're closing the task that was claimed
91
- if closed_task_id and claimed_task_id and closed_task_id == claimed_task_id:
92
- state.variables["task_claimed"] = False
93
- state.variables["claimed_task_id"] = None
94
- logger.info(
95
- f"Session {state.session_id}: task_claimed=False "
96
- f"(claimed task {closed_task_id} closed via close_task)"
97
- )
98
- else:
99
- logger.debug(
100
- f"Session {state.session_id}: close_task for {closed_task_id} "
101
- f"(claimed: {claimed_task_id}) - not clearing task_claimed"
102
- )
103
- return
104
-
105
112
  # Extract task_id based on tool type
106
113
  arguments = tool_input.get("arguments", {}) or {}
107
- if inner_tool_name == "update_task":
114
+ if inner_tool_name in ("update_task", "claim_task"):
108
115
  task_id = arguments.get("task_id")
116
+ # Resolve to UUID for consistent comparison with close_task
117
+ if task_id and task_manager:
118
+ try:
119
+ task = task_manager.get_task(task_id)
120
+ if task:
121
+ task_id = task.id # Use UUID
122
+ except Exception: # nosec B110 - best effort resolution, keep original if fails
123
+ pass
109
124
  elif inner_tool_name == "create_task":
110
125
  # For create_task, the id is in the result
111
126
  result = tool_output.get("result", {}) if isinstance(tool_output, dict) else {}
112
127
  task_id = result.get("id") if isinstance(result, dict) else None
128
+ # Skip if we can't get the task ID (e.g., Claude Code doesn't include tool results)
129
+ # The MCP tool itself handles state updates in this case via _crud.py
130
+ if not task_id:
131
+ return
113
132
  else:
114
133
  task_id = None
115
134
 
@@ -121,8 +140,8 @@ def detect_task_claim(
121
140
  f"(via {inner_tool_name})"
122
141
  )
123
142
 
124
- # Auto-link task to session when status is set to in_progress
125
- if inner_tool_name == "update_task":
143
+ # Auto-link task to session when claiming a task
144
+ if inner_tool_name in ("update_task", "claim_task"):
126
145
  arguments = tool_input.get("arguments", {}) or {}
127
146
  task_id = arguments.get("task_id")
128
147
  if task_id and session_task_manager:
@@ -159,6 +178,70 @@ def detect_plan_mode(event: "HookEvent", state: "WorkflowState") -> None:
159
178
  logger.info(f"Session {state.session_id}: plan_mode=False (exited plan mode)")
160
179
 
161
180
 
181
+ def detect_plan_mode_from_context(event: "HookEvent", state: "WorkflowState") -> None:
182
+ """Detect plan mode from system reminders injected by Claude Code.
183
+
184
+ Claude Code injects system reminders like "Plan mode is active" when the user
185
+ enters plan mode via the UI (not via the EnterPlanMode tool). This function
186
+ detects those reminders and sets the plan_mode variable accordingly.
187
+
188
+ IMPORTANT: Only matches indicators within <system-reminder> tags to avoid
189
+ false positives from handoff context or user messages that mention plan mode.
190
+
191
+ This complements detect_plan_mode() which only catches programmatic tool calls.
192
+
193
+ Args:
194
+ event: The BEFORE_AGENT hook event (contains user prompt with system reminders)
195
+ state: Current workflow state (modified in place)
196
+ """
197
+ if not event.data:
198
+ return
199
+
200
+ # Check for plan mode system reminder in the prompt
201
+ prompt = event.data.get("prompt", "") or ""
202
+
203
+ # Extract only content within <system-reminder> tags to avoid false positives
204
+ # from handoff context or user messages mentioning plan mode
205
+ import re
206
+
207
+ system_reminders = re.findall(r"<system-reminder>(.*?)</system-reminder>", prompt, re.DOTALL)
208
+ reminder_text = " ".join(system_reminders)
209
+
210
+ # Claude Code injects these phrases in system reminders when plan mode is active
211
+ plan_mode_indicators = [
212
+ "Plan mode is active",
213
+ "Plan mode still active",
214
+ "You are in plan mode",
215
+ ]
216
+
217
+ # Check if plan mode is indicated in system reminders only
218
+ for indicator in plan_mode_indicators:
219
+ if indicator in reminder_text:
220
+ if not state.variables.get("plan_mode"):
221
+ state.variables["plan_mode"] = True
222
+ logger.info(
223
+ f"Session {state.session_id}: plan_mode=True "
224
+ f"(detected from system reminder: '{indicator}')"
225
+ )
226
+ return
227
+
228
+ # Detect exit from plan mode (also only in system reminders)
229
+ exit_indicators = [
230
+ "Exited Plan Mode",
231
+ "Plan mode exited",
232
+ ]
233
+
234
+ for indicator in exit_indicators:
235
+ if indicator in reminder_text:
236
+ if state.variables.get("plan_mode"):
237
+ state.variables["plan_mode"] = False
238
+ logger.info(
239
+ f"Session {state.session_id}: plan_mode=False "
240
+ f"(detected from system reminder: '{indicator}')"
241
+ )
242
+ return
243
+
244
+
162
245
  def detect_mcp_call(event: "HookEvent", state: "WorkflowState") -> None:
163
246
  """Track MCP tool calls by server/tool for workflow conditions.
164
247
 
@@ -180,7 +263,8 @@ def detect_mcp_call(event: "HookEvent", state: "WorkflowState") -> None:
180
263
 
181
264
  tool_name = event.data.get("tool_name", "")
182
265
  tool_input = event.data.get("tool_input", {}) or {}
183
- tool_output = event.data.get("tool_output", {}) or {}
266
+ # Claude Code sends "tool_result", but we also check "tool_output" for compatibility
267
+ tool_output = event.data.get("tool_result") or event.data.get("tool_output") or {}
184
268
 
185
269
  # Check for MCP proxy call
186
270
  if tool_name not in ("call_tool", "mcp__gobby__call_tool"):
@@ -0,0 +1,47 @@
1
+ """Task enforcement actions for workflow engine.
2
+
3
+ This package provides actions that enforce task tracking before allowing
4
+ certain tools, and enforce task completion before allowing agent to stop.
5
+ """
6
+
7
+ from gobby.workflows.enforcement.blocking import block_tools
8
+ from gobby.workflows.enforcement.commit_policy import (
9
+ capture_baseline_dirty_files,
10
+ require_commit_before_stop,
11
+ require_task_review_or_close_before_stop,
12
+ )
13
+ from gobby.workflows.enforcement.handlers import (
14
+ handle_block_tools,
15
+ handle_capture_baseline_dirty_files,
16
+ handle_require_active_task,
17
+ handle_require_commit_before_stop,
18
+ handle_require_task_complete,
19
+ handle_require_task_review_or_close_before_stop,
20
+ handle_validate_session_task_scope,
21
+ )
22
+ from gobby.workflows.enforcement.task_policy import (
23
+ require_active_task,
24
+ require_task_complete,
25
+ validate_session_task_scope,
26
+ )
27
+
28
+ __all__ = [
29
+ # Blocking
30
+ "block_tools",
31
+ # Commit policy
32
+ "capture_baseline_dirty_files",
33
+ "require_commit_before_stop",
34
+ "require_task_review_or_close_before_stop",
35
+ # Task policy
36
+ "require_active_task",
37
+ "require_task_complete",
38
+ "validate_session_task_scope",
39
+ # Handlers
40
+ "handle_block_tools",
41
+ "handle_capture_baseline_dirty_files",
42
+ "handle_require_active_task",
43
+ "handle_require_commit_before_stop",
44
+ "handle_require_task_complete",
45
+ "handle_require_task_review_or_close_before_stop",
46
+ "handle_validate_session_task_scope",
47
+ ]