gobby 0.2.5__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 (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,208 @@
1
+ """
2
+ Detection helper functions for workflow engine.
3
+
4
+ Extracted from engine.py to reduce complexity.
5
+ These functions detect specific events (task claims, plan mode, MCP calls)
6
+ and update workflow state variables accordingly.
7
+ """
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from gobby.hooks.events import HookEvent
14
+ from gobby.tasks.session_tasks import SessionTaskManager
15
+
16
+ from .definitions import WorkflowState
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def detect_task_claim(
22
+ event: "HookEvent",
23
+ state: "WorkflowState",
24
+ session_task_manager: "SessionTaskManager | None" = None,
25
+ ) -> None:
26
+ """Detect gobby-tasks calls that claim or release a task for this session.
27
+
28
+ Sets `task_claimed: true` in workflow state variables when the agent
29
+ successfully creates a task or updates a task to in_progress status.
30
+
31
+ Clears `task_claimed: false` when the agent closes a task, requiring
32
+ them to claim another task before making further file modifications.
33
+
34
+ This enables session-scoped task enforcement where each session must
35
+ explicitly claim a task rather than free-riding on project-wide checks.
36
+
37
+ Args:
38
+ event: The AFTER_TOOL hook event
39
+ state: Current workflow state (modified in place)
40
+ session_task_manager: Optional manager for auto-linking tasks to sessions
41
+ """
42
+ if not event.data:
43
+ return
44
+
45
+ tool_name = event.data.get("tool_name", "")
46
+ tool_input = event.data.get("tool_input", {}) or {}
47
+ tool_output = event.data.get("tool_output", {}) or {}
48
+
49
+ # Check if this is a gobby-tasks call via MCP proxy
50
+ # Tool name could be "call_tool" (from legacy) or "mcp__gobby__call_tool" (direct)
51
+ if tool_name not in ("call_tool", "mcp__gobby__call_tool"):
52
+ return
53
+
54
+ # Check server is gobby-tasks
55
+ server_name = tool_input.get("server_name", "")
56
+ if server_name != "gobby-tasks":
57
+ return
58
+
59
+ # Check inner tool name
60
+ inner_tool_name = tool_input.get("tool_name", "")
61
+ if inner_tool_name not in ("create_task", "update_task", "close_task"):
62
+ return
63
+
64
+ # For update_task, only count if status is being set to in_progress
65
+ if inner_tool_name == "update_task":
66
+ arguments = tool_input.get("arguments", {}) or {}
67
+ if arguments.get("status") != "in_progress":
68
+ return
69
+
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)
74
+ # tool_output structure varies, but errors typically have "error" key
75
+ # or the MCP response has "status": "error"
76
+ if isinstance(tool_output, dict):
77
+ if tool_output.get("error") or tool_output.get("status") == "error":
78
+ return
79
+ # Also check nested result for MCP proxy responses
80
+ result = tool_output.get("result", {})
81
+ if isinstance(result, dict) and result.get("error"):
82
+ return
83
+
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
+ # Extract task_id based on tool type
106
+ arguments = tool_input.get("arguments", {}) or {}
107
+ if inner_tool_name == "update_task":
108
+ task_id = arguments.get("task_id")
109
+ elif inner_tool_name == "create_task":
110
+ # For create_task, the id is in the result
111
+ result = tool_output.get("result", {}) if isinstance(tool_output, dict) else {}
112
+ task_id = result.get("id") if isinstance(result, dict) else None
113
+ else:
114
+ task_id = None
115
+
116
+ # All conditions met - set task_claimed and claimed_task_id
117
+ state.variables["task_claimed"] = True
118
+ state.variables["claimed_task_id"] = task_id
119
+ logger.info(
120
+ f"Session {state.session_id}: task_claimed=True, claimed_task_id={task_id} "
121
+ f"(via {inner_tool_name})"
122
+ )
123
+
124
+ # Auto-link task to session when status is set to in_progress
125
+ if inner_tool_name == "update_task":
126
+ arguments = tool_input.get("arguments", {}) or {}
127
+ task_id = arguments.get("task_id")
128
+ if task_id and session_task_manager:
129
+ try:
130
+ session_task_manager.link_task(state.session_id, task_id, "worked_on")
131
+ logger.info(f"Auto-linked task {task_id} to session {state.session_id}")
132
+ except Exception as e:
133
+ logger.warning(f"Failed to auto-link task {task_id}: {e}")
134
+
135
+
136
+ def detect_plan_mode(event: "HookEvent", state: "WorkflowState") -> None:
137
+ """Detect Claude Code plan mode entry/exit and set workflow variable.
138
+
139
+ Sets `plan_mode: true` when EnterPlanMode tool is called, allowing
140
+ file modifications without an active task (planning writes to plan files).
141
+
142
+ Clears `plan_mode: false` when ExitPlanMode tool is called, re-enabling
143
+ task enforcement for actual implementation work.
144
+
145
+ Args:
146
+ event: The AFTER_TOOL hook event
147
+ state: Current workflow state (modified in place)
148
+ """
149
+ if not event.data:
150
+ return
151
+
152
+ tool_name = event.data.get("tool_name", "")
153
+
154
+ if tool_name == "EnterPlanMode":
155
+ state.variables["plan_mode"] = True
156
+ logger.info(f"Session {state.session_id}: plan_mode=True (entered plan mode)")
157
+ elif tool_name == "ExitPlanMode":
158
+ state.variables["plan_mode"] = False
159
+ logger.info(f"Session {state.session_id}: plan_mode=False (exited plan mode)")
160
+
161
+
162
+ def detect_mcp_call(event: "HookEvent", state: "WorkflowState") -> None:
163
+ """Track MCP tool calls by server/tool for workflow conditions.
164
+
165
+ Sets state.variables["mcp_calls"] = {
166
+ "gobby-memory": ["recall", "remember"],
167
+ "context7": ["get-library-docs"],
168
+ ...
169
+ }
170
+
171
+ This enables workflow conditions like:
172
+ when: "mcp_called('gobby-memory', 'recall')"
173
+
174
+ Args:
175
+ event: The AFTER_TOOL hook event
176
+ state: Current workflow state (modified in place)
177
+ """
178
+ if not event.data:
179
+ return
180
+
181
+ tool_name = event.data.get("tool_name", "")
182
+ tool_input = event.data.get("tool_input", {}) or {}
183
+ tool_output = event.data.get("tool_output", {}) or {}
184
+
185
+ # Check for MCP proxy call
186
+ if tool_name not in ("call_tool", "mcp__gobby__call_tool"):
187
+ return
188
+
189
+ server_name = tool_input.get("server_name", "")
190
+ inner_tool = tool_input.get("tool_name", "")
191
+
192
+ if not server_name or not inner_tool:
193
+ return
194
+
195
+ # Check if call succeeded (skip tracking failed calls)
196
+ if isinstance(tool_output, dict):
197
+ if tool_output.get("error") or tool_output.get("status") == "error":
198
+ return
199
+ result = tool_output.get("result", {})
200
+ if isinstance(result, dict) and result.get("error"):
201
+ return
202
+
203
+ # Track the call
204
+ mcp_calls = state.variables.setdefault("mcp_calls", {})
205
+ server_calls = mcp_calls.setdefault(server_name, [])
206
+ if inner_tool not in server_calls:
207
+ server_calls.append(inner_tool)
208
+ logger.debug(f"Session {state.session_id}: MCP call tracked {server_name}/{inner_tool}")
@@ -0,0 +1,485 @@
1
+ import logging
2
+ from datetime import UTC, datetime
3
+ from pathlib import Path
4
+ from types import SimpleNamespace
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from gobby.hooks.events import HookEvent, HookEventType, HookResponse
8
+ from gobby.storage.workflow_audit import WorkflowAuditManager
9
+
10
+ from .approval_flow import handle_approval_response
11
+ from .audit_helpers import (
12
+ log_approval,
13
+ log_rule_eval,
14
+ log_tool_call,
15
+ log_transition,
16
+ )
17
+ from .definitions import WorkflowDefinition, WorkflowState
18
+ from .detection_helpers import detect_mcp_call, detect_plan_mode, detect_task_claim
19
+ from .evaluator import ConditionEvaluator
20
+ from .lifecycle_evaluator import (
21
+ evaluate_all_lifecycle_workflows as _evaluate_all_lifecycle_workflows,
22
+ )
23
+ from .lifecycle_evaluator import (
24
+ evaluate_lifecycle_triggers as _evaluate_lifecycle_triggers,
25
+ )
26
+ from .lifecycle_evaluator import (
27
+ evaluate_workflow_triggers as _evaluate_workflow_triggers,
28
+ )
29
+ from .lifecycle_evaluator import (
30
+ process_action_result,
31
+ )
32
+ from .loader import WorkflowLoader
33
+ from .premature_stop import check_premature_stop
34
+ from .state_manager import WorkflowStateManager
35
+
36
+ if TYPE_CHECKING:
37
+ from .actions import ActionExecutor
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class WorkflowEngine:
43
+ """
44
+ Core engine for executing step-based workflows.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ loader: WorkflowLoader,
50
+ state_manager: WorkflowStateManager,
51
+ action_executor: "ActionExecutor",
52
+ evaluator: ConditionEvaluator | None = None,
53
+ audit_manager: WorkflowAuditManager | None = None,
54
+ ):
55
+ self.loader = loader
56
+ self.state_manager = state_manager
57
+ self.action_executor = action_executor
58
+ self.evaluator = evaluator or ConditionEvaluator()
59
+ self.audit_manager = audit_manager
60
+
61
+ # Maps canonical trigger names to their legacy aliases for backward compatibility.
62
+ TRIGGER_ALIASES: dict[str, list[str]] = {
63
+ "on_before_agent": ["on_prompt_submit"],
64
+ "on_before_tool": ["on_tool_call"],
65
+ "on_after_tool": ["on_tool_result"],
66
+ }
67
+
68
+ # Variables to inherit from parent session
69
+ VARS_TO_INHERIT = ["plan_mode"]
70
+
71
+ async def handle_event(self, event: HookEvent) -> HookResponse:
72
+ """
73
+ Main entry point for hook events.
74
+ """
75
+ session_id = event.metadata.get("_platform_session_id")
76
+ if not session_id:
77
+ return HookResponse(decision="allow") # No session, no workflow
78
+
79
+ # 1. Load state
80
+ state = self.state_manager.get_state(session_id)
81
+
82
+ # 2. If no state, check triggers to start one (e.g. on_session_start)
83
+ # Note: This logic might need to move to a specialized trigger handler
84
+ # For now, simplistic check
85
+
86
+ if not state:
87
+ # TODO: Logic to load workflow?
88
+ # For now, return allow
89
+ return HookResponse(decision="allow")
90
+
91
+ # Check if workflow is temporarily disabled (escape hatch)
92
+ if state.disabled:
93
+ logger.debug(
94
+ f"Workflow '{state.workflow_name}' is disabled for session {session_id}. "
95
+ f"Reason: {state.disabled_reason or 'No reason specified'}"
96
+ )
97
+ return HookResponse(decision="allow")
98
+
99
+ # Stuck prevention: Check if step duration exceeding limit
100
+ # This is a basic implementation of "Stuck Detection"
101
+ if state.step_entered_at:
102
+ logger.debug(f"step_entered_at type: {type(state.step_entered_at)}")
103
+ logger.debug(f"step_entered_at value: {state.step_entered_at}")
104
+ diff = datetime.now(UTC) - state.step_entered_at
105
+ logger.debug(f"diff type: {type(diff)}, value: {diff}")
106
+ duration = diff.total_seconds()
107
+ logger.debug(f"duration type: {type(duration)}, value: {duration}")
108
+ # Hardcoded limit for MVP: 30 minutes
109
+ if duration > 1800:
110
+ # Force transition to reflect if not already there
111
+ if state.step != "reflect":
112
+ project_path = Path(event.cwd) if event.cwd else None
113
+ workflow = self.loader.load_workflow(state.workflow_name, project_path)
114
+ if workflow and workflow.get_step("reflect"):
115
+ await self.transition_to(state, "reflect", workflow)
116
+ return HookResponse(
117
+ decision="modify",
118
+ context="[System Alert] Step duration limit exceeded. Transitioning to 'reflect' step.",
119
+ )
120
+
121
+ # 3. Load definition
122
+ # Skip if this is a lifecycle-only state (used for task_claimed tracking)
123
+ if state.workflow_name == "__lifecycle__":
124
+ logger.debug(
125
+ f"Skipping step workflow handling for lifecycle state in session {session_id}"
126
+ )
127
+ return HookResponse(decision="allow")
128
+
129
+ project_path = Path(event.cwd) if event.cwd else None
130
+ workflow = self.loader.load_workflow(state.workflow_name, project_path)
131
+ if not workflow:
132
+ logger.error(f"Workflow '{state.workflow_name}' not found for session {session_id}")
133
+ return HookResponse(decision="allow")
134
+
135
+ # Skip step handling for lifecycle workflows - they only use triggers
136
+ if workflow.type == "lifecycle":
137
+ logger.debug(
138
+ f"Skipping step workflow handling for lifecycle workflow '{workflow.name}' "
139
+ f"in session {session_id}"
140
+ )
141
+ return HookResponse(decision="allow")
142
+
143
+ # 4. Process event
144
+ # Logic matches WORKFLOWS.md "Evaluation Flow"
145
+
146
+ # Determine context for evaluation
147
+ # Use SimpleNamespace for variables so dot notation works (variables.session_task)
148
+ # Look up session info for condition evaluation
149
+ session_info = {}
150
+ if (
151
+ self.action_executor
152
+ and self.action_executor.session_manager
153
+ and event.machine_id
154
+ and event.project_id
155
+ ):
156
+ session = self.action_executor.session_manager.find_by_external_id(
157
+ external_id=event.session_id,
158
+ machine_id=event.machine_id,
159
+ project_id=event.project_id,
160
+ source=event.source.value,
161
+ )
162
+ if session:
163
+ session_info = {
164
+ "id": session.id,
165
+ "external_id": session.external_id,
166
+ "project_id": session.project_id,
167
+ "status": session.status,
168
+ "git_branch": session.git_branch,
169
+ "source": session.source,
170
+ }
171
+ eval_context = {
172
+ "event": event,
173
+ "workflow_state": state,
174
+ "variables": SimpleNamespace(**state.variables),
175
+ "session": SimpleNamespace(**session_info),
176
+ "tool_name": event.data.get("tool_name"),
177
+ "tool_args": event.data.get("tool_args", {}),
178
+ # State attributes for transition conditions
179
+ "step_action_count": state.step_action_count,
180
+ "total_action_count": state.total_action_count,
181
+ "step": state.step,
182
+ }
183
+
184
+ current_step = workflow.get_step(state.step)
185
+ if not current_step:
186
+ logger.error(f"Step '{state.step}' not found in workflow '{workflow.name}'")
187
+ return HookResponse(decision="allow")
188
+
189
+ # Handle approval flow on user prompt submit
190
+ if event.event_type == HookEventType.BEFORE_AGENT:
191
+ approval_response = self._handle_approval_response(event, state, current_step)
192
+ if approval_response:
193
+ return approval_response
194
+
195
+ # Reset premature stop counter on user prompt
196
+ # This allows the failsafe to distinguish agent-stuck-in-loop from user-initiated-stops
197
+ if state.variables.get("_premature_stop_count", 0) > 0:
198
+ state.variables["_premature_stop_count"] = 0
199
+ self.state_manager.save_state(state)
200
+ logger.debug(f"Reset premature_stop_count for session {session_id}")
201
+
202
+ # Check blocked tools
203
+ if event.event_type == HookEventType.BEFORE_TOOL:
204
+ # Block tool calls while waiting for approval
205
+ if state.approval_pending:
206
+ reason = "Waiting for user approval. Please respond with 'yes' or 'no'."
207
+ self._log_tool_call(session_id, state.step, "unknown", "block", reason)
208
+ return HookResponse(decision="block", reason=reason)
209
+
210
+ # Reset premature stop counter on tool calls
211
+ # This ensures the failsafe only triggers for repeated stops without work in between
212
+ if state.variables.get("_premature_stop_count", 0) > 0:
213
+ state.variables["_premature_stop_count"] = 0
214
+ self.state_manager.save_state(state)
215
+ logger.debug(f"Reset premature_stop_count on tool call for session {session_id}")
216
+
217
+ raw_tool_name = eval_context.get("tool_name")
218
+ tool_name = str(raw_tool_name) if raw_tool_name is not None else ""
219
+
220
+ # Check blocked list
221
+ if tool_name in current_step.blocked_tools:
222
+ reason = f"Tool '{tool_name}' is blocked in step '{state.step}'."
223
+ self._log_tool_call(session_id, state.step, tool_name, "block", reason)
224
+ return HookResponse(decision="block", reason=reason)
225
+
226
+ # Check allowed list (if not "all")
227
+ if current_step.allowed_tools != "all":
228
+ if tool_name not in current_step.allowed_tools:
229
+ reason = f"Tool '{tool_name}' is not in allowed list for step '{state.step}'."
230
+ self._log_tool_call(session_id, state.step, tool_name, "block", reason)
231
+ return HookResponse(decision="block", reason=reason)
232
+
233
+ # Check rules
234
+ for rule in current_step.rules:
235
+ if self.evaluator.evaluate(rule.when, eval_context):
236
+ if rule.action == "block":
237
+ reason = rule.message or "Blocked by workflow rule."
238
+ self._log_rule_eval(
239
+ session_id,
240
+ state.step,
241
+ rule.name or "unnamed",
242
+ rule.when,
243
+ "block",
244
+ reason,
245
+ )
246
+ return HookResponse(decision="block", reason=reason)
247
+ # Handle other actions like warn, require_approval
248
+
249
+ # Log successful tool allow
250
+ self._log_tool_call(session_id, state.step, tool_name, "allow")
251
+
252
+ # Check transitions
253
+ logger.debug("Checking transitions")
254
+ for transition in current_step.transitions:
255
+ if self.evaluator.evaluate(transition.when, eval_context):
256
+ # Transition!
257
+ await self.transition_to(state, transition.to, workflow)
258
+ return HookResponse(
259
+ decision="modify", context=f"Transitioning to step: {transition.to}"
260
+ )
261
+
262
+ # Check exit conditions
263
+ logger.debug("Checking exit conditions")
264
+ if self.evaluator.check_exit_conditions(current_step.exit_conditions, state):
265
+ # TODO: Determine next step or completion logic
266
+ # For now, simplistic 'next step' if linear, or rely on transitions
267
+ pass
268
+
269
+ # Update stats (generic)
270
+ if event.event_type == HookEventType.AFTER_TOOL:
271
+ state.step_action_count += 1
272
+ state.total_action_count += 1
273
+
274
+ # Detect gobby-tasks calls for session-scoped task claiming
275
+ self._detect_task_claim(event, state)
276
+
277
+ # Detect Claude Code plan mode entry/exit
278
+ self._detect_plan_mode(event, state)
279
+
280
+ # Track all MCP proxy calls for workflow conditions
281
+ self._detect_mcp_call(event, state)
282
+
283
+ self.state_manager.save_state(state) # Persist updates
284
+
285
+ return HookResponse(decision="allow")
286
+
287
+ async def transition_to(
288
+ self, state: WorkflowState, new_step_name: str, workflow: WorkflowDefinition
289
+ ) -> None:
290
+ """
291
+ Execute transition logic.
292
+ """
293
+ old_step = workflow.get_step(state.step)
294
+ new_step = workflow.get_step(new_step_name)
295
+
296
+ if not new_step:
297
+ logger.error(f"Cannot transition to unknown step '{new_step_name}'")
298
+ return
299
+
300
+ logger.info(
301
+ f"Transitioning session {state.session_id} from '{state.step}' to '{new_step_name}'"
302
+ )
303
+
304
+ # Log the transition
305
+ self._log_transition(state.session_id, state.step, new_step_name)
306
+
307
+ # Execute on_exit of old step
308
+ if old_step:
309
+ await self._execute_actions(old_step.on_exit, state)
310
+
311
+ # Update state
312
+ state.step = new_step_name
313
+ state.step_entered_at = datetime.now(UTC)
314
+ state.step_action_count = 0
315
+ state.context_injected = False # Reset for new step context
316
+
317
+ self.state_manager.save_state(state)
318
+
319
+ # Execute on_enter of new step
320
+ await self._execute_actions(new_step.on_enter, state)
321
+
322
+ async def _execute_actions(self, actions: list[dict[str, Any]], state: WorkflowState) -> None:
323
+ """
324
+ Execute a list of actions.
325
+ """
326
+ from .actions import ActionContext
327
+
328
+ context = ActionContext(
329
+ session_id=state.session_id,
330
+ state=state,
331
+ db=self.action_executor.db,
332
+ session_manager=self.action_executor.session_manager,
333
+ template_engine=self.action_executor.template_engine,
334
+ llm_service=self.action_executor.llm_service,
335
+ transcript_processor=self.action_executor.transcript_processor,
336
+ config=self.action_executor.config,
337
+ mcp_manager=self.action_executor.mcp_manager,
338
+ memory_manager=self.action_executor.memory_manager,
339
+ memory_sync_manager=self.action_executor.memory_sync_manager,
340
+ task_sync_manager=self.action_executor.task_sync_manager,
341
+ session_task_manager=self.action_executor.session_task_manager,
342
+ )
343
+
344
+ for action_def in actions:
345
+ action_type = action_def.get("action")
346
+ if not action_type:
347
+ continue
348
+
349
+ result = await self.action_executor.execute(action_type, context, **action_def)
350
+
351
+ if result and "inject_context" in result:
352
+ # Log context injection for now
353
+ logger.info(f"Context injected: {result['inject_context'][:50]}...")
354
+
355
+ def _handle_approval_response(
356
+ self,
357
+ event: HookEvent,
358
+ state: WorkflowState,
359
+ current_step: Any,
360
+ ) -> HookResponse | None:
361
+ """Handle user response to approval request."""
362
+ return handle_approval_response(
363
+ event, state, current_step, self.evaluator, self.state_manager
364
+ )
365
+
366
+ async def evaluate_all_lifecycle_workflows(
367
+ self, event: HookEvent, context_data: dict[str, Any] | None = None
368
+ ) -> HookResponse:
369
+ """Discover and evaluate all lifecycle workflows for the given event."""
370
+ return await _evaluate_all_lifecycle_workflows(
371
+ event=event,
372
+ loader=self.loader,
373
+ state_manager=self.state_manager,
374
+ action_executor=self.action_executor,
375
+ evaluator=self.evaluator,
376
+ detect_task_claim_fn=self._detect_task_claim,
377
+ detect_plan_mode_fn=self._detect_plan_mode,
378
+ check_premature_stop_fn=self._check_premature_stop,
379
+ context_data=context_data,
380
+ )
381
+
382
+ def _process_action_result(
383
+ self,
384
+ result: dict[str, Any],
385
+ context_data: dict[str, Any],
386
+ state: "WorkflowState",
387
+ injected_context: list[str],
388
+ ) -> str | None:
389
+ """Process action execution result."""
390
+ return process_action_result(result, context_data, state, injected_context)
391
+
392
+ async def _evaluate_workflow_triggers(
393
+ self,
394
+ workflow: "WorkflowDefinition",
395
+ event: HookEvent,
396
+ context_data: dict[str, Any],
397
+ ) -> HookResponse:
398
+ """Evaluate triggers for a single workflow definition."""
399
+ return await _evaluate_workflow_triggers(
400
+ workflow, event, context_data, self.state_manager, self.action_executor, self.evaluator
401
+ )
402
+
403
+ async def evaluate_lifecycle_triggers(
404
+ self, workflow_name: str, event: HookEvent, context_data: dict[str, Any] | None = None
405
+ ) -> HookResponse:
406
+ """Evaluate triggers for a specific lifecycle workflow (e.g. session-handoff)."""
407
+ return await _evaluate_lifecycle_triggers(
408
+ workflow_name, event, self.loader, self.action_executor, self.evaluator, context_data
409
+ )
410
+
411
+ # --- Premature Stop Handling ---
412
+
413
+ async def _check_premature_stop(
414
+ self, event: HookEvent, context_data: dict[str, Any]
415
+ ) -> HookResponse | None:
416
+ """Check if an active step workflow should handle a premature stop."""
417
+ template_engine = self.action_executor.template_engine if self.action_executor else None
418
+ return await check_premature_stop(
419
+ event, context_data, self.state_manager, self.loader, self.evaluator, template_engine
420
+ )
421
+
422
+ # --- Audit Logging Helpers ---
423
+
424
+ def _log_tool_call(
425
+ self,
426
+ session_id: str,
427
+ step: str,
428
+ tool_name: str,
429
+ result: str,
430
+ reason: str | None = None,
431
+ context: dict[str, Any] | None = None,
432
+ ) -> None:
433
+ """Log a tool call permission check to the audit log."""
434
+ log_tool_call(self.audit_manager, session_id, step, tool_name, result, reason, context)
435
+
436
+ def _log_rule_eval(
437
+ self,
438
+ session_id: str,
439
+ step: str,
440
+ rule_id: str,
441
+ condition: str,
442
+ result: str,
443
+ reason: str | None = None,
444
+ context: dict[str, Any] | None = None,
445
+ ) -> None:
446
+ """Log a rule evaluation to the audit log."""
447
+ log_rule_eval(
448
+ self.audit_manager, session_id, step, rule_id, condition, result, reason, context
449
+ )
450
+
451
+ def _log_transition(
452
+ self,
453
+ session_id: str,
454
+ from_step: str,
455
+ to_step: str,
456
+ reason: str | None = None,
457
+ context: dict[str, Any] | None = None,
458
+ ) -> None:
459
+ """Log a step transition to the audit log."""
460
+ log_transition(self.audit_manager, session_id, from_step, to_step, reason, context)
461
+
462
+ def _log_approval(
463
+ self,
464
+ session_id: str,
465
+ step: str,
466
+ result: str,
467
+ condition_id: str | None = None,
468
+ prompt: str | None = None,
469
+ context: dict[str, Any] | None = None,
470
+ ) -> None:
471
+ """Log an approval gate event to the audit log."""
472
+ log_approval(self.audit_manager, session_id, step, result, condition_id, prompt, context)
473
+
474
+ def _detect_task_claim(self, event: HookEvent, state: WorkflowState) -> None:
475
+ """Detect gobby-tasks calls that claim or release a task for this session."""
476
+ session_task_manager = getattr(self.action_executor, "session_task_manager", None)
477
+ detect_task_claim(event, state, session_task_manager)
478
+
479
+ def _detect_plan_mode(self, event: HookEvent, state: WorkflowState) -> None:
480
+ """Detect Claude Code plan mode entry/exit and set workflow variable."""
481
+ detect_plan_mode(event, state)
482
+
483
+ def _detect_mcp_call(self, event: HookEvent, state: WorkflowState) -> None:
484
+ """Track MCP tool calls by server/tool for workflow conditions."""
485
+ detect_mcp_call(event, state)