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,164 @@
1
+ """
2
+ Premature stop handling for workflow engine.
3
+
4
+ Extracted from engine.py to reduce complexity.
5
+ Handles the case when a session tries to stop before the workflow's
6
+ exit condition is satisfied.
7
+ """
8
+
9
+ import logging
10
+ from pathlib import Path
11
+ from types import SimpleNamespace
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from gobby.hooks.events import HookEvent, HookResponse
15
+
16
+ if TYPE_CHECKING:
17
+ from .evaluator import ConditionEvaluator
18
+ from .loader import WorkflowLoader
19
+ from .state_manager import WorkflowStateManager
20
+ from .templates import TemplateEngine
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ async def check_premature_stop(
26
+ event: HookEvent,
27
+ context_data: dict[str, Any],
28
+ state_manager: "WorkflowStateManager",
29
+ loader: "WorkflowLoader",
30
+ evaluator: "ConditionEvaluator",
31
+ template_engine: "TemplateEngine | None",
32
+ ) -> HookResponse | None:
33
+ """
34
+ Check if an active step workflow should handle a premature stop.
35
+
36
+ Called on STOP events to evaluate whether the workflow's exit_condition
37
+ is met. If not met and workflow has on_premature_stop defined, returns
38
+ an appropriate response.
39
+
40
+ Args:
41
+ event: The STOP hook event
42
+ context_data: Shared context data including session variables
43
+ state_manager: Workflow state manager
44
+ loader: Workflow definition loader
45
+ evaluator: Condition evaluator
46
+ template_engine: Template engine for rendering messages
47
+
48
+ Returns:
49
+ HookResponse if premature stop detected, None otherwise
50
+ """
51
+ session_id = event.metadata.get("_platform_session_id")
52
+ if not session_id:
53
+ return None
54
+
55
+ # Check if there's an active step workflow
56
+ state = state_manager.get_state(session_id)
57
+ if not state:
58
+ return None
59
+
60
+ # Skip lifecycle-only states
61
+ if state.workflow_name == "__lifecycle__":
62
+ return None
63
+
64
+ # Load the workflow definition
65
+ project_path = Path(event.cwd) if event.cwd else None
66
+ workflow = loader.load_workflow(state.workflow_name, project_path=project_path)
67
+ if not workflow:
68
+ logger.warning(f"Workflow '{state.workflow_name}' not found for premature stop check")
69
+ return None
70
+
71
+ # Check if workflow has exit_condition and on_premature_stop
72
+ if not workflow.exit_condition:
73
+ return None
74
+
75
+ # Build evaluation context
76
+ # Use SimpleNamespace for variables so dot notation works (variables.session_task)
77
+ eval_context: dict[str, Any] = {
78
+ "workflow_state": state,
79
+ "state": state,
80
+ "variables": SimpleNamespace(**state.variables),
81
+ "current_step": state.step,
82
+ }
83
+ # Add session variables to context
84
+ eval_context.update(context_data)
85
+
86
+ # Evaluate the exit condition
87
+ exit_condition_met = evaluator.evaluate(workflow.exit_condition, eval_context)
88
+
89
+ if exit_condition_met:
90
+ logger.debug(f"Workflow '{workflow.name}' exit_condition met, allowing stop")
91
+ return None
92
+
93
+ # Exit condition not met - check for premature stop handler
94
+ if not workflow.on_premature_stop:
95
+ logger.debug(
96
+ f"Workflow '{workflow.name}' exit_condition not met but no on_premature_stop defined"
97
+ )
98
+ return None
99
+
100
+ # Failsafe: check if we've exceeded max stop attempts
101
+ # Counter is stored in variables and resets on BEFORE_AGENT (user prompt)
102
+ stop_count = state.variables.get("_premature_stop_count", 0) + 1
103
+ max_attempts = state.variables.get("premature_stop_max_attempts", 3)
104
+
105
+ # Update and persist the counter
106
+ state.variables["_premature_stop_count"] = stop_count
107
+ state_manager.save_state(state)
108
+
109
+ if max_attempts > 0 and stop_count >= max_attempts:
110
+ logger.warning(
111
+ f"Premature stop failsafe triggered for workflow '{workflow.name}': "
112
+ f"stop_count={stop_count} >= max_attempts={max_attempts}"
113
+ )
114
+ return HookResponse(
115
+ decision="allow",
116
+ context=(
117
+ f"⚠️ **Failsafe Exit**: Allowing stop after {stop_count} blocked attempts. "
118
+ f"Task may be incomplete."
119
+ ),
120
+ )
121
+
122
+ # Handle premature stop based on action type
123
+ handler = workflow.on_premature_stop
124
+
125
+ # Render the message through template engine (supports Jinja2 variables)
126
+ rendered_message = handler.message
127
+ if template_engine and handler.message:
128
+ render_context = {
129
+ "variables": state.variables,
130
+ "state": state,
131
+ "workflow": workflow,
132
+ }
133
+ try:
134
+ rendered_message = template_engine.render(handler.message, render_context)
135
+ except Exception as e:
136
+ logger.warning(f"Failed to render on_premature_stop message: {e}")
137
+ # Fall back to unrendered message
138
+
139
+ logger.info(
140
+ f"Premature stop detected for workflow '{workflow.name}': "
141
+ f"action={handler.action}, message={rendered_message[:100] if rendered_message else None}..., "
142
+ f"attempt {stop_count}/{max_attempts}"
143
+ )
144
+
145
+ if handler.action == "block":
146
+ return HookResponse(
147
+ decision="block",
148
+ reason=rendered_message,
149
+ )
150
+ elif handler.action == "warn":
151
+ return HookResponse(
152
+ decision="allow",
153
+ context=f"⚠️ **Warning**: {rendered_message}",
154
+ )
155
+ else: # guide_continuation (default)
156
+ return HookResponse(
157
+ decision="block",
158
+ reason=rendered_message,
159
+ context=(
160
+ f"📋 **Task Incomplete**\n\n"
161
+ f"{rendered_message}\n\n"
162
+ f"The workflow exit condition `{workflow.exit_condition}` is not yet satisfied."
163
+ ),
164
+ )
@@ -0,0 +1,139 @@
1
+ """Session lifecycle workflow actions.
2
+
3
+ Extracted from actions.py as part of strangler fig decomposition.
4
+ These functions handle session status updates, mode switching, and session chaining.
5
+ """
6
+
7
+ import logging
8
+ import shlex
9
+ import subprocess # nosec B404 - subprocess needed for session spawning
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def start_new_session(
16
+ session_manager: Any,
17
+ session_id: str,
18
+ command: str | None = None,
19
+ args: list[str] | str | None = None,
20
+ prompt: str | None = None,
21
+ cwd: str | None = None,
22
+ ) -> dict[str, Any]:
23
+ """Start a new CLI session (chaining).
24
+
25
+ Args:
26
+ session_manager: The session manager instance
27
+ session_id: Current session ID
28
+ command: CLI command to run (default: auto-detect from source)
29
+ args: List of arguments or string to split
30
+ prompt: Initial prompt/context to inject
31
+ cwd: Working directory (default: current session's cwd)
32
+
33
+ Returns:
34
+ Dict with started_new_session, pid, and command, or error
35
+ """
36
+ session = session_manager.get(session_id)
37
+ if not session:
38
+ return {"error": "Session not found"}
39
+
40
+ # Determine command
41
+ if not command:
42
+ source = getattr(session, "source", "claude")
43
+ if source == "claude":
44
+ command = "claude"
45
+ elif source == "antigravity":
46
+ command = "claude" # Antigravity uses Claude Code
47
+ elif source == "gemini":
48
+ command = "gemini"
49
+ else:
50
+ command = "claude" # Default fallthrough
51
+
52
+ # Parse args
53
+ cmd_args: list[str] = []
54
+ if args:
55
+ if isinstance(args, str):
56
+ cmd_args = shlex.split(args)
57
+ else:
58
+ cmd_args = list(args)
59
+
60
+ # Determine working directory
61
+ if not cwd:
62
+ cwd = getattr(session, "project_path", None) or "."
63
+
64
+ logger.info(f"Starting new session: {command} {cmd_args} in {cwd}")
65
+
66
+ try:
67
+ full_cmd = [command] + cmd_args
68
+
69
+ # Inject prompt via -p flag for Claude/Gemini if supported
70
+ if prompt and command in ["claude", "gemini"]:
71
+ full_cmd.extend(["-p", prompt])
72
+
73
+ proc = subprocess.Popen( # nosec B603 - cmd built from config, no shell
74
+ full_cmd,
75
+ cwd=cwd,
76
+ stdout=subprocess.DEVNULL,
77
+ stderr=subprocess.DEVNULL,
78
+ stdin=subprocess.DEVNULL,
79
+ start_new_session=True, # Detach
80
+ )
81
+
82
+ logger.info(f"Spawned process {proc.pid}")
83
+ return {"started_new_session": True, "pid": proc.pid, "command": str(full_cmd)}
84
+
85
+ except Exception as e:
86
+ logger.error(f"Failed to start new session: {e}", exc_info=True)
87
+ return {"error": str(e)}
88
+
89
+
90
+ def mark_session_status(
91
+ session_manager: Any,
92
+ session_id: str,
93
+ status: str | None = None,
94
+ target: str = "current_session",
95
+ ) -> dict[str, Any]:
96
+ """Mark a session status (current or parent).
97
+
98
+ Args:
99
+ session_manager: The session manager instance
100
+ session_id: Current session ID
101
+ status: New status to set
102
+ target: "current_session" or "parent_session"
103
+
104
+ Returns:
105
+ Dict with status_updated, session_id, and status, or error
106
+ """
107
+ if not status:
108
+ return {"error": "Missing status"}
109
+
110
+ target_session_id = session_id
111
+ if target == "parent_session":
112
+ current_session = session_manager.get(session_id)
113
+ if current_session and current_session.parent_session_id:
114
+ target_session_id = current_session.parent_session_id
115
+ else:
116
+ return {"error": "No parent session linked"}
117
+
118
+ session_manager.update_status(target_session_id, status)
119
+ return {"status_updated": True, "session_id": target_session_id, "status": status}
120
+
121
+
122
+ def switch_mode(mode: str | None = None) -> dict[str, Any]:
123
+ """Signal the agent to switch modes (e.g., PLAN, ACT).
124
+
125
+ Args:
126
+ mode: The mode to switch to
127
+
128
+ Returns:
129
+ Dict with inject_context and mode_switch, or error
130
+ """
131
+ if not mode:
132
+ return {"error": "Missing mode"}
133
+
134
+ message = (
135
+ f"SYSTEM: SWITCH MODE TO {mode.upper()}\n"
136
+ f"You are now in {mode.upper()} mode. Adjust your behavior accordingly."
137
+ )
138
+
139
+ return {"inject_context": message, "mode_switch": mode}
@@ -0,0 +1,123 @@
1
+ """Workflow state management actions.
2
+
3
+ Extracted from actions.py as part of strangler fig decomposition.
4
+ These functions handle workflow state persistence and variable management.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def load_workflow_state(db: Any, session_id: str, state: Any) -> dict[str, Any]:
14
+ """Load workflow state from DB into the provided state object.
15
+
16
+ Args:
17
+ db: Database instance
18
+ session_id: Session ID to load state for
19
+ state: WorkflowState object to update
20
+
21
+ Returns:
22
+ Dict with state_loaded boolean
23
+ """
24
+ from gobby.workflows.state_manager import WorkflowStateManager
25
+
26
+ state_manager = WorkflowStateManager(db)
27
+ loaded_state = state_manager.get_state(session_id)
28
+
29
+ if loaded_state:
30
+ # Copy attributes from loaded state to existing state object
31
+ for field in loaded_state.model_fields:
32
+ val = getattr(loaded_state, field)
33
+ setattr(state, field, val)
34
+ return {"state_loaded": True}
35
+
36
+ return {"state_loaded": False}
37
+
38
+
39
+ def save_workflow_state(db: Any, state: Any) -> dict[str, Any]:
40
+ """Save workflow state to DB.
41
+
42
+ Args:
43
+ db: Database instance
44
+ state: WorkflowState object to save
45
+
46
+ Returns:
47
+ Dict with state_saved boolean
48
+ """
49
+ from gobby.workflows.state_manager import WorkflowStateManager
50
+
51
+ state_manager = WorkflowStateManager(db)
52
+ state_manager.save_state(state)
53
+ return {"state_saved": True}
54
+
55
+
56
+ def set_variable(state: Any, name: str | None, value: Any) -> dict[str, Any] | None:
57
+ """Set a workflow variable.
58
+
59
+ Args:
60
+ state: WorkflowState object
61
+ name: Variable name
62
+ value: Variable value
63
+
64
+ Returns:
65
+ Dict with variable_set and value, or None if no name
66
+ """
67
+ if not name:
68
+ return None
69
+
70
+ if not state.variables:
71
+ state.variables = {}
72
+
73
+ state.variables[name] = value
74
+ return {"variable_set": name, "value": value}
75
+
76
+
77
+ def increment_variable(
78
+ state: Any, name: str | None, amount: int | float = 1
79
+ ) -> dict[str, Any] | None:
80
+ """Increment a numeric workflow variable.
81
+
82
+ Args:
83
+ state: WorkflowState object
84
+ name: Variable name
85
+ amount: Amount to increment by (default: 1)
86
+
87
+ Returns:
88
+ Dict with variable_incremented and value, or None if no name
89
+ """
90
+ if not name:
91
+ return None
92
+
93
+ if not state.variables:
94
+ state.variables = {}
95
+
96
+ current = state.variables.get(name, 0)
97
+ if not isinstance(current, (int, float)):
98
+ logger.error(
99
+ f"increment_variable: Variable '{name}' is not numeric, got {type(current).__name__}: {current}"
100
+ )
101
+ raise TypeError(
102
+ f"Cannot increment non-numeric variable '{name}': "
103
+ f"expected int or float, got {type(current).__name__} with value {current!r}"
104
+ )
105
+
106
+ new_value = current + amount
107
+ state.variables[name] = new_value
108
+ return {"variable_incremented": name, "value": new_value}
109
+
110
+
111
+ def mark_loop_complete(state: Any) -> dict[str, Any]:
112
+ """Mark the autonomous loop as complete.
113
+
114
+ Args:
115
+ state: WorkflowState object
116
+
117
+ Returns:
118
+ Dict with loop_marked_complete boolean
119
+ """
120
+ if not state.variables:
121
+ state.variables = {}
122
+ state.variables["stop_reason"] = "completed"
123
+ return {"loop_marked_complete": True}
@@ -0,0 +1,104 @@
1
+ import json
2
+ import logging
3
+ from datetime import UTC, datetime
4
+
5
+ from gobby.storage.database import DatabaseProtocol
6
+
7
+ from .definitions import WorkflowState
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class WorkflowStateManager:
13
+ """
14
+ Manages persistence of WorkflowState and Handoffs.
15
+ """
16
+
17
+ def __init__(self, db: DatabaseProtocol):
18
+ self.db = db
19
+
20
+ def get_state(self, session_id: str) -> WorkflowState | None:
21
+ row = self.db.fetchone("SELECT * FROM workflow_states WHERE session_id = ?", (session_id,))
22
+ if not row:
23
+ return None
24
+
25
+ try:
26
+ return WorkflowState(
27
+ session_id=row["session_id"],
28
+ workflow_name=row["workflow_name"],
29
+ step=row["step"],
30
+ step_entered_at=(
31
+ datetime.fromisoformat(row["step_entered_at"])
32
+ if row["step_entered_at"]
33
+ else datetime.now(UTC)
34
+ ),
35
+ step_action_count=row["step_action_count"],
36
+ total_action_count=row["total_action_count"],
37
+ artifacts=json.loads(row["artifacts"]) if row["artifacts"] else {},
38
+ observations=json.loads(row["observations"]) if row["observations"] else [],
39
+ reflection_pending=bool(row["reflection_pending"]),
40
+ context_injected=bool(row["context_injected"]),
41
+ variables=json.loads(row["variables"]) if row["variables"] else {},
42
+ task_list=json.loads(row["task_list"]) if row["task_list"] else None,
43
+ current_task_index=row["current_task_index"],
44
+ files_modified_this_task=row["files_modified_this_task"],
45
+ updated_at=(
46
+ datetime.fromisoformat(row["updated_at"])
47
+ if row["updated_at"]
48
+ else datetime.now(UTC)
49
+ ),
50
+ )
51
+ except Exception as e:
52
+ logger.error(
53
+ f"Failed to parse workflow state for session {session_id}: {e}", exc_info=True
54
+ )
55
+ return None
56
+
57
+ def save_state(self, state: WorkflowState) -> None:
58
+ """Upsert workflow state."""
59
+ self.db.execute(
60
+ """
61
+ INSERT INTO workflow_states (
62
+ session_id, workflow_name, step, step_entered_at,
63
+ step_action_count, total_action_count, artifacts,
64
+ observations, reflection_pending, context_injected, variables,
65
+ task_list, current_task_index, files_modified_this_task,
66
+ updated_at
67
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
68
+ ON CONFLICT(session_id) DO UPDATE SET
69
+ workflow_name = excluded.workflow_name,
70
+ step = excluded.step,
71
+ step_entered_at = excluded.step_entered_at,
72
+ step_action_count = excluded.step_action_count,
73
+ total_action_count = excluded.total_action_count,
74
+ artifacts = excluded.artifacts,
75
+ observations = excluded.observations,
76
+ reflection_pending = excluded.reflection_pending,
77
+ context_injected = excluded.context_injected,
78
+ variables = excluded.variables,
79
+ task_list = excluded.task_list,
80
+ current_task_index = excluded.current_task_index,
81
+ files_modified_this_task = excluded.files_modified_this_task,
82
+ updated_at = excluded.updated_at
83
+ """,
84
+ (
85
+ state.session_id,
86
+ state.workflow_name,
87
+ state.step,
88
+ state.step_entered_at.isoformat(),
89
+ state.step_action_count,
90
+ state.total_action_count,
91
+ json.dumps(state.artifacts),
92
+ json.dumps(state.observations),
93
+ 1 if state.reflection_pending else 0,
94
+ 1 if state.context_injected else 0,
95
+ json.dumps(state.variables),
96
+ json.dumps(state.task_list) if state.task_list else None,
97
+ state.current_task_index,
98
+ state.files_modified_this_task,
99
+ datetime.now(UTC).isoformat(),
100
+ ),
101
+ )
102
+
103
+ def delete_state(self, session_id: str) -> None:
104
+ self.db.execute("DELETE FROM workflow_states WHERE session_id = ?", (session_id,))
@@ -0,0 +1,163 @@
1
+ """Stop signal workflow actions for autonomous execution.
2
+
3
+ These actions enable workflows to check for and respond to stop signals
4
+ sent by external systems (HTTP, WebSocket, CLI, MCP).
5
+ """
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from gobby.autonomous.stop_registry import StopRegistry
12
+ from gobby.workflows.definitions import WorkflowState
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def check_stop_signal(
18
+ stop_registry: "StopRegistry | None",
19
+ session_id: str,
20
+ state: "WorkflowState",
21
+ acknowledge: bool = False,
22
+ ) -> dict[str, Any]:
23
+ """Check if a stop signal has been sent for this session.
24
+
25
+ This action can be used in workflow transitions or as a periodic check
26
+ during autonomous execution.
27
+
28
+ Args:
29
+ stop_registry: StopRegistry instance for checking signals
30
+ session_id: The session to check
31
+ state: Current workflow state (updated with signal info)
32
+ acknowledge: If True, acknowledge the signal (session will stop)
33
+
34
+ Returns:
35
+ Dict with:
36
+ - has_signal: True if there's a pending stop signal
37
+ - signal: Signal details if present (source, reason, requested_at)
38
+ - acknowledged: True if the signal was acknowledged
39
+ - inject_context: Optional message about the stop signal
40
+ """
41
+ if not stop_registry:
42
+ logger.warning("No stop_registry available, cannot check stop signal")
43
+ return {"has_signal": False}
44
+
45
+ signal = stop_registry.get_signal(session_id)
46
+
47
+ if not signal or not signal.is_pending:
48
+ return {"has_signal": False}
49
+
50
+ # Store signal info in workflow variables
51
+ state.variables["_stop_signal_pending"] = True
52
+ state.variables["_stop_signal_source"] = signal.source
53
+ state.variables["_stop_signal_reason"] = signal.reason
54
+
55
+ result: dict[str, Any] = {
56
+ "has_signal": True,
57
+ "signal": {
58
+ "source": signal.source,
59
+ "reason": signal.reason,
60
+ "requested_at": signal.requested_at.isoformat(),
61
+ },
62
+ }
63
+
64
+ if acknowledge:
65
+ stop_registry.acknowledge(session_id)
66
+ result["acknowledged"] = True
67
+ result["inject_context"] = (
68
+ f"🛑 **Stop Signal Received**\n\n"
69
+ f"Source: {signal.source}\n"
70
+ f"Reason: {signal.reason or 'No reason provided'}\n\n"
71
+ f"The session will stop gracefully."
72
+ )
73
+ logger.info(f"Stop signal acknowledged for session {session_id}")
74
+ else:
75
+ result["acknowledged"] = False
76
+ result["inject_context"] = (
77
+ f"⚠️ **Stop Signal Pending**\n\n"
78
+ f"A stop signal was received from {signal.source}.\n"
79
+ f"Reason: {signal.reason or 'No reason provided'}\n\n"
80
+ f"Complete current work and prepare to stop."
81
+ )
82
+
83
+ return result
84
+
85
+
86
+ def has_stop_signal(stop_registry: "StopRegistry | None", session_id: str) -> bool:
87
+ """Condition function to check if a stop signal is pending.
88
+
89
+ Use this in workflow transition conditions:
90
+
91
+ ```yaml
92
+ transitions:
93
+ - to: stopping
94
+ when: "has_stop_signal(session.id)"
95
+ ```
96
+
97
+ Args:
98
+ stop_registry: StopRegistry instance
99
+ session_id: The session to check
100
+
101
+ Returns:
102
+ True if there's a pending stop signal
103
+ """
104
+ if not stop_registry:
105
+ return False
106
+ return stop_registry.has_pending_signal(session_id)
107
+
108
+
109
+ def request_stop(
110
+ stop_registry: "StopRegistry | None",
111
+ session_id: str,
112
+ source: str = "workflow",
113
+ reason: str | None = None,
114
+ ) -> dict[str, Any]:
115
+ """Request a session to stop (can be used by stuck detection).
116
+
117
+ Args:
118
+ stop_registry: StopRegistry instance
119
+ session_id: The session to signal
120
+ source: Source of the request (workflow, stuck_detection, etc.)
121
+ reason: Optional reason for the stop request
122
+
123
+ Returns:
124
+ Dict with success status and signal details
125
+ """
126
+ if not stop_registry:
127
+ logger.warning("No stop_registry available, cannot request stop")
128
+ return {"success": False, "error": "No stop registry available"}
129
+
130
+ signal = stop_registry.signal_stop(session_id, source, reason)
131
+
132
+ return {
133
+ "success": True,
134
+ "signal": {
135
+ "session_id": signal.session_id,
136
+ "source": signal.source,
137
+ "reason": signal.reason,
138
+ "requested_at": signal.requested_at.isoformat(),
139
+ },
140
+ }
141
+
142
+
143
+ def clear_stop_signal(
144
+ stop_registry: "StopRegistry | None",
145
+ session_id: str,
146
+ ) -> dict[str, Any]:
147
+ """Clear any stop signal for a session.
148
+
149
+ Use this after a session has fully stopped or when the signal
150
+ should be cancelled.
151
+
152
+ Args:
153
+ stop_registry: StopRegistry instance
154
+ session_id: The session to clear
155
+
156
+ Returns:
157
+ Dict with success status
158
+ """
159
+ if not stop_registry:
160
+ return {"success": False, "error": "No stop registry available"}
161
+
162
+ cleared = stop_registry.clear(session_id)
163
+ return {"success": True, "cleared": cleared}