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,669 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from .definitions import WorkflowState
9
+
10
+ if TYPE_CHECKING:
11
+ from .webhook_executor import WebhookExecutor
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Approval keywords (case-insensitive)
16
+ APPROVAL_KEYWORDS = {"yes", "approve", "approved", "proceed", "continue", "ok", "okay", "y"}
17
+ REJECTION_KEYWORDS = {"no", "reject", "rejected", "stop", "cancel", "abort", "n"}
18
+
19
+
20
+ def is_task_complete(task: Any) -> bool:
21
+ """
22
+ Check if a task counts as complete for workflow purposes.
23
+
24
+ A task is complete if:
25
+ - status is 'closed', OR
26
+ - status is 'review' AND requires_user_review is False
27
+ (agent marked for visibility but doesn't need user sign-off)
28
+
29
+ Tasks in 'review' with requires_user_review=True are NOT complete
30
+ because they're awaiting user approval.
31
+
32
+ Args:
33
+ task: Task object with status and requires_user_review attributes
34
+
35
+ Returns:
36
+ True if task is complete, False otherwise
37
+ """
38
+ if task.status == "closed":
39
+ return True
40
+ requires_user_review = getattr(task, "requires_user_review", False)
41
+ if task.status == "review" and not requires_user_review:
42
+ return True
43
+ return False
44
+
45
+
46
+ def task_needs_user_review(task_manager: Any, task_id: str | None) -> bool:
47
+ """
48
+ Check if a task is awaiting user review (in review + HITL flag).
49
+
50
+ Used in workflow transition conditions like:
51
+ when: "task_needs_user_review(variables.session_task)"
52
+
53
+ Args:
54
+ task_manager: LocalTaskManager instance for querying tasks
55
+ task_id: Task ID to check
56
+
57
+ Returns:
58
+ True if task is in 'review' status AND has requires_user_review=True.
59
+ Returns False if task_id is None or task not found.
60
+ """
61
+ if not task_id or not task_manager:
62
+ return False
63
+
64
+ task = task_manager.get_task(task_id)
65
+ if not task:
66
+ return False
67
+
68
+ return bool(task.status == "review" and getattr(task, "requires_user_review", False))
69
+
70
+
71
+ def task_tree_complete(task_manager: Any, task_id: str | list[str] | None) -> bool:
72
+ """
73
+ Check if a task and all its subtasks are complete.
74
+
75
+ A task is complete if:
76
+ - status is 'closed', OR
77
+ - status is 'review' AND requires_user_review is False
78
+
79
+ Used in workflow transition conditions like:
80
+ when: "task_tree_complete(variables.session_task)"
81
+
82
+ Args:
83
+ task_manager: LocalTaskManager instance for querying tasks
84
+ task_id: Single task ID, list of task IDs, or None
85
+
86
+ Returns:
87
+ True if all tasks and their subtasks are complete, False otherwise.
88
+ Returns True if task_id is None (no task to check).
89
+ """
90
+ if not task_id:
91
+ return True
92
+
93
+ if not task_manager:
94
+ logger.warning("task_tree_complete: No task_manager available")
95
+ return False
96
+
97
+ # Normalize to list
98
+ task_ids = [task_id] if isinstance(task_id, str) else task_id
99
+
100
+ for tid in task_ids:
101
+ # Get the task itself
102
+ task = task_manager.get_task(tid)
103
+ if not task:
104
+ logger.warning(f"task_tree_complete: Task '{tid}' not found")
105
+ return False
106
+
107
+ # Check if main task is complete
108
+ if not is_task_complete(task):
109
+ logger.debug(f"task_tree_complete: Task '{tid}' is not complete (status={task.status})")
110
+ return False
111
+
112
+ # Check all subtasks recursively
113
+ if not _subtasks_complete(task_manager, tid):
114
+ return False
115
+
116
+ return True
117
+
118
+
119
+ def _subtasks_complete(task_manager: Any, parent_id: str) -> bool:
120
+ """Recursively check if all subtasks are complete."""
121
+ subtasks = task_manager.list_tasks(parent_task_id=parent_id)
122
+
123
+ for subtask in subtasks:
124
+ if not is_task_complete(subtask):
125
+ logger.debug(
126
+ f"_subtasks_complete: Subtask '{subtask.id}' is not complete (status={subtask.status})"
127
+ )
128
+ return False
129
+
130
+ # Recursively check subtasks of this subtask
131
+ if not _subtasks_complete(task_manager, subtask.id):
132
+ return False
133
+
134
+ return True
135
+
136
+
137
+ @dataclass
138
+ class ApprovalCheckResult:
139
+ """Result of checking a user_approval condition."""
140
+
141
+ needs_approval: bool = False # True if we need to request approval
142
+ is_approved: bool = False # True if user approved
143
+ is_rejected: bool = False # True if user rejected
144
+ is_timed_out: bool = False # True if approval timed out
145
+ condition_id: str | None = None # ID of the condition
146
+ prompt: str | None = None # Prompt to show user
147
+ timeout_seconds: int | None = None # Timeout value
148
+
149
+
150
+ def check_approval_response(user_input: str) -> str | None:
151
+ """
152
+ Check if user input contains an approval or rejection keyword.
153
+
154
+ Returns:
155
+ "approved" if approval keyword found
156
+ "rejected" if rejection keyword found
157
+ None if no keyword found
158
+ """
159
+ # Normalize input - check if entire input is a keyword or starts with one
160
+ normalized = user_input.strip().lower()
161
+
162
+ # Check exact match first
163
+ if normalized in APPROVAL_KEYWORDS:
164
+ return "approved"
165
+ if normalized in REJECTION_KEYWORDS:
166
+ return "rejected"
167
+
168
+ # Check if starts with keyword (e.g., "yes, let's proceed")
169
+ # Strip common punctuation from first word
170
+ first_word = normalized.split()[0].rstrip(",.!?:;") if normalized else ""
171
+ if first_word in APPROVAL_KEYWORDS:
172
+ return "approved"
173
+ if first_word in REJECTION_KEYWORDS:
174
+ return "rejected"
175
+
176
+ return None
177
+
178
+
179
+ class ConditionEvaluator:
180
+ """
181
+ Evaluates 'when' conditions in workflows.
182
+ Supports simple boolean logic and variable access.
183
+ """
184
+
185
+ def __init__(self) -> None:
186
+ """Initialize the condition evaluator."""
187
+ self._plugin_conditions: dict[str, Any] = {}
188
+ self._task_manager: Any = None
189
+ self._stop_registry: Any = None
190
+ self._webhook_executor: WebhookExecutor | None = None
191
+
192
+ def register_task_manager(self, task_manager: Any) -> None:
193
+ """
194
+ Register a task manager for task-related condition helpers.
195
+
196
+ This enables the task_tree_complete() function in workflow conditions.
197
+
198
+ Args:
199
+ task_manager: LocalTaskManager instance
200
+ """
201
+ self._task_manager = task_manager
202
+ logger.debug("ConditionEvaluator: task_manager registered")
203
+
204
+ def register_stop_registry(self, stop_registry: Any) -> None:
205
+ """
206
+ Register a stop registry for stop signal condition helpers.
207
+
208
+ This enables the has_stop_signal() function in workflow conditions.
209
+
210
+ Args:
211
+ stop_registry: StopRegistry instance
212
+ """
213
+ self._stop_registry = stop_registry
214
+ logger.debug("ConditionEvaluator: stop_registry registered")
215
+
216
+ def register_webhook_executor(self, webhook_executor: WebhookExecutor | None) -> None:
217
+ """
218
+ Register a webhook executor for webhook condition evaluation.
219
+
220
+ This enables webhook conditions in workflow transitions.
221
+
222
+ Args:
223
+ webhook_executor: WebhookExecutor instance
224
+ """
225
+ self._webhook_executor = webhook_executor
226
+ logger.debug("ConditionEvaluator: webhook_executor registered")
227
+
228
+ def register_plugin_conditions(self, plugin_registry: Any) -> None:
229
+ """
230
+ Register conditions from loaded plugins.
231
+
232
+ Conditions are registered with the naming convention:
233
+ plugin_<plugin_name>_<condition_name>
234
+
235
+ These can be called in 'when' clauses like:
236
+ when: "plugin_my_plugin_passes_lint()"
237
+
238
+ Args:
239
+ plugin_registry: PluginRegistry instance containing loaded plugins.
240
+ """
241
+ if plugin_registry is None:
242
+ return
243
+
244
+ for plugin_name, plugin in plugin_registry._plugins.items():
245
+ # Sanitize plugin name for use as identifier
246
+ safe_name = plugin_name.replace("-", "_").replace(".", "_")
247
+ for condition_name, evaluator in plugin._conditions.items():
248
+ full_name = f"plugin_{safe_name}_{condition_name}"
249
+ self._plugin_conditions[full_name] = evaluator
250
+ logger.debug(f"Registered plugin condition: {full_name}")
251
+
252
+ def evaluate(self, condition: str, context: dict[str, Any]) -> bool:
253
+ """
254
+ Evaluate a condition string against a context dictionary.
255
+
256
+ Args:
257
+ condition: The condition string (e.g., "phase_action_count > 5")
258
+ context: Dictionary containing Available variables (state, event, etc.)
259
+
260
+ Returns:
261
+ Boolean result of the evaluation.
262
+ """
263
+ if not condition:
264
+ return True
265
+
266
+ try:
267
+ # SAFETY: Using eval() is risky but standard for this type of flexibility until
268
+ # we implement a proper expression parser. We restrict globals to builtins logic.
269
+ # In a production environment, we should use a safer parser like `simpleeval` or `jinja2`.
270
+ # For this MVP, we rely on the context being controlled.
271
+
272
+ # Simple sanitization/safety check could go here
273
+
274
+ # Allow common helpers
275
+ allowed_globals = {
276
+ "__builtins__": {},
277
+ "len": len,
278
+ "bool": bool,
279
+ "str": str,
280
+ "int": int,
281
+ "list": list,
282
+ "dict": dict,
283
+ "None": None,
284
+ "True": True,
285
+ "False": False,
286
+ }
287
+
288
+ # Add plugin conditions as callable functions
289
+ allowed_globals.update(self._plugin_conditions)
290
+
291
+ # Add task-related helpers (bind task_manager via closure)
292
+ if self._task_manager:
293
+
294
+ def _task_tree_complete_wrapper(task_id: str | list[str] | None) -> bool:
295
+ # Helper wrapper to match types
296
+ return task_tree_complete(self._task_manager, task_id)
297
+
298
+ allowed_globals["task_tree_complete"] = _task_tree_complete_wrapper
299
+
300
+ def _task_needs_user_review_wrapper(task_id: str | None) -> bool:
301
+ # Helper wrapper for HITL check
302
+ return task_needs_user_review(self._task_manager, task_id)
303
+
304
+ allowed_globals["task_needs_user_review"] = _task_needs_user_review_wrapper
305
+ else:
306
+ # Provide no-ops when no task_manager
307
+ allowed_globals["task_tree_complete"] = lambda task_id: True
308
+ allowed_globals["task_needs_user_review"] = lambda task_id: False
309
+
310
+ # Add stop signal helpers (bind stop_registry via closure)
311
+ if self._stop_registry:
312
+ allowed_globals["has_stop_signal"] = lambda session_id: (
313
+ self._stop_registry.has_pending_signal(session_id)
314
+ )
315
+ else:
316
+ # Provide a no-op that returns False when no stop_registry
317
+ allowed_globals["has_stop_signal"] = lambda session_id: False
318
+
319
+ # Add MCP call tracking helper (for meeseeks workflow gates)
320
+ def _mcp_called(server: str, tool: str | None = None) -> bool:
321
+ """Check if MCP tool was called successfully.
322
+
323
+ Used in workflow conditions like:
324
+ when: "mcp_called('gobby-memory', 'recall')"
325
+ when: "mcp_called('context7')" # Any tool on server
326
+
327
+ Args:
328
+ server: MCP server name (e.g., "gobby-memory", "context7")
329
+ tool: Optional specific tool name (e.g., "recall", "remember")
330
+
331
+ Returns:
332
+ True if the server (and optionally tool) was called.
333
+ """
334
+ variables = context.get("variables", {})
335
+ if isinstance(variables, dict):
336
+ mcp_calls = variables.get("mcp_calls", {})
337
+ else:
338
+ # SimpleNamespace from workflow engine
339
+ mcp_calls = getattr(variables, "mcp_calls", {})
340
+
341
+ # Ensure mcp_calls is a dict (could be None or other type)
342
+ if not isinstance(mcp_calls, dict):
343
+ mcp_calls = {}
344
+
345
+ if tool:
346
+ return tool in mcp_calls.get(server, [])
347
+ return bool(mcp_calls.get(server))
348
+
349
+ allowed_globals["mcp_called"] = _mcp_called
350
+
351
+ # eval used with restricted allowed_globals for workflow conditions
352
+ # nosec B307: eval is intentional here for DSL evaluation with
353
+ # restricted globals (__builtins__={}) and controlled workflow conditions
354
+ return bool(eval(condition, allowed_globals, context)) # nosec B307
355
+ except Exception as e:
356
+ logger.warning(f"Condition evaluation failed: '{condition}'. Error: {e}")
357
+ return False
358
+
359
+ def check_exit_conditions(self, conditions: list[dict[str, Any]], state: WorkflowState) -> bool:
360
+ """
361
+ Check if all exit conditions are met. (AND logic)
362
+ """
363
+ context = {
364
+ "workflow_state": state,
365
+ "state": state, # alias
366
+ # Flatten state for easier access
367
+ "step_action_count": state.step_action_count,
368
+ "total_action_count": state.total_action_count,
369
+ "variables": state.variables,
370
+ "task_list": state.task_list,
371
+ }
372
+ # Add variables safely to avoid shadowing internal context keys
373
+ for key, value in state.variables.items():
374
+ if key in context:
375
+ # Log warning or namespace? For now just skip or simple duplicate warn
376
+ logger.debug(
377
+ f"Variable '{key}' shadows internal context key, skipping direct merge"
378
+ )
379
+ continue
380
+ context[key] = value
381
+
382
+ for condition in conditions:
383
+ cond_type = condition.get("type")
384
+
385
+ if cond_type == "variable_set":
386
+ var_name = condition.get("variable")
387
+ if not var_name or var_name not in state.variables:
388
+ return False
389
+
390
+ elif cond_type == "user_approval":
391
+ # User approval condition - check if approval has been granted
392
+ condition_id = condition.get("id", f"approval_{hash(str(condition)) % 10000}")
393
+ approved_var = f"_approval_{condition_id}_granted"
394
+
395
+ # Check if this specific approval has been granted
396
+ if not state.variables.get(approved_var, False):
397
+ return False
398
+
399
+ elif cond_type == "expression":
400
+ expr = condition.get("expression")
401
+ if expr and not self.evaluate(expr, context):
402
+ return False
403
+
404
+ elif cond_type == "webhook":
405
+ # Webhook condition - check pre-evaluated result stored in variables
406
+ # The async evaluate_webhook_conditions method must be called first
407
+ condition_id = condition.get("id", f"webhook_{hash(str(condition)) % 10000}")
408
+ result_var = f"_webhook_{condition_id}_result"
409
+
410
+ # Get pre-evaluated webhook result from state
411
+ webhook_result = state.variables.get(result_var)
412
+ if webhook_result is None:
413
+ # Webhook hasn't been evaluated yet
414
+ logger.warning(
415
+ f"Webhook condition '{condition_id}' not pre-evaluated. "
416
+ "Call evaluate_webhook_conditions() first."
417
+ )
418
+ return False
419
+
420
+ # Check based on configured criteria
421
+ if not self._check_webhook_result(condition, webhook_result):
422
+ return False
423
+
424
+ return True
425
+
426
+ def _check_webhook_result(self, condition: dict[str, Any], result: dict[str, Any]) -> bool:
427
+ """Check if webhook result matches the condition criteria.
428
+
429
+ Args:
430
+ condition: Webhook condition configuration
431
+ result: Pre-evaluated webhook result stored in state
432
+
433
+ Returns:
434
+ True if condition is satisfied
435
+ """
436
+ if not isinstance(result, dict):
437
+ return False
438
+
439
+ # Check success (default: require success)
440
+ expect_success = condition.get("expect_success", True)
441
+ if expect_success and not result.get("success", False):
442
+ return False
443
+ if not expect_success and result.get("success", False):
444
+ return False
445
+
446
+ # Check status code if specified
447
+ expected_status = condition.get("status_code")
448
+ if expected_status is not None:
449
+ actual_status = result.get("status_code")
450
+ if isinstance(expected_status, list):
451
+ if actual_status not in expected_status:
452
+ return False
453
+ elif actual_status != expected_status:
454
+ return False
455
+
456
+ # Check body contains string if specified
457
+ body_contains = condition.get("body_contains")
458
+ if body_contains:
459
+ body = result.get("body", "")
460
+ if body_contains not in body:
461
+ return False
462
+
463
+ # Check JSON body field if specified (dot notation: "data.approved")
464
+ json_field = condition.get("json_field")
465
+ if json_field:
466
+ json_body = result.get("json_body", {})
467
+ expected_value = condition.get("json_value")
468
+ actual_value = self._get_nested_value(json_body, json_field)
469
+
470
+ if expected_value is not None:
471
+ if actual_value != expected_value:
472
+ return False
473
+ else:
474
+ # Just check field exists and is truthy
475
+ if not actual_value:
476
+ return False
477
+
478
+ return True
479
+
480
+ def _get_nested_value(self, obj: dict[str, Any], path: str) -> Any:
481
+ """Get a nested value from a dict using dot notation.
482
+
483
+ Args:
484
+ obj: Dictionary to traverse
485
+ path: Dot-separated path (e.g., "data.user.name")
486
+
487
+ Returns:
488
+ Value at path, or None if not found
489
+ """
490
+ parts = path.split(".")
491
+ current: Any = obj
492
+ for part in parts:
493
+ if not isinstance(current, dict):
494
+ return None
495
+ current = current.get(part)
496
+ if current is None:
497
+ return None
498
+ return current
499
+
500
+ def check_pending_approval(
501
+ self, conditions: list[dict[str, Any]], state: WorkflowState
502
+ ) -> ApprovalCheckResult | None:
503
+ """
504
+ Check if any user_approval condition needs attention.
505
+
506
+ Returns:
507
+ ApprovalCheckResult if there's an approval condition that needs handling,
508
+ None if no approval conditions or all are already granted.
509
+ """
510
+ for condition in conditions:
511
+ if condition.get("type") != "user_approval":
512
+ continue
513
+
514
+ condition_id = condition.get("id", f"approval_{hash(str(condition)) % 10000}")
515
+ approved_var = f"_approval_{condition_id}_granted"
516
+ rejected_var = f"_approval_{condition_id}_rejected"
517
+
518
+ # Check if already approved
519
+ if state.variables.get(approved_var, False):
520
+ continue
521
+
522
+ # Check if rejected
523
+ if state.variables.get(rejected_var, False):
524
+ return ApprovalCheckResult(
525
+ is_rejected=True,
526
+ condition_id=condition_id,
527
+ )
528
+
529
+ # Check timeout if approval is pending
530
+ timeout = condition.get("timeout")
531
+ if state.approval_pending and state.approval_condition_id == condition_id:
532
+ if timeout and state.approval_requested_at:
533
+ elapsed = (datetime.now(UTC) - state.approval_requested_at).total_seconds()
534
+ if elapsed > timeout:
535
+ return ApprovalCheckResult(
536
+ is_timed_out=True,
537
+ condition_id=condition_id,
538
+ timeout_seconds=timeout,
539
+ )
540
+
541
+ # Need to request approval
542
+ prompt = condition.get("prompt", "Do you approve this action? (yes/no)")
543
+ return ApprovalCheckResult(
544
+ needs_approval=True,
545
+ condition_id=condition_id,
546
+ prompt=prompt,
547
+ timeout_seconds=timeout,
548
+ )
549
+
550
+ return None
551
+
552
+ async def evaluate_webhook_conditions(
553
+ self, conditions: list[dict[str, Any]], state: WorkflowState
554
+ ) -> dict[str, Any]:
555
+ """
556
+ Pre-evaluate webhook conditions and store results in state variables.
557
+
558
+ This async method must be called before check_exit_conditions() for
559
+ workflows that include webhook conditions. Results are stored in
560
+ state.variables with keys like "_webhook_<id>_result".
561
+
562
+ Args:
563
+ conditions: List of condition dicts from workflow definition
564
+ state: Current workflow state (will be modified)
565
+
566
+ Returns:
567
+ Dict with evaluation summary:
568
+ - evaluated: Number of webhook conditions evaluated
569
+ - results: Dict mapping condition_id to webhook result
570
+ - errors: List of any errors encountered
571
+
572
+ Example webhook condition config:
573
+ {
574
+ "type": "webhook",
575
+ "id": "approval_check",
576
+ "url": "https://api.example.com/approve",
577
+ "method": "POST", # Optional, default POST
578
+ "headers": {"Authorization": "Bearer ${secrets.API_KEY}"},
579
+ "payload": {"session_id": "{{ session_id }}"},
580
+ "timeout": 30, # Optional, default 30s
581
+ "expect_success": true, # Check response is 2xx
582
+ "status_code": 200, # Or [200, 201] for multiple
583
+ "body_contains": "approved", # Check body contains string
584
+ "json_field": "data.approved", # Check JSON field
585
+ "json_value": true, # Expected value (optional)
586
+ "store_as": "approval_response" # Store full result in variable
587
+ }
588
+ """
589
+ if not self._webhook_executor:
590
+ logger.warning("No webhook_executor registered for condition evaluation")
591
+ return {"evaluated": 0, "results": {}, "errors": ["No webhook executor"]}
592
+
593
+ evaluated = 0
594
+ results: dict[str, dict[str, Any]] = {}
595
+ errors: list[str] = []
596
+
597
+ for condition in conditions:
598
+ if condition.get("type") != "webhook":
599
+ continue
600
+
601
+ condition_id = condition.get("id", f"webhook_{hash(str(condition)) % 10000}")
602
+
603
+ try:
604
+ # Execute the webhook
605
+ webhook_result = await self._webhook_executor.execute(
606
+ url=condition.get("url", ""),
607
+ method=condition.get("method", "POST"),
608
+ headers=condition.get("headers"),
609
+ payload=condition.get("payload"),
610
+ timeout=condition.get("timeout", 30),
611
+ context={
612
+ "session_id": state.session_id,
613
+ "workflow_name": state.workflow_name,
614
+ "step": state.step,
615
+ "variables": state.variables,
616
+ },
617
+ )
618
+
619
+ # Convert result to storable dict
620
+ try:
621
+ json_body = webhook_result.json_body()
622
+ except Exception:
623
+ json_body = None
624
+
625
+ result_dict: dict[str, Any] = {
626
+ "success": webhook_result.success,
627
+ "status_code": webhook_result.status_code,
628
+ "body": webhook_result.body,
629
+ "error": webhook_result.error,
630
+ "json_body": json_body,
631
+ }
632
+
633
+ # Store result in state variables
634
+ result_var = f"_webhook_{condition_id}_result"
635
+ state.variables[result_var] = result_dict
636
+
637
+ # Also store in named variable if specified
638
+ store_as = condition.get("store_as")
639
+ if store_as:
640
+ state.variables[store_as] = result_dict
641
+
642
+ results[condition_id] = result_dict
643
+ evaluated += 1
644
+
645
+ logger.debug(
646
+ f"Webhook condition '{condition_id}' evaluated: "
647
+ f"status={webhook_result.status_code}, success={webhook_result.success}"
648
+ )
649
+
650
+ except Exception as e:
651
+ error_msg = f"Webhook condition '{condition_id}' failed: {e}"
652
+ logger.error(error_msg)
653
+ errors.append(error_msg)
654
+
655
+ # Store error result
656
+ result_var = f"_webhook_{condition_id}_result"
657
+ state.variables[result_var] = {
658
+ "success": False,
659
+ "status_code": None,
660
+ "body": None,
661
+ "error": str(e),
662
+ "json_body": None,
663
+ }
664
+
665
+ return {
666
+ "evaluated": evaluated,
667
+ "results": results,
668
+ "errors": errors,
669
+ }