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,973 @@
1
+ """
2
+ Internal MCP tools for Gobby Workflow System.
3
+
4
+ Exposes functionality for:
5
+ - get_workflow: Get details about a specific workflow definition
6
+ - list_workflows: Discover available workflow definitions
7
+ - activate_workflow: Start a step-based workflow (supports initial variables)
8
+ - end_workflow: Complete/terminate active workflow
9
+ - get_workflow_status: Get current workflow state
10
+ - request_step_transition: Request transition to a different step
11
+ - mark_artifact_complete: Register an artifact as complete
12
+ - set_variable: Set a workflow variable for the session
13
+ - get_variable: Get workflow variable(s) for the session
14
+ - get_workflow_status: Get current workflow state
15
+ - request_step_transition: Request transition to a different step
16
+ - mark_artifact_complete: Register an artifact as complete
17
+ - set_variable: Set a workflow variable for the session
18
+ - get_variable: Get workflow variable(s) for the session
19
+ - import_workflow: Import a workflow from a file path
20
+ - reload_cache: Clear the workflow loader cache to pick up file changes
21
+
22
+ These tools are registered with the InternalToolRegistry and accessed
23
+ via the downstream proxy pattern (call_tool, list_tools, get_tool_schema).
24
+ """
25
+
26
+ import logging
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
32
+ from gobby.storage.database import LocalDatabase
33
+ from gobby.storage.sessions import LocalSessionManager
34
+ from gobby.storage.tasks._id import resolve_task_reference
35
+ from gobby.storage.tasks._models import TaskNotFoundError
36
+ from gobby.utils.project_context import get_workflow_project_path
37
+ from gobby.workflows.definitions import WorkflowState
38
+ from gobby.workflows.loader import WorkflowLoader
39
+ from gobby.workflows.state_manager import WorkflowStateManager
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ def _resolve_session_task_value(
45
+ value: str,
46
+ session_id: str | None,
47
+ session_manager: LocalSessionManager,
48
+ db: LocalDatabase,
49
+ ) -> str:
50
+ """
51
+ Resolve a session_task value from seq_num reference (#N or N) to UUID.
52
+
53
+ This prevents repeated resolution failures in condition evaluation when
54
+ task_tree_complete() is called with a seq_num that requires project_id.
55
+
56
+ Args:
57
+ value: The value to potentially resolve (e.g., "#4424", "47", or a UUID)
58
+ session_id: Session ID to look up project_id
59
+ session_manager: Session manager for lookups
60
+ db: Database for task resolution
61
+
62
+ Returns:
63
+ Resolved UUID if value was a seq_num reference, otherwise original value
64
+ """
65
+ # Only process string values that look like seq_num references
66
+ if not isinstance(value, str):
67
+ return value
68
+
69
+ # Check if it's a seq_num reference (#N or plain N)
70
+ is_seq_ref = value.startswith("#") or value.isdigit()
71
+ if not is_seq_ref:
72
+ return value
73
+
74
+ # Need session to get project_id
75
+ if not session_id:
76
+ logger.warning(f"Cannot resolve task reference '{value}': no session_id provided")
77
+ return value
78
+
79
+ # Get project_id from session
80
+ session = session_manager.get(session_id)
81
+ if not session or not session.project_id:
82
+ logger.warning(f"Cannot resolve task reference '{value}': session has no project_id")
83
+ return value
84
+
85
+ # Resolve the reference
86
+ try:
87
+ resolved = resolve_task_reference(db, value, session.project_id)
88
+ logger.debug(f"Resolved session_task '{value}' to UUID '{resolved}'")
89
+ return resolved
90
+ except TaskNotFoundError as e:
91
+ logger.warning(f"Could not resolve task reference '{value}': {e}")
92
+ return value
93
+ except Exception as e:
94
+ logger.warning(f"Unexpected error resolving task reference '{value}': {e}")
95
+ return value
96
+
97
+
98
+ def create_workflows_registry(
99
+ loader: WorkflowLoader | None = None,
100
+ state_manager: WorkflowStateManager | None = None,
101
+ session_manager: LocalSessionManager | None = None,
102
+ db: LocalDatabase | None = None,
103
+ ) -> InternalToolRegistry:
104
+ """
105
+ Create a workflow tool registry with all workflow-related tools.
106
+
107
+ Args:
108
+ loader: WorkflowLoader instance
109
+ state_manager: WorkflowStateManager instance
110
+ session_manager: LocalSessionManager instance
111
+ db: LocalDatabase instance
112
+
113
+ Returns:
114
+ InternalToolRegistry with workflow tools registered
115
+ """
116
+ # Create defaults if not provided
117
+ _db = db or LocalDatabase()
118
+ _loader = loader or WorkflowLoader()
119
+ _state_manager = state_manager or WorkflowStateManager(_db)
120
+ _session_manager = session_manager or LocalSessionManager(_db)
121
+
122
+ registry = InternalToolRegistry(
123
+ name="gobby-workflows",
124
+ description="Workflow management - list, activate, status, transition, end",
125
+ )
126
+
127
+ @registry.tool(
128
+ name="get_workflow",
129
+ description="Get details about a specific workflow definition.",
130
+ )
131
+ def get_workflow(
132
+ name: str,
133
+ project_path: str | None = None,
134
+ ) -> dict[str, Any]:
135
+ """
136
+ Get workflow details including steps, triggers, and settings.
137
+
138
+ Args:
139
+ name: Workflow name (without .yaml extension)
140
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
141
+
142
+ Returns:
143
+ Workflow definition details
144
+ """
145
+ # Auto-discover project path if not provided
146
+ if not project_path:
147
+ discovered = get_workflow_project_path()
148
+ if discovered:
149
+ project_path = str(discovered)
150
+
151
+ proj = Path(project_path) if project_path else None
152
+ definition = _loader.load_workflow(name, proj)
153
+
154
+ if not definition:
155
+ return {"success": False, "error": f"Workflow '{name}' not found"}
156
+
157
+ return {
158
+ "success": True,
159
+ "name": definition.name,
160
+ "type": definition.type,
161
+ "description": definition.description,
162
+ "version": definition.version,
163
+ "steps": (
164
+ [
165
+ {
166
+ "name": s.name,
167
+ "description": s.description,
168
+ "allowed_tools": s.allowed_tools,
169
+ "blocked_tools": s.blocked_tools,
170
+ }
171
+ for s in definition.steps
172
+ ]
173
+ if definition.steps
174
+ else []
175
+ ),
176
+ "triggers": (
177
+ {name: len(actions) for name, actions in definition.triggers.items()}
178
+ if definition.triggers
179
+ else {}
180
+ ),
181
+ "settings": definition.settings,
182
+ }
183
+
184
+ @registry.tool(
185
+ name="list_workflows",
186
+ description="List available workflow definitions from project and global directories.",
187
+ )
188
+ def list_workflows(
189
+ project_path: str | None = None,
190
+ workflow_type: str | None = None,
191
+ global_only: bool = False,
192
+ ) -> dict[str, Any]:
193
+ """
194
+ List available workflows.
195
+
196
+ Lists workflows from both project (.gobby/workflows) and global (~/.gobby/workflows)
197
+ directories. Project workflows shadow global ones with the same name.
198
+
199
+ Args:
200
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
201
+ workflow_type: Filter by type ("step" or "lifecycle")
202
+ global_only: If True, only show global workflows (ignore project)
203
+
204
+ Returns:
205
+ List of workflows with name, type, description, and source
206
+ """
207
+ import yaml
208
+
209
+ # Auto-discover project path if not provided
210
+ if not project_path:
211
+ discovered = get_workflow_project_path()
212
+ if discovered:
213
+ project_path = str(discovered)
214
+
215
+ search_dirs = list(_loader.global_dirs)
216
+ proj = Path(project_path) if project_path else None
217
+
218
+ # Include project workflows unless global_only (project searched first to shadow global)
219
+ if not global_only and proj:
220
+ project_dir = proj / ".gobby" / "workflows"
221
+ if project_dir.exists():
222
+ search_dirs.insert(0, project_dir)
223
+
224
+ workflows = []
225
+ seen_names = set()
226
+
227
+ for search_dir in search_dirs:
228
+ if not search_dir.exists():
229
+ continue
230
+
231
+ is_project = proj and search_dir == (proj / ".gobby" / "workflows")
232
+
233
+ for yaml_path in search_dir.glob("*.yaml"):
234
+ name = yaml_path.stem
235
+ if name in seen_names:
236
+ continue
237
+
238
+ try:
239
+ with open(yaml_path) as f:
240
+ data = yaml.safe_load(f)
241
+
242
+ if not data:
243
+ continue
244
+
245
+ wf_type = data.get("type", "step")
246
+
247
+ if workflow_type and wf_type != workflow_type:
248
+ continue
249
+
250
+ workflows.append(
251
+ {
252
+ "name": name,
253
+ "type": wf_type,
254
+ "description": data.get("description", ""),
255
+ "source": "project" if is_project else "global",
256
+ }
257
+ )
258
+ seen_names.add(name)
259
+
260
+ except Exception:
261
+ pass # nosec B110 - skip invalid workflow files
262
+
263
+ return {"workflows": workflows, "count": len(workflows)}
264
+
265
+ @registry.tool(
266
+ name="activate_workflow",
267
+ description="Activate a step-based workflow for the current session.",
268
+ )
269
+ def activate_workflow(
270
+ name: str,
271
+ session_id: str | None = None,
272
+ initial_step: str | None = None,
273
+ variables: dict[str, Any] | None = None,
274
+ project_path: str | None = None,
275
+ ) -> dict[str, Any]:
276
+ """
277
+ Activate a step-based workflow for the current session.
278
+
279
+ Args:
280
+ name: Workflow name (e.g., "plan-act-reflect", "auto-task")
281
+ session_id: Required session ID (must be provided to prevent cross-session bleed)
282
+ initial_step: Optional starting step (defaults to first step)
283
+ variables: Optional initial variables to set (merged with workflow defaults)
284
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
285
+
286
+ Returns:
287
+ Success status, workflow info, and current step.
288
+
289
+ Example:
290
+ activate_workflow(
291
+ name="auto-task",
292
+ variables={"session_task": "#47"},
293
+ session_id="..."
294
+ )
295
+
296
+ Errors if:
297
+ - session_id not provided
298
+ - Another step-based workflow is currently active
299
+ - Workflow not found
300
+ - Workflow is lifecycle type (those auto-run, not manually activated)
301
+ """
302
+ # Auto-discover project path if not provided
303
+ if not project_path:
304
+ discovered = get_workflow_project_path()
305
+ if discovered:
306
+ project_path = str(discovered)
307
+
308
+ proj = Path(project_path) if project_path else None
309
+
310
+ # Load workflow
311
+ definition = _loader.load_workflow(name, proj)
312
+ if not definition:
313
+ return {"success": False, "error": f"Workflow '{name}' not found"}
314
+
315
+ if definition.type == "lifecycle":
316
+ return {
317
+ "success": False,
318
+ "error": f"Workflow '{name}' is lifecycle type (auto-runs on events, not manually activated)",
319
+ }
320
+
321
+ # Require explicit session_id to prevent cross-session bleed
322
+ if not session_id:
323
+ return {
324
+ "success": False,
325
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
326
+ }
327
+
328
+ # Check for existing workflow
329
+ # Allow if:
330
+ # - No existing state
331
+ # - Existing is __lifecycle__ placeholder
332
+ # - Existing is a lifecycle-type workflow (they run concurrently with step workflows)
333
+ existing = _state_manager.get_state(session_id)
334
+ if existing and existing.workflow_name != "__lifecycle__":
335
+ # Check if existing workflow is a lifecycle type
336
+ existing_def = _loader.load_workflow(existing.workflow_name, proj)
337
+ # Only allow if we can confirm it's a lifecycle workflow
338
+ # If definition not found or it's a step workflow, block activation
339
+ if not existing_def or existing_def.type != "lifecycle":
340
+ # It's a step workflow (or unknown) - can only have one active
341
+ return {
342
+ "success": False,
343
+ "error": f"Session already has step workflow '{existing.workflow_name}' active. Use end_workflow first.",
344
+ }
345
+ # Existing is a lifecycle workflow - allow step workflow to activate alongside it
346
+
347
+ # Determine initial step
348
+ if initial_step:
349
+ if not any(s.name == initial_step for s in definition.steps):
350
+ return {
351
+ "success": False,
352
+ "error": f"Step '{initial_step}' not found. Available: {[s.name for s in definition.steps]}",
353
+ }
354
+ step = initial_step
355
+ else:
356
+ step = definition.steps[0].name if definition.steps else "default"
357
+
358
+ # Merge workflow default variables with passed-in variables
359
+ merged_variables = dict(definition.variables) # Start with workflow defaults
360
+ if variables:
361
+ merged_variables.update(variables) # Override with passed-in values
362
+
363
+ # Resolve session_task references (#N or N) to UUIDs upfront
364
+ # This prevents repeated resolution failures in condition evaluation
365
+ if "session_task" in merged_variables:
366
+ session_task_val = merged_variables["session_task"]
367
+ if isinstance(session_task_val, str):
368
+ merged_variables["session_task"] = _resolve_session_task_value(
369
+ session_task_val, session_id, _session_manager, _db
370
+ )
371
+
372
+ # Create state
373
+ state = WorkflowState(
374
+ session_id=session_id,
375
+ workflow_name=name,
376
+ step=step,
377
+ step_entered_at=datetime.now(UTC),
378
+ step_action_count=0,
379
+ total_action_count=0,
380
+ artifacts={},
381
+ observations=[],
382
+ reflection_pending=False,
383
+ context_injected=False,
384
+ variables=merged_variables,
385
+ task_list=None,
386
+ current_task_index=0,
387
+ files_modified_this_task=0,
388
+ )
389
+
390
+ _state_manager.save_state(state)
391
+
392
+ return {
393
+ "success": True,
394
+ "session_id": session_id,
395
+ "workflow": name,
396
+ "step": step,
397
+ "steps": [s.name for s in definition.steps],
398
+ "variables": merged_variables,
399
+ }
400
+
401
+ @registry.tool(
402
+ name="end_workflow",
403
+ description="End the currently active step-based workflow.",
404
+ )
405
+ def end_workflow(
406
+ session_id: str | None = None,
407
+ reason: str | None = None,
408
+ ) -> dict[str, Any]:
409
+ """
410
+ End the currently active step-based workflow.
411
+
412
+ Allows starting a different workflow afterward.
413
+ Does not affect lifecycle workflows (they continue running).
414
+
415
+ Args:
416
+ session_id: Required session ID (must be provided to prevent cross-session bleed)
417
+ reason: Optional reason for ending
418
+
419
+ Returns:
420
+ Success status
421
+ """
422
+ # Require explicit session_id to prevent cross-session bleed
423
+ if not session_id:
424
+ return {
425
+ "success": False,
426
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
427
+ }
428
+
429
+ state = _state_manager.get_state(session_id)
430
+ if not state:
431
+ return {"error": "No workflow active for session"}
432
+
433
+ _state_manager.delete_state(session_id)
434
+
435
+ return {}
436
+
437
+ @registry.tool(
438
+ name="get_workflow_status",
439
+ description="Get current workflow step and state.",
440
+ )
441
+ def get_workflow_status(session_id: str | None = None) -> dict[str, Any]:
442
+ """
443
+ Get current workflow step and state.
444
+
445
+ Args:
446
+ session_id: Required session ID (must be provided to prevent cross-session bleed)
447
+
448
+ Returns:
449
+ Workflow state including step, action counts, artifacts
450
+ """
451
+ # Require explicit session_id to prevent cross-session bleed
452
+ if not session_id:
453
+ return {
454
+ "has_workflow": False,
455
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
456
+ }
457
+
458
+ state = _state_manager.get_state(session_id)
459
+ if not state:
460
+ return {"has_workflow": False, "session_id": session_id}
461
+
462
+ return {
463
+ "has_workflow": True,
464
+ "session_id": session_id,
465
+ "workflow_name": state.workflow_name,
466
+ "step": state.step,
467
+ "step_action_count": state.step_action_count,
468
+ "total_action_count": state.total_action_count,
469
+ "reflection_pending": state.reflection_pending,
470
+ "artifacts": list(state.artifacts.keys()) if state.artifacts else [],
471
+ "variables": state.variables,
472
+ "task_progress": (
473
+ f"{state.current_task_index + 1}/{len(state.task_list)}"
474
+ if state.task_list
475
+ else None
476
+ ),
477
+ "updated_at": state.updated_at.isoformat() if state.updated_at else None,
478
+ }
479
+
480
+ @registry.tool(
481
+ name="request_step_transition",
482
+ description="Request transition to a different step.",
483
+ )
484
+ def request_step_transition(
485
+ to_step: str,
486
+ reason: str | None = None,
487
+ session_id: str | None = None,
488
+ force: bool = False,
489
+ project_path: str | None = None,
490
+ ) -> dict[str, Any]:
491
+ """
492
+ Request transition to a different step. May require approval.
493
+
494
+ Args:
495
+ to_step: Target step name
496
+ reason: Reason for transition
497
+ session_id: Required session ID (must be provided to prevent cross-session bleed)
498
+ force: Skip exit condition checks
499
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
500
+
501
+ Returns:
502
+ Success status and new step info
503
+ """
504
+ # Auto-discover project path if not provided
505
+ if not project_path:
506
+ discovered = get_workflow_project_path()
507
+ if discovered:
508
+ project_path = str(discovered)
509
+
510
+ proj = Path(project_path) if project_path else None
511
+
512
+ # Require explicit session_id to prevent cross-session bleed
513
+ if not session_id:
514
+ return {
515
+ "success": False,
516
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
517
+ }
518
+
519
+ state = _state_manager.get_state(session_id)
520
+ if not state:
521
+ return {"success": False, "error": "No workflow active for session"}
522
+
523
+ # Load workflow to validate step
524
+ definition = _loader.load_workflow(state.workflow_name, proj)
525
+ if not definition:
526
+ return {"success": False, "error": f"Workflow '{state.workflow_name}' not found"}
527
+
528
+ if not any(s.name == to_step for s in definition.steps):
529
+ return {
530
+ "success": False,
531
+ "error": f"Step '{to_step}' not found. Available: {[s.name for s in definition.steps]}",
532
+ }
533
+
534
+ # Block manual transitions to steps that have conditional auto-transitions
535
+ # These steps should only be reached when their conditions are met
536
+ current_step_def = next((s for s in definition.steps if s.name == state.step), None)
537
+ if current_step_def and current_step_def.transitions:
538
+ for transition in current_step_def.transitions:
539
+ if transition.to == to_step and transition.when:
540
+ # This step has a conditional transition - block manual transition
541
+ return {
542
+ "success": False,
543
+ "error": (
544
+ f"Step '{to_step}' has a conditional auto-transition "
545
+ f"(when: {transition.when}). Manual transitions to this step "
546
+ f"are blocked to prevent workflow circumvention. "
547
+ f"The transition will occur automatically when the condition is met."
548
+ ),
549
+ }
550
+
551
+ old_step = state.step
552
+ state.step = to_step
553
+ state.step_entered_at = datetime.now(UTC)
554
+ state.step_action_count = 0
555
+
556
+ _state_manager.save_state(state)
557
+
558
+ return {
559
+ "success": True,
560
+ "from_step": old_step,
561
+ "to_step": to_step,
562
+ "reason": reason,
563
+ "forced": force,
564
+ }
565
+
566
+ @registry.tool(
567
+ name="mark_artifact_complete",
568
+ description="Register an artifact as complete (plan, spec, etc.).",
569
+ )
570
+ def mark_artifact_complete(
571
+ artifact_type: str,
572
+ file_path: str,
573
+ session_id: str | None = None,
574
+ ) -> dict[str, Any]:
575
+ """
576
+ Register an artifact as complete.
577
+
578
+ Args:
579
+ artifact_type: Type of artifact (e.g., "plan", "spec", "test")
580
+ file_path: Path to the artifact file
581
+ session_id: Required session ID (must be provided to prevent cross-session bleed)
582
+
583
+ Returns:
584
+ Success status
585
+ """
586
+ # Require explicit session_id to prevent cross-session bleed
587
+ if not session_id:
588
+ return {
589
+ "success": False,
590
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
591
+ }
592
+
593
+ state = _state_manager.get_state(session_id)
594
+ if not state:
595
+ return {"error": "No workflow active for session"}
596
+
597
+ # Update artifacts
598
+ state.artifacts[artifact_type] = file_path
599
+ _state_manager.save_state(state)
600
+
601
+ return {}
602
+
603
+ @registry.tool(
604
+ name="set_variable",
605
+ description="Set a workflow variable for the current session (session-scoped, not persisted to YAML).",
606
+ )
607
+ def set_variable(
608
+ name: str,
609
+ value: str | int | float | bool | None,
610
+ session_id: str | None = None,
611
+ ) -> dict[str, Any]:
612
+ """
613
+ Set a workflow variable for the current session.
614
+
615
+ Variables set this way are session-scoped - they persist in the database
616
+ for the duration of the session but do not modify the workflow YAML file.
617
+
618
+ This is useful for:
619
+ - Setting session_epic to enforce epic completion before stopping
620
+ - Setting is_worktree to mark a session as a worktree agent
621
+ - Dynamic configuration without modifying workflow definitions
622
+
623
+ Args:
624
+ name: Variable name (e.g., "session_epic", "is_worktree")
625
+ value: Variable value (string, number, boolean, or null)
626
+ session_id: Required session ID (must be provided to prevent cross-session bleed)
627
+
628
+ Returns:
629
+ Success status and updated variables
630
+ """
631
+ # Require explicit session_id to prevent cross-session bleed
632
+ if not session_id:
633
+ return {
634
+ "success": False,
635
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
636
+ }
637
+
638
+ # Get or create state
639
+ state = _state_manager.get_state(session_id)
640
+ if not state:
641
+ # Create a minimal lifecycle state for variable storage
642
+ state = WorkflowState(
643
+ session_id=session_id,
644
+ workflow_name="__lifecycle__",
645
+ step="",
646
+ step_entered_at=datetime.now(UTC),
647
+ variables={},
648
+ )
649
+
650
+ # Block modification of session_task when a real workflow is active
651
+ # This prevents circumventing workflows by changing the tracked task
652
+ if name == "session_task" and state.workflow_name != "__lifecycle__":
653
+ current_value = state.variables.get("session_task")
654
+ if current_value is not None and value != current_value:
655
+ return {
656
+ "success": False,
657
+ "error": (
658
+ f"Cannot modify session_task while workflow '{state.workflow_name}' is active. "
659
+ f"Current value: {current_value}. "
660
+ f"Use end_workflow() first if you need to change the tracked task."
661
+ ),
662
+ }
663
+
664
+ # Resolve session_task references (#N or N) to UUIDs upfront
665
+ # This prevents repeated resolution failures in condition evaluation
666
+ if name == "session_task" and isinstance(value, str):
667
+ value = _resolve_session_task_value(value, session_id, _session_manager, _db)
668
+
669
+ # Set the variable
670
+ state.variables[name] = value
671
+ _state_manager.save_state(state)
672
+
673
+ # Add deprecation warning for session_task variable (when no workflow active)
674
+ if name == "session_task" and value and state.workflow_name == "__lifecycle__":
675
+ return {
676
+ "warning": (
677
+ "DEPRECATED: Setting session_task directly is deprecated. "
678
+ "Use activate_workflow(name='auto-task', variables={'session_task': ...}) instead "
679
+ "for proper state machine semantics and on_premature_stop handling."
680
+ ),
681
+ }
682
+
683
+ return {}
684
+
685
+ @registry.tool(
686
+ name="get_variable",
687
+ description="Get workflow variable(s) for the current session.",
688
+ )
689
+ def get_variable(
690
+ name: str | None = None,
691
+ session_id: str | None = None,
692
+ ) -> dict[str, Any]:
693
+ """
694
+ Get workflow variable(s) for the current session.
695
+
696
+ Args:
697
+ name: Variable name to get (if None, returns all variables)
698
+ session_id: Required session ID (must be provided to prevent cross-session bleed)
699
+
700
+ Returns:
701
+ Variable value(s) and session info
702
+ """
703
+ # Require explicit session_id to prevent cross-session bleed
704
+ if not session_id:
705
+ return {
706
+ "success": False,
707
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
708
+ }
709
+
710
+ state = _state_manager.get_state(session_id)
711
+ if not state:
712
+ if name:
713
+ return {
714
+ "success": True,
715
+ "session_id": session_id,
716
+ "variable": name,
717
+ "value": None,
718
+ "exists": False,
719
+ }
720
+ return {
721
+ "success": True,
722
+ "session_id": session_id,
723
+ "variables": {},
724
+ }
725
+
726
+ if name:
727
+ value = state.variables.get(name)
728
+ return {
729
+ "success": True,
730
+ "session_id": session_id,
731
+ "variable": name,
732
+ "value": value,
733
+ "exists": name in state.variables,
734
+ }
735
+
736
+ return {
737
+ "success": True,
738
+ "session_id": session_id,
739
+ "variables": state.variables,
740
+ }
741
+
742
+ @registry.tool(
743
+ name="import_workflow",
744
+ description="Import a workflow from a file path into the project or global directory.",
745
+ )
746
+ def import_workflow(
747
+ source_path: str,
748
+ workflow_name: str | None = None,
749
+ is_global: bool = False,
750
+ project_path: str | None = None,
751
+ ) -> dict[str, Any]:
752
+ """
753
+ Import a workflow from a file.
754
+
755
+ Args:
756
+ source_path: Path to the workflow YAML file
757
+ workflow_name: Override the workflow name (defaults to name in file)
758
+ is_global: Install to global ~/.gobby/workflows instead of project
759
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
760
+
761
+ Returns:
762
+ Success status and destination path
763
+ """
764
+ import shutil
765
+
766
+ import yaml
767
+
768
+ source = Path(source_path)
769
+ if not source.exists():
770
+ return {"success": False, "error": f"File not found: {source_path}"}
771
+
772
+ if source.suffix != ".yaml":
773
+ return {"success": False, "error": "Workflow file must have .yaml extension"}
774
+
775
+ try:
776
+ with open(source) as f:
777
+ data = yaml.safe_load(f)
778
+
779
+ if not data or "name" not in data:
780
+ return {"success": False, "error": "Invalid workflow: missing 'name' field"}
781
+
782
+ except yaml.YAMLError as e:
783
+ return {"success": False, "error": f"Invalid YAML: {e}"}
784
+
785
+ name = workflow_name or data.get("name", source.stem)
786
+ filename = f"{name}.yaml"
787
+
788
+ if is_global:
789
+ dest_dir = Path.home() / ".gobby" / "workflows"
790
+ else:
791
+ # Auto-discover project path if not provided
792
+ if not project_path:
793
+ discovered = get_workflow_project_path()
794
+ if discovered:
795
+ project_path = str(discovered)
796
+
797
+ proj = Path(project_path) if project_path else None
798
+ if not proj:
799
+ return {
800
+ "success": False,
801
+ "error": "project_path required when not using is_global (could not auto-discover)",
802
+ }
803
+ dest_dir = proj / ".gobby" / "workflows"
804
+
805
+ dest_dir.mkdir(parents=True, exist_ok=True)
806
+ dest_path = dest_dir / filename
807
+
808
+ shutil.copy(source, dest_path)
809
+
810
+ # Clear loader cache so new workflow is discoverable
811
+ _loader.clear_discovery_cache()
812
+
813
+ return {
814
+ "success": True,
815
+ "workflow_name": name,
816
+ "destination": str(dest_path),
817
+ "is_global": is_global,
818
+ }
819
+
820
+ @registry.tool(
821
+ name="reload_cache",
822
+ description="Clear the workflow cache. Use this after modifying workflow YAML files.",
823
+ )
824
+ def reload_cache() -> dict[str, Any]:
825
+ """
826
+ Clear the workflow loader cache.
827
+
828
+ This forces the daemon to re-read workflow YAML files from disk
829
+ on the next access. Use this when you've modified workflow files
830
+ and want the changes to take effect immediately without restarting
831
+ the daemon.
832
+
833
+ Returns:
834
+ Success status
835
+ """
836
+ _loader.clear_cache()
837
+ logger.info("Workflow cache cleared via reload_cache tool")
838
+ return {"message": "Workflow cache cleared"}
839
+
840
+ @registry.tool(
841
+ name="close_terminal",
842
+ description=(
843
+ "Close the current terminal window/pane (agent self-termination). "
844
+ "Launches ~/.gobby/scripts/agent_shutdown.sh which handles "
845
+ "terminal-specific shutdown (tmux, iTerm, etc.). Rebuilds script if missing."
846
+ ),
847
+ )
848
+ async def close_terminal(
849
+ signal: str = "TERM",
850
+ delay_ms: int = 0,
851
+ ) -> dict[str, Any]:
852
+ """
853
+ Close the current terminal by running the agent shutdown script.
854
+
855
+ This is for agent self-termination (meeseeks-style). The agent calls
856
+ this to close its own terminal window when done with its workflow.
857
+
858
+ The script is located at ~/.gobby/scripts/agent_shutdown.sh and is
859
+ automatically rebuilt if missing. It handles different terminal types
860
+ (tmux, iTerm, Terminal.app, Ghostty, Kitty, WezTerm, etc.).
861
+
862
+ Args:
863
+ signal: Signal to use for shutdown (TERM, KILL, INT). Default: TERM.
864
+ delay_ms: Optional delay in milliseconds before shutdown. Default: 0.
865
+
866
+ Returns:
867
+ Dict with success status and message.
868
+ """
869
+ import asyncio
870
+ import os
871
+ import stat
872
+ import subprocess # nosec B404 - subprocess needed for agent shutdown script
873
+
874
+ # Script location
875
+ gobby_dir = Path.home() / ".gobby"
876
+ scripts_dir = gobby_dir / "scripts"
877
+ script_path = scripts_dir / "agent_shutdown.sh"
878
+
879
+ # Source script from the install directory (single source of truth)
880
+ source_script_path = (
881
+ Path(__file__).parent.parent.parent
882
+ / "install"
883
+ / "shared"
884
+ / "scripts"
885
+ / "agent_shutdown.sh"
886
+ )
887
+
888
+ def get_script_version(script_content: str) -> str | None:
889
+ """Extract VERSION marker from script content."""
890
+ import re
891
+
892
+ match = re.search(r"^# VERSION:\s*(.+)$", script_content, re.MULTILINE)
893
+ return match.group(1).strip() if match else None
894
+
895
+ # Ensure directories exist and script is present/up-to-date
896
+ script_rebuilt = False
897
+ try:
898
+ scripts_dir.mkdir(parents=True, exist_ok=True)
899
+
900
+ # Read source script content
901
+ if source_script_path.exists():
902
+ source_content = source_script_path.read_text()
903
+ source_version = get_script_version(source_content)
904
+ else:
905
+ logger.warning(f"Source shutdown script not found at {source_script_path}")
906
+ source_content = None
907
+ source_version = None
908
+
909
+ # Check if installed script exists and compare versions
910
+ needs_rebuild = False
911
+ if not script_path.exists():
912
+ needs_rebuild = True
913
+ elif source_content:
914
+ installed_content = script_path.read_text()
915
+ installed_version = get_script_version(installed_content)
916
+ # Rebuild if versions differ or installed has no version marker
917
+ if installed_version != source_version:
918
+ needs_rebuild = True
919
+ logger.info(
920
+ f"Shutdown script version mismatch: installed={installed_version}, source={source_version}"
921
+ )
922
+
923
+ if needs_rebuild and source_content:
924
+ script_path.write_text(source_content)
925
+ # Make executable
926
+ script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
927
+ script_rebuilt = True
928
+ logger.info(f"Created/updated agent shutdown script at {script_path}")
929
+ except OSError as e:
930
+ return {
931
+ "success": False,
932
+ "error": f"Failed to create shutdown script: {e}",
933
+ }
934
+
935
+ # Validate signal
936
+ valid_signals = {"TERM", "KILL", "INT", "HUP", "QUIT"}
937
+ if signal.upper() not in valid_signals:
938
+ return {
939
+ "success": False,
940
+ "error": f"Invalid signal '{signal}'. Valid: {valid_signals}",
941
+ }
942
+
943
+ # Apply delay before launching script (non-blocking)
944
+ if delay_ms > 0:
945
+ await asyncio.sleep(delay_ms / 1000.0)
946
+
947
+ # Launch the script
948
+ try:
949
+ # Run in background - we don't wait for it since it kills our process
950
+ env = os.environ.copy()
951
+
952
+ subprocess.Popen( # nosec B603 - script path is from gobby scripts directory
953
+ [str(script_path), signal.upper(), "0"], # Delay already applied
954
+ env=env,
955
+ start_new_session=True, # Detach from parent
956
+ stdout=subprocess.DEVNULL,
957
+ stderr=subprocess.DEVNULL,
958
+ )
959
+
960
+ return {
961
+ "success": True,
962
+ "message": "Shutdown script launched",
963
+ "script_path": str(script_path),
964
+ "script_rebuilt": script_rebuilt,
965
+ "signal": signal.upper(),
966
+ }
967
+ except Exception as e:
968
+ return {
969
+ "success": False,
970
+ "error": f"Failed to launch shutdown script: {e}",
971
+ }
972
+
973
+ return registry