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,1310 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Any, Protocol
6
+
7
+ from gobby.storage.database import DatabaseProtocol
8
+ from gobby.storage.sessions import LocalSessionManager
9
+ from gobby.storage.tasks import LocalTaskManager # noqa: F401
10
+ from gobby.workflows.artifact_actions import capture_artifact, read_artifact
11
+ from gobby.workflows.autonomous_actions import (
12
+ detect_stuck,
13
+ detect_task_loop,
14
+ get_progress_summary,
15
+ record_progress,
16
+ record_task_selection,
17
+ start_progress_tracking,
18
+ stop_progress_tracking,
19
+ )
20
+ from gobby.workflows.context_actions import (
21
+ extract_handoff_context,
22
+ format_handoff_as_markdown,
23
+ inject_context,
24
+ inject_message,
25
+ )
26
+ from gobby.workflows.definitions import WorkflowState
27
+ from gobby.workflows.git_utils import get_file_changes, get_git_status, get_recent_git_commits
28
+ from gobby.workflows.llm_actions import call_llm
29
+ from gobby.workflows.mcp_actions import call_mcp_tool
30
+ from gobby.workflows.memory_actions import (
31
+ memory_recall_relevant,
32
+ memory_save,
33
+ memory_sync_export,
34
+ memory_sync_import,
35
+ reset_memory_injection_tracking,
36
+ )
37
+ from gobby.workflows.session_actions import (
38
+ mark_session_status,
39
+ start_new_session,
40
+ switch_mode,
41
+ )
42
+ from gobby.workflows.state_actions import (
43
+ increment_variable,
44
+ load_workflow_state,
45
+ mark_loop_complete,
46
+ save_workflow_state,
47
+ set_variable,
48
+ )
49
+ from gobby.workflows.stop_signal_actions import (
50
+ check_stop_signal,
51
+ clear_stop_signal,
52
+ request_stop,
53
+ )
54
+ from gobby.workflows.summary_actions import (
55
+ format_turns_for_llm,
56
+ generate_handoff,
57
+ generate_summary,
58
+ synthesize_title,
59
+ )
60
+ from gobby.workflows.task_enforcement_actions import (
61
+ capture_baseline_dirty_files,
62
+ require_active_task,
63
+ require_commit_before_stop,
64
+ require_task_complete,
65
+ require_task_review_or_close_before_stop,
66
+ validate_session_task_scope,
67
+ )
68
+ from gobby.workflows.templates import TemplateEngine
69
+ from gobby.workflows.todo_actions import mark_todo_complete, write_todos
70
+ from gobby.workflows.webhook import WebhookAction
71
+ from gobby.workflows.webhook_executor import WebhookExecutor
72
+
73
+ logger = logging.getLogger(__name__)
74
+
75
+
76
+ @dataclass
77
+ class ActionContext:
78
+ """Context passed to action handlers."""
79
+
80
+ session_id: str
81
+ state: WorkflowState
82
+ db: DatabaseProtocol
83
+ session_manager: LocalSessionManager
84
+ template_engine: TemplateEngine
85
+ llm_service: Any | None = None
86
+ transcript_processor: Any | None = None
87
+ config: Any | None = None
88
+ mcp_manager: Any | None = None
89
+ memory_manager: Any | None = None
90
+ memory_sync_manager: Any | None = None
91
+ task_sync_manager: Any | None = None
92
+ session_task_manager: Any | None = None
93
+ event_data: dict[str, Any] | None = None # Hook event data (e.g., prompt_text)
94
+
95
+
96
+ class ActionHandler(Protocol):
97
+ """Protocol for action handlers."""
98
+
99
+ async def __call__(self, context: ActionContext, **kwargs: Any) -> dict[str, Any] | None: ...
100
+
101
+
102
+ class ActionExecutor:
103
+ """Registry and executor for workflow actions."""
104
+
105
+ def __init__(
106
+ self,
107
+ db: DatabaseProtocol,
108
+ session_manager: LocalSessionManager,
109
+ template_engine: TemplateEngine,
110
+ llm_service: Any | None = None,
111
+ transcript_processor: Any | None = None,
112
+ config: Any | None = None,
113
+ mcp_manager: Any | None = None,
114
+ memory_manager: Any | None = None,
115
+ memory_sync_manager: Any | None = None,
116
+ task_manager: Any | None = None,
117
+ task_sync_manager: Any | None = None,
118
+ session_task_manager: Any | None = None,
119
+ stop_registry: Any | None = None,
120
+ progress_tracker: Any | None = None,
121
+ stuck_detector: Any | None = None,
122
+ websocket_server: Any | None = None,
123
+ ):
124
+ self.db = db
125
+ self.session_manager = session_manager
126
+ self.template_engine = template_engine
127
+ self.llm_service = llm_service
128
+ self.transcript_processor = transcript_processor
129
+ self.config = config
130
+ self.mcp_manager = mcp_manager
131
+ self.memory_manager = memory_manager
132
+ self.memory_sync_manager = memory_sync_manager
133
+ self.task_manager = task_manager
134
+ self.task_sync_manager = task_sync_manager
135
+ self.session_task_manager = session_task_manager
136
+ self.stop_registry = stop_registry
137
+ self.progress_tracker = progress_tracker
138
+ self.stuck_detector = stuck_detector
139
+ self.websocket_server = websocket_server
140
+ self._handlers: dict[str, ActionHandler] = {}
141
+
142
+ self._register_defaults()
143
+
144
+ def register(self, name: str, handler: ActionHandler) -> None:
145
+ """Register an action handler."""
146
+ self._handlers[name] = handler
147
+
148
+ def register_plugin_actions(self, plugin_registry: Any) -> None:
149
+ """
150
+ Register actions from loaded plugins.
151
+
152
+ Actions are registered with the naming convention:
153
+ plugin:<plugin-name>:<action-name>
154
+
155
+ Plugin actions with schemas will have their inputs validated before execution.
156
+
157
+ Args:
158
+ plugin_registry: PluginRegistry instance containing loaded plugins.
159
+ """
160
+ if plugin_registry is None:
161
+ return
162
+
163
+ for plugin_name, plugin in plugin_registry._plugins.items():
164
+ for action_name, plugin_action in plugin._actions.items():
165
+ full_name = f"plugin:{plugin_name}:{action_name}"
166
+
167
+ # Create wrapper that validates schema before calling handler
168
+ if plugin_action.schema:
169
+ wrapper = self._create_validating_wrapper(plugin_action)
170
+ self._handlers[full_name] = wrapper
171
+ else:
172
+ # No schema, use handler directly
173
+ self._handlers[full_name] = plugin_action.handler
174
+
175
+ logger.debug(f"Registered plugin action: {full_name}")
176
+
177
+ def _create_validating_wrapper(self, plugin_action: Any) -> ActionHandler:
178
+ """Create a wrapper handler that validates input against schema.
179
+
180
+ Args:
181
+ plugin_action: PluginAction with schema and handler.
182
+
183
+ Returns:
184
+ Wrapper handler that validates before calling the real handler.
185
+ """
186
+
187
+ async def validating_handler(
188
+ context: ActionContext, **kwargs: Any
189
+ ) -> dict[str, Any] | None:
190
+ # Validate input against schema
191
+ is_valid, error = plugin_action.validate_input(kwargs)
192
+ if not is_valid:
193
+ logger.warning(f"Plugin action '{plugin_action.name}' validation failed: {error}")
194
+ return {"error": f"Schema validation failed: {error}"}
195
+
196
+ # Call the actual handler
197
+ result = await plugin_action.handler(context, **kwargs)
198
+ return dict(result) if isinstance(result, dict) else None
199
+
200
+ return validating_handler
201
+
202
+ def _register_defaults(self) -> None:
203
+ """Register built-in actions."""
204
+ self.register("inject_context", self._handle_inject_context)
205
+ self.register("inject_message", self._handle_inject_message)
206
+ self.register("capture_artifact", self._handle_capture_artifact)
207
+ self.register("generate_handoff", self._handle_generate_handoff)
208
+ self.register("generate_summary", self._handle_generate_summary)
209
+ self.register("mark_session_status", self._handle_mark_session_status)
210
+ self.register("switch_mode", self._handle_switch_mode)
211
+ self.register("read_artifact", self._handle_read_artifact)
212
+ self.register("load_workflow_state", self._handle_load_workflow_state)
213
+ self.register("save_workflow_state", self._handle_save_workflow_state)
214
+ self.register("set_variable", self._handle_set_variable)
215
+ self.register("increment_variable", self._handle_increment_variable)
216
+ self.register("call_llm", self._handle_call_llm)
217
+ self.register("synthesize_title", self._handle_synthesize_title)
218
+ self.register("write_todos", self._handle_write_todos)
219
+ self.register("mark_todo_complete", self._handle_mark_todo_complete)
220
+ self.register("persist_tasks", self._handle_persist_tasks)
221
+ self.register("get_workflow_tasks", self._handle_get_workflow_tasks)
222
+ self.register("update_workflow_task", self._handle_update_workflow_task)
223
+ self.register("call_mcp_tool", self._handle_call_mcp_tool)
224
+ # Memory actions - underscore pattern (memory_*)
225
+ self.register("memory_save", self._handle_save_memory)
226
+ self.register("memory_recall_relevant", self._handle_memory_recall_relevant)
227
+ self.register("memory_sync_import", self._handle_memory_sync_import)
228
+ self.register("memory_sync_export", self._handle_memory_sync_export)
229
+ self.register(
230
+ "reset_memory_injection_tracking", self._handle_reset_memory_injection_tracking
231
+ )
232
+ # Task sync actions
233
+ self.register("task_sync_import", self._handle_task_sync_import)
234
+ self.register("task_sync_export", self._handle_task_sync_export)
235
+ self.register("extract_handoff_context", self._handle_extract_handoff_context)
236
+ self.register("start_new_session", self._handle_start_new_session)
237
+ self.register("mark_loop_complete", self._handle_mark_loop_complete)
238
+ # Task enforcement
239
+ self.register("require_active_task", self._handle_require_active_task)
240
+ self.register("require_commit_before_stop", self._handle_require_commit_before_stop)
241
+ self.register(
242
+ "require_task_review_or_close_before_stop",
243
+ self._handle_require_task_review_or_close_before_stop,
244
+ )
245
+ self.register("require_task_complete", self._handle_require_task_complete)
246
+ self.register("validate_session_task_scope", self._handle_validate_session_task_scope)
247
+ self.register("capture_baseline_dirty_files", self._handle_capture_baseline_dirty_files)
248
+ # Webhook
249
+ self.register("webhook", self._handle_webhook)
250
+ # Stop signal actions
251
+ self.register("check_stop_signal", self._handle_check_stop_signal)
252
+ self.register("request_stop", self._handle_request_stop)
253
+ self.register("clear_stop_signal", self._handle_clear_stop_signal)
254
+ # Autonomous execution actions
255
+ self.register("start_progress_tracking", self._handle_start_progress_tracking)
256
+ self.register("stop_progress_tracking", self._handle_stop_progress_tracking)
257
+ self.register("record_progress", self._handle_record_progress)
258
+ self.register("detect_task_loop", self._handle_detect_task_loop)
259
+ self.register("detect_stuck", self._handle_detect_stuck)
260
+ self.register("record_task_selection", self._handle_record_task_selection)
261
+ self.register("get_progress_summary", self._handle_get_progress_summary)
262
+
263
+ async def execute(
264
+ self, action_type: str, context: ActionContext, **kwargs: Any
265
+ ) -> dict[str, Any] | None:
266
+ """Execute an action."""
267
+ handler = self._handlers.get(action_type)
268
+ if not handler:
269
+ logger.warning(f"Unknown action type: {action_type}")
270
+ return None
271
+
272
+ try:
273
+ return await handler(context, **kwargs)
274
+ except Exception as e:
275
+ logger.error(f"Error executing action {action_type}: {e}", exc_info=True)
276
+ return {"error": str(e)}
277
+
278
+ # --- Action Implementations ---
279
+
280
+ async def _handle_inject_context(
281
+ self, context: ActionContext, **kwargs: Any
282
+ ) -> dict[str, Any] | None:
283
+ """Inject context from a source."""
284
+ return inject_context(
285
+ session_manager=context.session_manager,
286
+ session_id=context.session_id,
287
+ state=context.state,
288
+ template_engine=context.template_engine,
289
+ source=kwargs.get("source"),
290
+ template=kwargs.get("template"),
291
+ require=kwargs.get("require", False),
292
+ )
293
+
294
+ async def _handle_inject_message(
295
+ self, context: ActionContext, **kwargs: Any
296
+ ) -> dict[str, Any] | None:
297
+ """Inject a message to the user/assistant, rendering it as a template."""
298
+ return inject_message(
299
+ session_manager=context.session_manager,
300
+ session_id=context.session_id,
301
+ state=context.state,
302
+ template_engine=context.template_engine,
303
+ content=kwargs.get("content"),
304
+ **{k: v for k, v in kwargs.items() if k != "content"},
305
+ )
306
+
307
+ async def _handle_capture_artifact(
308
+ self, context: ActionContext, **kwargs: Any
309
+ ) -> dict[str, Any] | None:
310
+ """Capture an artifact (file) and store its path in state."""
311
+ return capture_artifact(
312
+ state=context.state,
313
+ pattern=kwargs.get("pattern"),
314
+ save_as=kwargs.get("as"),
315
+ )
316
+
317
+ async def _handle_read_artifact(
318
+ self, context: ActionContext, **kwargs: Any
319
+ ) -> dict[str, Any] | None:
320
+ """Read an artifact's content into a workflow variable."""
321
+ return read_artifact(
322
+ state=context.state,
323
+ pattern=kwargs.get("pattern"),
324
+ variable_name=kwargs.get("as"),
325
+ )
326
+
327
+ async def _handle_load_workflow_state(
328
+ self, context: ActionContext, **kwargs: Any
329
+ ) -> dict[str, Any] | None:
330
+ """Load workflow state from DB."""
331
+ return load_workflow_state(context.db, context.session_id, context.state)
332
+
333
+ async def _handle_save_workflow_state(
334
+ self, context: ActionContext, **kwargs: Any
335
+ ) -> dict[str, Any] | None:
336
+ """Save workflow state to DB."""
337
+ return save_workflow_state(context.db, context.state)
338
+
339
+ async def _handle_set_variable(
340
+ self, context: ActionContext, **kwargs: Any
341
+ ) -> dict[str, Any] | None:
342
+ """Set a workflow variable.
343
+
344
+ Values containing Jinja2 templates ({{ ... }}) are rendered before setting.
345
+ """
346
+ value = kwargs.get("value")
347
+
348
+ # Render template if value contains Jinja2 syntax
349
+ if isinstance(value, str) and "{{" in value:
350
+ template_context = {
351
+ "variables": context.state.variables or {},
352
+ "state": context.state,
353
+ }
354
+ value = context.template_engine.render(value, template_context)
355
+
356
+ return set_variable(context.state, kwargs.get("name"), value)
357
+
358
+ async def _handle_increment_variable(
359
+ self, context: ActionContext, **kwargs: Any
360
+ ) -> dict[str, Any] | None:
361
+ """Increment a numeric workflow variable."""
362
+ return increment_variable(context.state, kwargs.get("name"), kwargs.get("amount", 1))
363
+
364
+ async def _handle_call_llm(
365
+ self, context: ActionContext, **kwargs: Any
366
+ ) -> dict[str, Any] | None:
367
+ """Call LLM with a prompt template and store result in variable."""
368
+ return await call_llm(
369
+ llm_service=context.llm_service,
370
+ template_engine=context.template_engine,
371
+ state=context.state,
372
+ session=context.session_manager.get(context.session_id),
373
+ prompt=kwargs.get("prompt"),
374
+ output_as=kwargs.get("output_as"),
375
+ **{k: v for k, v in kwargs.items() if k not in ("prompt", "output_as")},
376
+ )
377
+
378
+ async def _handle_synthesize_title(
379
+ self, context: ActionContext, **kwargs: Any
380
+ ) -> dict[str, Any] | None:
381
+ """Synthesize and set a session title."""
382
+ # Extract prompt from event data (UserPromptSubmit hook)
383
+ prompt = None
384
+ if context.event_data:
385
+ prompt = context.event_data.get("prompt")
386
+
387
+ return await synthesize_title(
388
+ session_manager=context.session_manager,
389
+ session_id=context.session_id,
390
+ llm_service=context.llm_service,
391
+ transcript_processor=context.transcript_processor,
392
+ template_engine=context.template_engine,
393
+ template=kwargs.get("template"),
394
+ prompt=prompt,
395
+ )
396
+
397
+ async def _handle_write_todos(
398
+ self, context: ActionContext, **kwargs: Any
399
+ ) -> dict[str, Any] | None:
400
+ """Write todos to a file (default TODO.md)."""
401
+ return write_todos(
402
+ todos=kwargs.get("todos", []),
403
+ filename=kwargs.get("filename", "TODO.md"),
404
+ mode=kwargs.get("mode", "w"),
405
+ )
406
+
407
+ async def _handle_mark_todo_complete(
408
+ self, context: ActionContext, **kwargs: Any
409
+ ) -> dict[str, Any] | None:
410
+ """Mark a todo as complete in TODO.md."""
411
+ return mark_todo_complete(
412
+ todo_text=kwargs.get("todo_text", ""),
413
+ filename=kwargs.get("filename", "TODO.md"),
414
+ )
415
+
416
+ async def _handle_memory_sync_import(
417
+ self, context: ActionContext, **kwargs: Any
418
+ ) -> dict[str, Any] | None:
419
+ """Import memories from filesystem."""
420
+ return await memory_sync_import(context.memory_sync_manager)
421
+
422
+ async def _handle_memory_sync_export(
423
+ self, context: ActionContext, **kwargs: Any
424
+ ) -> dict[str, Any] | None:
425
+ """Export memories to filesystem."""
426
+ return await memory_sync_export(context.memory_sync_manager)
427
+
428
+ async def _handle_task_sync_import(
429
+ self, context: ActionContext, **kwargs: Any
430
+ ) -> dict[str, Any] | None:
431
+ """Import tasks from JSONL file.
432
+
433
+ Reads .gobby/tasks.jsonl and imports tasks into SQLite using
434
+ Last-Write-Wins conflict resolution based on updated_at.
435
+ """
436
+ if not context.task_sync_manager:
437
+ logger.debug("task_sync_import: No task_sync_manager available")
438
+ return {"error": "Task Sync Manager not available"}
439
+
440
+ try:
441
+ # Get project_id from session for project-scoped sync
442
+ project_id = None
443
+ session = context.session_manager.get(context.session_id)
444
+ if session:
445
+ project_id = session.project_id
446
+
447
+ context.task_sync_manager.import_from_jsonl(project_id=project_id)
448
+ logger.info("Task sync import completed")
449
+ return {"imported": True}
450
+ except Exception as e:
451
+ logger.error(f"task_sync_import failed: {e}", exc_info=True)
452
+ return {"error": str(e)}
453
+
454
+ async def _handle_task_sync_export(
455
+ self, context: ActionContext, **kwargs: Any
456
+ ) -> dict[str, Any] | None:
457
+ """Export tasks to JSONL file.
458
+
459
+ Writes tasks and dependencies to .gobby/tasks.jsonl for Git persistence.
460
+ Uses content hashing to skip writes if nothing changed.
461
+ """
462
+ if not context.task_sync_manager:
463
+ logger.debug("task_sync_export: No task_sync_manager available")
464
+ return {"error": "Task Sync Manager not available"}
465
+
466
+ try:
467
+ # Get project_id from session for project-scoped sync
468
+ project_id = None
469
+ session = context.session_manager.get(context.session_id)
470
+ if session:
471
+ project_id = session.project_id
472
+
473
+ context.task_sync_manager.export_to_jsonl(project_id=project_id)
474
+ logger.info("Task sync export completed")
475
+ return {"exported": True}
476
+ except Exception as e:
477
+ logger.error(f"task_sync_export failed: {e}", exc_info=True)
478
+ return {"error": str(e)}
479
+
480
+ async def _handle_persist_tasks(
481
+ self, context: ActionContext, **kwargs: Any
482
+ ) -> dict[str, Any] | None:
483
+ """Persist a list of task dicts to Gobby task system.
484
+
485
+ Enhanced to support workflow integration with ID mapping.
486
+
487
+ Args (via kwargs):
488
+ tasks: List of task dicts (or source variable name)
489
+ source: Variable name containing task list (alternative to tasks)
490
+ workflow_name: Associate tasks with this workflow
491
+ parent_task_id: Optional parent task for all created tasks
492
+
493
+ Returns:
494
+ Dict with tasks_persisted count, ids list, and id_mapping dict
495
+ """
496
+ # Get tasks from either 'tasks' kwarg or 'source' variable
497
+ tasks = kwargs.get("tasks", [])
498
+ source = kwargs.get("source")
499
+
500
+ if source and context.state.variables:
501
+ source_data = context.state.variables.get(source)
502
+ if source_data:
503
+ # Handle nested structure like task_list.tasks
504
+ if isinstance(source_data, dict) and "tasks" in source_data:
505
+ tasks = source_data["tasks"]
506
+ elif isinstance(source_data, list):
507
+ tasks = source_data
508
+
509
+ if not tasks:
510
+ return {"tasks_persisted": 0, "ids": [], "id_mapping": {}}
511
+
512
+ try:
513
+ from gobby.workflows.task_actions import persist_decomposed_tasks
514
+
515
+ current_session = context.session_manager.get(context.session_id)
516
+ project_id = current_session.project_id if current_session else "default"
517
+
518
+ # Get workflow name from kwargs or state
519
+ workflow_name = kwargs.get("workflow_name")
520
+ if not workflow_name and context.state.workflow_name:
521
+ workflow_name = context.state.workflow_name
522
+
523
+ parent_task_id = kwargs.get("parent_task_id")
524
+
525
+ id_mapping = persist_decomposed_tasks(
526
+ db=context.db,
527
+ project_id=project_id,
528
+ tasks_data=tasks,
529
+ workflow_name=workflow_name or "unnamed",
530
+ parent_task_id=parent_task_id,
531
+ created_in_session_id=context.session_id,
532
+ )
533
+
534
+ # Store ID mapping in workflow state for reference
535
+ if not context.state.variables:
536
+ context.state.variables = {}
537
+ context.state.variables["task_id_mapping"] = id_mapping
538
+
539
+ return {
540
+ "tasks_persisted": len(id_mapping),
541
+ "ids": list(id_mapping.values()),
542
+ "id_mapping": id_mapping,
543
+ }
544
+ except Exception as e:
545
+ logger.error(f"persist_tasks: Failed: {e}")
546
+ return {"error": str(e)}
547
+
548
+ async def _handle_get_workflow_tasks(
549
+ self, context: ActionContext, **kwargs: Any
550
+ ) -> dict[str, Any] | None:
551
+ """Get tasks associated with the current workflow.
552
+
553
+ Args (via kwargs):
554
+ workflow_name: Override workflow name (defaults to current)
555
+ include_closed: Include closed tasks (default: False)
556
+ as: Variable name to store result in
557
+
558
+ Returns:
559
+ Dict with tasks list and count
560
+ """
561
+ from gobby.workflows.task_actions import get_workflow_tasks
562
+
563
+ workflow_name = kwargs.get("workflow_name")
564
+ if not workflow_name and context.state.workflow_name:
565
+ workflow_name = context.state.workflow_name
566
+
567
+ if not workflow_name:
568
+ return {"error": "No workflow name specified"}
569
+
570
+ current_session = context.session_manager.get(context.session_id)
571
+ project_id = current_session.project_id if current_session else None
572
+
573
+ include_closed = kwargs.get("include_closed", False)
574
+
575
+ tasks = get_workflow_tasks(
576
+ db=context.db,
577
+ workflow_name=workflow_name,
578
+ project_id=project_id,
579
+ include_closed=include_closed,
580
+ )
581
+
582
+ # Convert to dicts for YAML/JSON serialization
583
+ tasks_data = [t.to_dict() for t in tasks]
584
+
585
+ # Store in variable if requested
586
+ output_as = kwargs.get("as")
587
+ if output_as:
588
+ if not context.state.variables:
589
+ context.state.variables = {}
590
+ context.state.variables[output_as] = tasks_data
591
+
592
+ # Also update task_list in state for workflow engine use
593
+ context.state.task_list = [
594
+ {"id": t.id, "title": t.title, "status": t.status} for t in tasks
595
+ ]
596
+
597
+ return {"tasks": tasks_data, "count": len(tasks)}
598
+
599
+ async def _handle_update_workflow_task(
600
+ self, context: ActionContext, **kwargs: Any
601
+ ) -> dict[str, Any] | None:
602
+ """Update a task from workflow context.
603
+
604
+ Args (via kwargs):
605
+ task_id: ID of task to update (required)
606
+ status: New status
607
+ verification: Verification result
608
+ validation_status: Validation status
609
+
610
+ Returns:
611
+ Dict with updated task data
612
+ """
613
+ from gobby.workflows.task_actions import update_task_from_workflow
614
+
615
+ task_id = kwargs.get("task_id")
616
+ if not task_id:
617
+ # Try to get from current_task_index in state
618
+ if context.state.task_list and context.state.current_task_index is not None:
619
+ idx = context.state.current_task_index
620
+ if 0 <= idx < len(context.state.task_list):
621
+ task_id = context.state.task_list[idx].get("id")
622
+
623
+ if not task_id:
624
+ return {"error": "No task_id specified"}
625
+
626
+ task = update_task_from_workflow(
627
+ db=context.db,
628
+ task_id=task_id,
629
+ status=kwargs.get("status"),
630
+ verification=kwargs.get("verification"),
631
+ validation_status=kwargs.get("validation_status"),
632
+ validation_feedback=kwargs.get("validation_feedback"),
633
+ )
634
+
635
+ if task:
636
+ return {"updated": True, "task": task.to_dict()}
637
+ return {"updated": False, "error": "Task not found"}
638
+
639
+ async def _handle_call_mcp_tool(
640
+ self,
641
+ context: ActionContext,
642
+ **kwargs: Any,
643
+ ) -> dict[str, Any] | None:
644
+ """Call an MCP tool on a connected server."""
645
+ return await call_mcp_tool(
646
+ mcp_manager=context.mcp_manager,
647
+ state=context.state,
648
+ server_name=kwargs.get("server_name"),
649
+ tool_name=kwargs.get("tool_name"),
650
+ arguments=kwargs.get("arguments"),
651
+ output_as=kwargs.get("as"),
652
+ )
653
+
654
+ async def _handle_generate_handoff(
655
+ self, context: ActionContext, **kwargs: Any
656
+ ) -> dict[str, Any] | None:
657
+ """Generate a handoff record (summary + mark status).
658
+
659
+ For compact mode, fetches the current session's existing summary_markdown
660
+ as previous_summary for cumulative compression.
661
+ """
662
+ # Detect mode from kwargs or event data
663
+ mode = kwargs.get("mode", "clear")
664
+
665
+ # Check if this is a compact event based on event_data
666
+ # Use precise matching against known compact event types to avoid false positives
667
+ COMPACT_EVENT_TYPES = {"pre_compact", "compact"}
668
+ if context.event_data:
669
+ raw_event_type = context.event_data.get("event_type") or ""
670
+ normalized_event_type = str(raw_event_type).strip().lower()
671
+ if normalized_event_type in COMPACT_EVENT_TYPES:
672
+ mode = "compact"
673
+
674
+ # For compact mode, fetch previous summary for cumulative compression
675
+ previous_summary = None
676
+ if mode == "compact":
677
+ current_session = context.session_manager.get(context.session_id)
678
+ if current_session:
679
+ previous_summary = getattr(current_session, "summary_markdown", None)
680
+ if previous_summary:
681
+ logger.debug(
682
+ f"Compact mode: using previous summary ({len(previous_summary)} chars) "
683
+ f"for cumulative compression"
684
+ )
685
+
686
+ return await generate_handoff(
687
+ session_manager=context.session_manager,
688
+ session_id=context.session_id,
689
+ llm_service=context.llm_service,
690
+ transcript_processor=context.transcript_processor,
691
+ template=kwargs.get("template"),
692
+ previous_summary=previous_summary,
693
+ mode=mode,
694
+ )
695
+
696
+ async def _handle_generate_summary(
697
+ self, context: ActionContext, **kwargs: Any
698
+ ) -> dict[str, Any] | None:
699
+ """Generate a session summary using LLM."""
700
+ return await generate_summary(
701
+ session_manager=context.session_manager,
702
+ session_id=context.session_id,
703
+ llm_service=context.llm_service,
704
+ transcript_processor=context.transcript_processor,
705
+ template=kwargs.get("template"),
706
+ )
707
+
708
+ async def _handle_start_new_session(
709
+ self, context: ActionContext, **kwargs: Any
710
+ ) -> dict[str, Any] | None:
711
+ """Start a new CLI session (chaining)."""
712
+ return start_new_session(
713
+ session_manager=context.session_manager,
714
+ session_id=context.session_id,
715
+ command=kwargs.get("command"),
716
+ args=kwargs.get("args"),
717
+ prompt=kwargs.get("prompt"),
718
+ cwd=kwargs.get("cwd"),
719
+ )
720
+
721
+ async def _handle_mark_loop_complete(
722
+ self, context: ActionContext, **kwargs: Any
723
+ ) -> dict[str, Any] | None:
724
+ """Mark the autonomous loop as complete."""
725
+ return mark_loop_complete(context.state)
726
+
727
+ async def _handle_extract_handoff_context(
728
+ self, context: ActionContext, **kwargs: Any
729
+ ) -> dict[str, Any] | None:
730
+ """Extract handoff context from transcript and save to session.compact_markdown."""
731
+ return extract_handoff_context(
732
+ session_manager=context.session_manager,
733
+ session_id=context.session_id,
734
+ config=context.config,
735
+ db=self.db,
736
+ )
737
+
738
+ def _format_handoff_as_markdown(self, ctx: Any, prompt_template: str | None = None) -> str:
739
+ """Format HandoffContext as markdown for injection."""
740
+ return format_handoff_as_markdown(ctx, prompt_template)
741
+
742
+ async def _handle_save_memory(
743
+ self, context: ActionContext, **kwargs: Any
744
+ ) -> dict[str, Any] | None:
745
+ """Save a memory directly from workflow context."""
746
+ return await memory_save(
747
+ memory_manager=context.memory_manager,
748
+ session_manager=context.session_manager,
749
+ session_id=context.session_id,
750
+ content=kwargs.get("content"),
751
+ memory_type=kwargs.get("memory_type", "fact"),
752
+ importance=kwargs.get("importance", 0.5),
753
+ tags=kwargs.get("tags"),
754
+ project_id=kwargs.get("project_id"),
755
+ )
756
+
757
+ async def _handle_memory_recall_relevant(
758
+ self, context: ActionContext, **kwargs: Any
759
+ ) -> dict[str, Any] | None:
760
+ """Recall memories relevant to the current user prompt."""
761
+ prompt_text = None
762
+ if context.event_data:
763
+ # Check both "prompt" (from hook event) and "prompt_text" (legacy/alternative)
764
+ prompt_text = context.event_data.get("prompt") or context.event_data.get("prompt_text")
765
+
766
+ return await memory_recall_relevant(
767
+ memory_manager=context.memory_manager,
768
+ session_manager=context.session_manager,
769
+ session_id=context.session_id,
770
+ prompt_text=prompt_text,
771
+ project_id=kwargs.get("project_id"),
772
+ limit=kwargs.get("limit", 5),
773
+ min_importance=kwargs.get("min_importance", 0.3),
774
+ state=context.state,
775
+ )
776
+
777
+ async def _handle_reset_memory_injection_tracking(
778
+ self, context: ActionContext, **kwargs: Any
779
+ ) -> dict[str, Any] | None:
780
+ """Reset memory injection tracking to allow re-injection after context loss."""
781
+ return reset_memory_injection_tracking(state=context.state)
782
+
783
+ async def _handle_mark_session_status(
784
+ self, context: ActionContext, **kwargs: Any
785
+ ) -> dict[str, Any] | None:
786
+ """Mark a session status (current or parent)."""
787
+ return mark_session_status(
788
+ session_manager=context.session_manager,
789
+ session_id=context.session_id,
790
+ status=kwargs.get("status"),
791
+ target=kwargs.get("target", "current_session"),
792
+ )
793
+
794
+ async def _handle_switch_mode(
795
+ self, context: ActionContext, **kwargs: Any
796
+ ) -> dict[str, Any] | None:
797
+ """Signal the agent to switch modes (e.g., PLAN, ACT)."""
798
+ return switch_mode(kwargs.get("mode"))
799
+
800
+ def _format_turns_for_llm(self, turns: list[dict[str, Any]]) -> str:
801
+ """Format transcript turns for LLM analysis."""
802
+ return format_turns_for_llm(turns)
803
+
804
+ def _get_git_status(self) -> str:
805
+ """Get git status for current directory."""
806
+ return get_git_status()
807
+
808
+ def _get_recent_git_commits(self, max_commits: int = 10) -> list[dict[str, str]]:
809
+ """Get recent git commits with hash and message."""
810
+ return get_recent_git_commits(max_commits)
811
+
812
+ def _get_file_changes(self) -> str:
813
+ """Get detailed file changes from git."""
814
+ return get_file_changes()
815
+
816
+ async def _handle_capture_baseline_dirty_files(
817
+ self, context: ActionContext, **kwargs: Any
818
+ ) -> dict[str, Any] | None:
819
+ """Capture baseline dirty files at session start."""
820
+ # Get project path - prioritize session lookup over hook payload
821
+ project_path = None
822
+
823
+ # 1. Get from session's project (most reliable - session exists by now)
824
+ if context.session_id and context.session_manager:
825
+ session = context.session_manager.get(context.session_id)
826
+ if session and session.project_id:
827
+ from gobby.storage.projects import LocalProjectManager
828
+
829
+ project_mgr = LocalProjectManager(context.db)
830
+ project = project_mgr.get(session.project_id)
831
+ if project and project.repo_path:
832
+ project_path = project.repo_path
833
+
834
+ # 2. Fallback to event_data.cwd (from hook payload)
835
+ if not project_path and context.event_data:
836
+ project_path = context.event_data.get("cwd")
837
+
838
+ return await capture_baseline_dirty_files(
839
+ workflow_state=context.state,
840
+ project_path=project_path,
841
+ )
842
+
843
+ async def _handle_require_active_task(
844
+ self, context: ActionContext, **kwargs: Any
845
+ ) -> dict[str, Any] | None:
846
+ """Check for active task before allowing protected tools."""
847
+ # Get project_id from session for project-scoped task filtering
848
+ current_session = context.session_manager.get(context.session_id)
849
+ project_id = current_session.project_id if current_session else None
850
+
851
+ return await require_active_task(
852
+ task_manager=self.task_manager,
853
+ session_id=context.session_id,
854
+ config=context.config,
855
+ event_data=context.event_data,
856
+ project_id=project_id,
857
+ workflow_state=context.state,
858
+ session_manager=context.session_manager,
859
+ session_task_manager=context.session_task_manager,
860
+ )
861
+
862
+ async def _handle_require_commit_before_stop(
863
+ self, context: ActionContext, **kwargs: Any
864
+ ) -> dict[str, Any] | None:
865
+ """Block stop if task has uncommitted changes."""
866
+ # Get project path - prioritize session lookup over hook payload
867
+ project_path = None
868
+
869
+ # 1. Get from session's project (most reliable - session exists by now)
870
+ if context.session_id and context.session_manager:
871
+ session = context.session_manager.get(context.session_id)
872
+ if session and session.project_id:
873
+ from gobby.storage.projects import LocalProjectManager
874
+
875
+ project_mgr = LocalProjectManager(context.db)
876
+ project = project_mgr.get(session.project_id)
877
+ if project and project.repo_path:
878
+ project_path = project.repo_path
879
+
880
+ # 2. Fallback to event_data.cwd (from hook payload)
881
+ if not project_path and context.event_data:
882
+ project_path = context.event_data.get("cwd")
883
+
884
+ return await require_commit_before_stop(
885
+ workflow_state=context.state,
886
+ project_path=project_path,
887
+ task_manager=self.task_manager,
888
+ )
889
+
890
+ async def _handle_require_task_review_or_close_before_stop(
891
+ self, context: ActionContext, **kwargs: Any
892
+ ) -> dict[str, Any] | None:
893
+ """Block stop if task is still in_progress (regardless of dirty files)."""
894
+ # Get project_id from session for task reference resolution
895
+ project_id = None
896
+ session = context.session_manager.get(context.session_id)
897
+ if session:
898
+ project_id = session.project_id
899
+
900
+ return await require_task_review_or_close_before_stop(
901
+ workflow_state=context.state,
902
+ task_manager=self.task_manager,
903
+ project_id=project_id,
904
+ )
905
+
906
+ async def _handle_require_task_complete(
907
+ self, context: ActionContext, **kwargs: Any
908
+ ) -> dict[str, Any] | None:
909
+ """Check that a task (and its subtasks) are complete before allowing stop.
910
+
911
+ Supports:
912
+ - Single task ID: "#47"
913
+ - List of task IDs: ["#47", "#48"]
914
+ - Wildcard: "*" - work until no ready tasks remain
915
+ """
916
+ current_session = context.session_manager.get(context.session_id)
917
+ project_id = current_session.project_id if current_session else None
918
+
919
+ # Get task_id from kwargs - may be a template that needs resolving
920
+ task_spec = kwargs.get("task_id")
921
+
922
+ # If it's a template reference like "{{ variables.session_task }}", resolve it
923
+ if task_spec and "{{" in str(task_spec):
924
+ task_spec = context.template_engine.render(
925
+ str(task_spec),
926
+ {"variables": context.state.variables or {}},
927
+ )
928
+
929
+ # Handle different task_spec types:
930
+ # - None/empty: no enforcement
931
+ # - "*": wildcard - fetch ready tasks
932
+ # - list: multiple specific tasks
933
+ # - string: single task ID
934
+ task_ids: list[str] | None = None
935
+
936
+ if not task_spec:
937
+ return None
938
+ elif task_spec == "*":
939
+ # Wildcard: get all ready tasks for this project
940
+ if self.task_manager:
941
+ ready_tasks = self.task_manager.list_ready_tasks(
942
+ project_id=project_id,
943
+ limit=100,
944
+ )
945
+ task_ids = [t.id for t in ready_tasks]
946
+ if not task_ids:
947
+ # No ready tasks - allow stop
948
+ logger.debug("require_task_complete: Wildcard mode, no ready tasks")
949
+ return None
950
+ elif isinstance(task_spec, list):
951
+ task_ids = task_spec
952
+ else:
953
+ task_ids = [str(task_spec)]
954
+
955
+ return await require_task_complete(
956
+ task_manager=self.task_manager,
957
+ session_id=context.session_id,
958
+ task_ids=task_ids,
959
+ event_data=context.event_data,
960
+ project_id=project_id,
961
+ workflow_state=context.state,
962
+ )
963
+
964
+ async def _handle_validate_session_task_scope(
965
+ self, context: ActionContext, **kwargs: Any
966
+ ) -> dict[str, Any] | None:
967
+ """Validate that claimed task is within session_task scope.
968
+
969
+ When session_task is set in workflow state, this blocks claiming
970
+ tasks that are not descendants of session_task.
971
+ """
972
+ return await validate_session_task_scope(
973
+ task_manager=self.task_manager,
974
+ workflow_state=context.state,
975
+ event_data=context.event_data,
976
+ )
977
+
978
+ async def _handle_webhook(self, context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
979
+ """Execute a webhook HTTP request.
980
+
981
+ Args (via kwargs):
982
+ url: Target URL for the request (required unless webhook_id provided)
983
+ webhook_id: ID of a pre-configured webhook (alternative to url)
984
+ method: HTTP method (GET, POST, PUT, PATCH, DELETE), default: POST
985
+ headers: Request headers dict (supports ${secrets.VAR} interpolation)
986
+ payload: Request body as dict or string (supports template interpolation)
987
+ timeout: Request timeout in seconds (1-300), default: 30
988
+ retry: Retry configuration dict with:
989
+ - max_attempts: Max retry attempts (1-10), default: 3
990
+ - backoff_seconds: Initial backoff delay, default: 1
991
+ - retry_on_status: HTTP status codes to retry on
992
+ capture_response: Response capture config with:
993
+ - status_var: Variable name for status code
994
+ - body_var: Variable name for response body
995
+ - headers_var: Variable name for response headers
996
+ on_success: Step to transition to on success (2xx)
997
+ on_failure: Step to transition to on failure
998
+
999
+ Returns:
1000
+ Dict with success status, status_code, and captured response data.
1001
+ """
1002
+ try:
1003
+ # Parse WebhookAction from kwargs to validate config
1004
+ webhook_action = WebhookAction.from_dict(kwargs)
1005
+ except ValueError as e:
1006
+ logger.error(f"Invalid webhook action config: {e}")
1007
+ return {"success": False, "error": str(e)}
1008
+
1009
+ # Build context for variable interpolation
1010
+ interpolation_context: dict[str, Any] = {}
1011
+ if context.state.variables:
1012
+ interpolation_context["state"] = {"variables": context.state.variables}
1013
+ if context.state.artifacts:
1014
+ interpolation_context["artifacts"] = context.state.artifacts
1015
+
1016
+ # Get secrets from config if available
1017
+ secrets: dict[str, str] = {}
1018
+ if self.config:
1019
+ secrets = getattr(self.config, "webhook_secrets", {})
1020
+
1021
+ # Create executor with template engine for payload interpolation
1022
+ executor = WebhookExecutor(
1023
+ template_engine=context.template_engine,
1024
+ secrets=secrets,
1025
+ )
1026
+
1027
+ # Execute the webhook
1028
+ if webhook_action.url:
1029
+ result = await executor.execute(
1030
+ url=webhook_action.url,
1031
+ method=webhook_action.method,
1032
+ headers=webhook_action.headers,
1033
+ payload=webhook_action.payload,
1034
+ timeout=webhook_action.timeout,
1035
+ retry_config=webhook_action.retry.to_dict() if webhook_action.retry else None,
1036
+ context=interpolation_context,
1037
+ )
1038
+ elif webhook_action.webhook_id:
1039
+ # webhook_id execution requires a registry which would be configured
1040
+ # at the daemon level - for now we return an error if no registry
1041
+ logger.warning("webhook_id execution not yet supported without registry")
1042
+ return {"success": False, "error": "webhook_id requires configured webhook registry"}
1043
+ else:
1044
+ return {"success": False, "error": "Either url or webhook_id is required"}
1045
+
1046
+ # Capture response into workflow variables if configured
1047
+ if webhook_action.capture_response:
1048
+ if not context.state.variables:
1049
+ context.state.variables = {}
1050
+
1051
+ capture = webhook_action.capture_response
1052
+ if capture.status_var and result.status_code is not None:
1053
+ context.state.variables[capture.status_var] = result.status_code
1054
+ if capture.body_var and result.body is not None:
1055
+ # Try to parse as JSON, fall back to raw string
1056
+ json_body = result.json_body()
1057
+ context.state.variables[capture.body_var] = json_body if json_body else result.body
1058
+ if capture.headers_var and result.headers is not None:
1059
+ context.state.variables[capture.headers_var] = result.headers
1060
+
1061
+ # Log outcome
1062
+ if result.success:
1063
+ logger.info(
1064
+ f"Webhook {webhook_action.method} {webhook_action.url} succeeded: {result.status_code}"
1065
+ )
1066
+ else:
1067
+ logger.warning(
1068
+ f"Webhook {webhook_action.method} {webhook_action.url} failed: "
1069
+ f"{result.error or result.status_code}"
1070
+ )
1071
+
1072
+ return {
1073
+ "success": result.success,
1074
+ "status_code": result.status_code,
1075
+ "error": result.error,
1076
+ "body": result.body if result.success else None,
1077
+ }
1078
+
1079
+ # --- Stop Signal Actions ---
1080
+
1081
+ async def _handle_check_stop_signal(
1082
+ self, context: ActionContext, **kwargs: Any
1083
+ ) -> dict[str, Any] | None:
1084
+ """Check if a stop signal has been sent for this session.
1085
+
1086
+ Args (via kwargs):
1087
+ acknowledge: If True, acknowledge the signal (session will stop)
1088
+
1089
+ Returns:
1090
+ Dict with has_signal, signal details, and optional inject_context
1091
+ """
1092
+ return check_stop_signal(
1093
+ stop_registry=self.stop_registry,
1094
+ session_id=context.session_id,
1095
+ state=context.state,
1096
+ acknowledge=kwargs.get("acknowledge", False),
1097
+ )
1098
+
1099
+ async def _handle_request_stop(
1100
+ self, context: ActionContext, **kwargs: Any
1101
+ ) -> dict[str, Any] | None:
1102
+ """Request a session to stop (used by stuck detection, etc.).
1103
+
1104
+ Args (via kwargs):
1105
+ session_id: The session to signal (defaults to current session)
1106
+ source: Source of the request (default: "workflow")
1107
+ reason: Optional reason for the stop request
1108
+
1109
+ Returns:
1110
+ Dict with success status and signal details
1111
+ """
1112
+ target_session = kwargs.get("session_id", context.session_id)
1113
+ return request_stop(
1114
+ stop_registry=self.stop_registry,
1115
+ session_id=target_session,
1116
+ source=kwargs.get("source", "workflow"),
1117
+ reason=kwargs.get("reason"),
1118
+ )
1119
+
1120
+ async def _handle_clear_stop_signal(
1121
+ self, context: ActionContext, **kwargs: Any
1122
+ ) -> dict[str, Any] | None:
1123
+ """Clear any stop signal for a session.
1124
+
1125
+ Args (via kwargs):
1126
+ session_id: The session to clear (defaults to current session)
1127
+
1128
+ Returns:
1129
+ Dict with success status
1130
+ """
1131
+ target_session = kwargs.get("session_id", context.session_id)
1132
+ return clear_stop_signal(
1133
+ stop_registry=self.stop_registry,
1134
+ session_id=target_session,
1135
+ )
1136
+
1137
+ # --- Autonomous Execution Actions ---
1138
+
1139
+ async def _broadcast_autonomous_event(self, event: str, session_id: str, **kwargs: Any) -> None:
1140
+ """Helper to broadcast autonomous events via WebSocket.
1141
+
1142
+ Non-blocking fire-and-forget broadcast.
1143
+
1144
+ Args:
1145
+ event: Event type (task_started, stuck_detected, etc.)
1146
+ session_id: Session ID
1147
+ **kwargs: Additional event data
1148
+ """
1149
+ import asyncio
1150
+
1151
+ if not self.websocket_server:
1152
+ return
1153
+
1154
+ try:
1155
+ # Create non-blocking task for broadcast
1156
+ task = asyncio.create_task(
1157
+ self.websocket_server.broadcast_autonomous_event(
1158
+ event=event,
1159
+ session_id=session_id,
1160
+ **kwargs,
1161
+ )
1162
+ )
1163
+ # Add callback to log errors silently
1164
+ task.add_done_callback(
1165
+ lambda t: (
1166
+ logger.debug(f"Broadcast {event} failed: {t.exception()}")
1167
+ if t.exception()
1168
+ else None
1169
+ )
1170
+ )
1171
+ except Exception as e:
1172
+ logger.debug(f"Failed to schedule broadcast for {event}: {e}")
1173
+
1174
+ async def _handle_start_progress_tracking(
1175
+ self, context: ActionContext, **kwargs: Any
1176
+ ) -> dict[str, Any] | None:
1177
+ """Start progress tracking for a session."""
1178
+ result = start_progress_tracking(
1179
+ progress_tracker=self.progress_tracker,
1180
+ session_id=context.session_id,
1181
+ state=context.state,
1182
+ )
1183
+
1184
+ # Broadcast loop_started event
1185
+ if result and result.get("success"):
1186
+ await self._broadcast_autonomous_event(
1187
+ event="loop_started",
1188
+ session_id=context.session_id,
1189
+ )
1190
+
1191
+ return result
1192
+
1193
+ async def _handle_stop_progress_tracking(
1194
+ self, context: ActionContext, **kwargs: Any
1195
+ ) -> dict[str, Any] | None:
1196
+ """Stop progress tracking for a session."""
1197
+ result = stop_progress_tracking(
1198
+ progress_tracker=self.progress_tracker,
1199
+ session_id=context.session_id,
1200
+ state=context.state,
1201
+ keep_data=kwargs.get("keep_data", False),
1202
+ )
1203
+
1204
+ # Broadcast loop_stopped event
1205
+ if result and result.get("success"):
1206
+ await self._broadcast_autonomous_event(
1207
+ event="loop_stopped",
1208
+ session_id=context.session_id,
1209
+ final_summary=result.get("final_summary"),
1210
+ )
1211
+
1212
+ return result
1213
+
1214
+ async def _handle_record_progress(
1215
+ self, context: ActionContext, **kwargs: Any
1216
+ ) -> dict[str, Any] | None:
1217
+ """Record a progress event."""
1218
+ result = record_progress(
1219
+ progress_tracker=self.progress_tracker,
1220
+ session_id=context.session_id,
1221
+ progress_type=kwargs.get("progress_type", "tool_call"),
1222
+ tool_name=kwargs.get("tool_name"),
1223
+ details=kwargs.get("details"),
1224
+ )
1225
+
1226
+ # Broadcast progress_recorded event for high-value events
1227
+ if result and result.get("success") and result.get("event", {}).get("is_high_value"):
1228
+ await self._broadcast_autonomous_event(
1229
+ event="progress_recorded",
1230
+ session_id=context.session_id,
1231
+ progress_type=result.get("event", {}).get("type"),
1232
+ is_high_value=True,
1233
+ )
1234
+
1235
+ return result
1236
+
1237
+ async def _handle_detect_task_loop(
1238
+ self, context: ActionContext, **kwargs: Any
1239
+ ) -> dict[str, Any] | None:
1240
+ """Detect task selection loops."""
1241
+ result = detect_task_loop(
1242
+ stuck_detector=self.stuck_detector,
1243
+ session_id=context.session_id,
1244
+ state=context.state,
1245
+ )
1246
+
1247
+ # Broadcast stuck_detected if stuck
1248
+ if result and result.get("is_stuck"):
1249
+ await self._broadcast_autonomous_event(
1250
+ event="stuck_detected",
1251
+ session_id=context.session_id,
1252
+ layer="task_loop",
1253
+ reason=result.get("reason"),
1254
+ details=result.get("details"),
1255
+ )
1256
+
1257
+ return result
1258
+
1259
+ async def _handle_detect_stuck(
1260
+ self, context: ActionContext, **kwargs: Any
1261
+ ) -> dict[str, Any] | None:
1262
+ """Run full stuck detection (all layers)."""
1263
+ result = detect_stuck(
1264
+ stuck_detector=self.stuck_detector,
1265
+ session_id=context.session_id,
1266
+ state=context.state,
1267
+ )
1268
+
1269
+ # Broadcast stuck_detected if stuck
1270
+ if result and result.get("is_stuck"):
1271
+ await self._broadcast_autonomous_event(
1272
+ event="stuck_detected",
1273
+ session_id=context.session_id,
1274
+ layer=result.get("layer"),
1275
+ reason=result.get("reason"),
1276
+ suggested_action=result.get("suggested_action"),
1277
+ )
1278
+
1279
+ return result
1280
+
1281
+ async def _handle_record_task_selection(
1282
+ self, context: ActionContext, **kwargs: Any
1283
+ ) -> dict[str, Any] | None:
1284
+ """Record a task selection for loop detection."""
1285
+ task_id = kwargs.get("task_id", "")
1286
+ result = record_task_selection(
1287
+ stuck_detector=self.stuck_detector,
1288
+ session_id=context.session_id,
1289
+ task_id=task_id,
1290
+ context=kwargs.get("context"),
1291
+ )
1292
+
1293
+ # Broadcast task_started event
1294
+ if result and result.get("success"):
1295
+ await self._broadcast_autonomous_event(
1296
+ event="task_started",
1297
+ session_id=context.session_id,
1298
+ task_id=task_id,
1299
+ )
1300
+
1301
+ return result
1302
+
1303
+ async def _handle_get_progress_summary(
1304
+ self, context: ActionContext, **kwargs: Any
1305
+ ) -> dict[str, Any] | None:
1306
+ """Get a summary of progress for a session."""
1307
+ return get_progress_summary(
1308
+ progress_tracker=self.progress_tracker,
1309
+ session_id=context.session_id,
1310
+ )