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,1264 @@
1
+ """
2
+ Internal MCP tools for Gobby Worktree Management.
3
+
4
+ Exposes functionality for:
5
+ - Creating git worktrees for isolated development
6
+ - Managing worktree lifecycle (claim, release, cleanup)
7
+ - Syncing worktrees with main branch
8
+ - Spawning agents in worktrees
9
+
10
+ These tools are registered with the InternalToolRegistry and accessed
11
+ via the downstream proxy pattern (call_tool, list_tools, get_tool_schema).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import platform
19
+ import tempfile
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any, Literal, cast
22
+
23
+ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
24
+ from gobby.utils.project_context import get_project_context
25
+ from gobby.workflows.definitions import WorkflowState
26
+ from gobby.workflows.loader import WorkflowLoader
27
+ from gobby.workflows.state_manager import WorkflowStateManager
28
+ from gobby.worktrees.git import WorktreeGitManager
29
+
30
+ if TYPE_CHECKING:
31
+ from gobby.agents.runner import AgentRunner
32
+ from gobby.storage.worktrees import LocalWorktreeManager
33
+ from gobby.worktrees.git import WorktreeGitManager
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Cache for WorktreeGitManager instances per repo path
38
+ _git_manager_cache: dict[str, WorktreeGitManager] = {}
39
+
40
+
41
+ def _get_worktree_base_dir() -> Path:
42
+ """
43
+ Get the base directory for worktrees.
44
+
45
+ Uses the system temp directory:
46
+ - macOS/Linux: /tmp/gobby-worktrees/
47
+ - Windows: %TEMP%/gobby-worktrees/
48
+
49
+ Returns:
50
+ Path to worktree base directory (creates if needed)
51
+ """
52
+ if platform.system() == "Windows":
53
+ # Windows: use %TEMP% (typically C:\\Users\\<user>\\AppData\\Local\\Temp)
54
+ base = Path(tempfile.gettempdir()) / "gobby-worktrees"
55
+ else:
56
+ # macOS/Linux: use /tmp for better isolation (tmpfs, cleared on reboot)
57
+ # Resolve symlink on macOS (/tmp -> /private/tmp) for consistent paths
58
+ # nosec B108: /tmp is intentional for worktrees - they're temporary by design
59
+ base = Path("/tmp").resolve() / "gobby-worktrees" # nosec B108
60
+
61
+ base.mkdir(parents=True, exist_ok=True)
62
+ return base
63
+
64
+
65
+ def _generate_worktree_path(branch_name: str, project_name: str | None = None) -> str:
66
+ """
67
+ Generate a worktree path in the temp directory.
68
+
69
+ Args:
70
+ branch_name: Branch name (used as directory name)
71
+ project_name: Optional project name for namespacing
72
+
73
+ Returns:
74
+ Full path for the worktree
75
+ """
76
+ base = _get_worktree_base_dir()
77
+
78
+ # Sanitize branch name for filesystem (replace / with -)
79
+ safe_branch = branch_name.replace("/", "-")
80
+
81
+ if project_name:
82
+ # Namespace by project: /tmp/gobby-worktrees/project-name/branch-name
83
+ return str(base / project_name / safe_branch)
84
+ else:
85
+ # No project namespace: /tmp/gobby-worktrees/branch-name
86
+ return str(base / safe_branch)
87
+
88
+
89
+ def _resolve_project_context(
90
+ project_path: str | None,
91
+ default_git_manager: WorktreeGitManager | None,
92
+ default_project_id: str | None,
93
+ ) -> tuple[WorktreeGitManager | None, str | None, str | None]:
94
+ """
95
+ Resolve project context from project_path or fall back to defaults.
96
+
97
+ Args:
98
+ project_path: Path to project directory (cwd from caller).
99
+ default_git_manager: Registry-level git manager (may be None).
100
+ default_project_id: Registry-level project ID (may be None).
101
+
102
+ Returns:
103
+ Tuple of (git_manager, project_id, error_message).
104
+ If error_message is not None, the other values should not be used.
105
+ """
106
+ if project_path:
107
+ # Resolve from provided path
108
+ path = Path(project_path)
109
+ if not path.exists():
110
+ return None, None, f"Project path does not exist: {project_path}"
111
+
112
+ project_ctx = get_project_context(path)
113
+ if not project_ctx:
114
+ return None, None, f"No .gobby/project.json found in {project_path}"
115
+
116
+ resolved_project_id = project_ctx.get("id")
117
+ resolved_path = project_ctx.get("project_path", str(path))
118
+
119
+ # Get or create git manager for this path
120
+ if resolved_path not in _git_manager_cache:
121
+ try:
122
+ _git_manager_cache[resolved_path] = WorktreeGitManager(resolved_path)
123
+ except ValueError as e:
124
+ return None, None, f"Invalid git repository: {e}"
125
+
126
+ return _git_manager_cache[resolved_path], resolved_project_id, None
127
+
128
+ # Fall back to defaults
129
+ if default_git_manager is None:
130
+ return None, None, "No project_path provided and no default git manager configured."
131
+ if default_project_id is None:
132
+ return None, None, "No project_path provided and no default project ID configured."
133
+
134
+ return default_git_manager, default_project_id, None
135
+
136
+
137
+ def _copy_project_json_to_worktree(
138
+ repo_path: str | Path,
139
+ worktree_path: str | Path,
140
+ ) -> None:
141
+ """
142
+ Copy .gobby/project.json from main repo to worktree, adding parent reference.
143
+
144
+ This ensures worktree sessions:
145
+ - Use the same project_id as the parent repo
146
+ - Can discover the parent project path for workflow lookup
147
+
148
+ Args:
149
+ repo_path: Path to main repository
150
+ worktree_path: Path to worktree directory
151
+ """
152
+ main_gobby_dir = Path(repo_path) / ".gobby"
153
+ main_project_json = main_gobby_dir / "project.json"
154
+ worktree_gobby_dir = Path(worktree_path) / ".gobby"
155
+
156
+ if main_project_json.exists():
157
+ try:
158
+ worktree_gobby_dir.mkdir(parents=True, exist_ok=True)
159
+ worktree_project_json = worktree_gobby_dir / "project.json"
160
+ if not worktree_project_json.exists():
161
+ # Read, add parent reference, write
162
+ with open(main_project_json) as f:
163
+ data = json.load(f)
164
+
165
+ data["parent_project_path"] = str(Path(repo_path).resolve())
166
+
167
+ with open(worktree_project_json, "w") as f:
168
+ json.dump(data, f, indent=2)
169
+
170
+ logger.info("Created project.json in worktree with parent reference")
171
+ except Exception as e:
172
+ logger.warning(f"Failed to create project.json in worktree: {e}")
173
+
174
+
175
+ def _install_provider_hooks(
176
+ provider: Literal["claude", "gemini", "codex", "antigravity"] | None,
177
+ worktree_path: str | Path,
178
+ ) -> bool:
179
+ """
180
+ Install CLI hooks for the specified provider in the worktree.
181
+
182
+ Args:
183
+ provider: Provider name ('claude', 'gemini', 'antigravity', or None)
184
+ worktree_path: Path to worktree directory
185
+
186
+ Returns:
187
+ True if hooks were successfully installed, False otherwise
188
+ """
189
+ if not provider:
190
+ return False
191
+
192
+ worktree_path_obj = Path(worktree_path)
193
+ try:
194
+ if provider == "claude":
195
+ from gobby.cli.installers.claude import install_claude
196
+
197
+ result = install_claude(worktree_path_obj)
198
+ if result["success"]:
199
+ logger.info(f"Installed Claude hooks in worktree: {worktree_path}")
200
+ return True
201
+ else:
202
+ logger.warning(f"Failed to install Claude hooks: {result.get('error')}")
203
+ elif provider == "gemini":
204
+ from gobby.cli.installers.gemini import install_gemini
205
+
206
+ result = install_gemini(worktree_path_obj)
207
+ if result["success"]:
208
+ logger.info(f"Installed Gemini hooks in worktree: {worktree_path}")
209
+ return True
210
+ else:
211
+ logger.warning(f"Failed to install Gemini hooks: {result.get('error')}")
212
+ elif provider == "antigravity":
213
+ from gobby.cli.installers.antigravity import install_antigravity
214
+
215
+ result = install_antigravity(worktree_path_obj)
216
+ if result["success"]:
217
+ logger.info(f"Installed Antigravity hooks in worktree: {worktree_path}")
218
+ return True
219
+ else:
220
+ logger.warning(f"Failed to install Antigravity hooks: {result.get('error')}")
221
+ # Note: codex uses CODEX_NOTIFY_SCRIPT env var, not project-level hooks
222
+ except Exception as e:
223
+ logger.warning(f"Failed to install {provider} hooks in worktree: {e}")
224
+ return False
225
+
226
+
227
+ def _build_worktree_context_prompt(
228
+ original_prompt: str,
229
+ worktree_path: str,
230
+ branch_name: str,
231
+ task_id: str | None,
232
+ main_repo_path: str | None = None,
233
+ ) -> str:
234
+ """
235
+ Build an enhanced prompt with worktree context injected.
236
+
237
+ This helps the spawned agent understand it's working in an isolated worktree,
238
+ not the main repository. Critical for preventing the agent from accessing
239
+ wrong files or working in the wrong directory.
240
+
241
+ Args:
242
+ original_prompt: The original task prompt
243
+ worktree_path: Path to the worktree
244
+ branch_name: Name of the worktree branch
245
+ task_id: Task ID being worked on (if any)
246
+ main_repo_path: Path to the main repo (to explicitly warn against accessing it)
247
+
248
+ Returns:
249
+ Enhanced prompt with worktree context prepended
250
+ """
251
+ context_lines = [
252
+ "## CRITICAL: Worktree Context",
253
+ "You are working in an ISOLATED git worktree, NOT the main repository.",
254
+ "",
255
+ f"**Your workspace:** {worktree_path}",
256
+ f"**Your branch:** {branch_name}",
257
+ ]
258
+
259
+ if task_id:
260
+ context_lines.append(f"**Your task:** {task_id}")
261
+
262
+ context_lines.extend(
263
+ [
264
+ "",
265
+ "**IMPORTANT RULES:**",
266
+ f"1. ALL file operations must be within {worktree_path}",
267
+ ]
268
+ )
269
+
270
+ if main_repo_path:
271
+ context_lines.append(f"2. Do NOT access {main_repo_path} (main repo)")
272
+ else:
273
+ context_lines.append("2. Do NOT access the main repository")
274
+
275
+ context_lines.extend(
276
+ [
277
+ "3. Run `pwd` to verify your location before any file operations",
278
+ f"4. Commit to YOUR branch ({branch_name}), not main/dev",
279
+ "5. When your assigned task is complete, STOP - do not claim other tasks",
280
+ "",
281
+ "---",
282
+ "",
283
+ ]
284
+ )
285
+
286
+ worktree_context = "\n".join(context_lines)
287
+ return f"{worktree_context}{original_prompt}"
288
+
289
+
290
+ def create_worktrees_registry(
291
+ worktree_storage: LocalWorktreeManager,
292
+ git_manager: WorktreeGitManager | None = None,
293
+ project_id: str | None = None,
294
+ agent_runner: AgentRunner | None = None,
295
+ ) -> InternalToolRegistry:
296
+ """
297
+ Create a worktree tool registry with all worktree-related tools.
298
+
299
+ Args:
300
+ worktree_storage: LocalWorktreeManager for database operations.
301
+ git_manager: WorktreeGitManager for git operations.
302
+ project_id: Default project ID for operations.
303
+ agent_runner: AgentRunner for spawning agents in worktrees.
304
+
305
+ Returns:
306
+ InternalToolRegistry with all worktree tools registered.
307
+ """
308
+ registry = InternalToolRegistry(
309
+ name="gobby-worktrees",
310
+ description="Git worktree management - create, manage, and cleanup isolated development directories",
311
+ )
312
+
313
+ @registry.tool(
314
+ name="create_worktree",
315
+ description="Create a new git worktree for isolated development.",
316
+ )
317
+ async def create_worktree(
318
+ branch_name: str,
319
+ base_branch: str = "main",
320
+ task_id: str | None = None,
321
+ worktree_path: str | None = None,
322
+ create_branch: bool = True,
323
+ project_path: str | None = None,
324
+ provider: Literal["claude", "gemini", "codex", "antigravity"] | None = None,
325
+ ) -> dict[str, Any]:
326
+ """
327
+ Create a new git worktree.
328
+
329
+ Args:
330
+ branch_name: Name for the new branch.
331
+ base_branch: Branch to base the worktree on (default: main).
332
+ task_id: Optional task ID to link to this worktree.
333
+ worktree_path: Optional custom path (defaults to ../{branch_name}).
334
+ create_branch: Whether to create a new branch (default: True).
335
+ project_path: Path to project directory (pass cwd from CLI).
336
+ provider: CLI provider to install hooks for (claude, gemini, codex, antigravity).
337
+ If specified, installs hooks so agents can communicate with daemon.
338
+
339
+ Returns:
340
+ Dict with worktree ID, path, and branch info.
341
+ """
342
+ # Resolve project context
343
+ resolved_git_mgr, resolved_project_id, error = _resolve_project_context(
344
+ project_path, git_manager, project_id
345
+ )
346
+ if error:
347
+ return {"success": False, "error": error}
348
+
349
+ # Type narrowing: if no error, these are guaranteed non-None
350
+ if resolved_git_mgr is None or resolved_project_id is None:
351
+ raise RuntimeError("Git manager or project ID unexpectedly None")
352
+
353
+ # Check if branch already exists as a worktree
354
+ existing = worktree_storage.get_by_branch(resolved_project_id, branch_name)
355
+ if existing:
356
+ return {
357
+ "success": False,
358
+ "error": f"Worktree already exists for branch '{branch_name}'",
359
+ "existing_worktree_id": existing.id,
360
+ "existing_path": existing.worktree_path,
361
+ }
362
+
363
+ # Generate default worktree path if not provided
364
+ if worktree_path is None:
365
+ # Use temp directory (e.g., /tmp/gobby-worktrees/project-name/branch-name)
366
+ project_name = Path(resolved_git_mgr.repo_path).name
367
+ worktree_path = _generate_worktree_path(branch_name, project_name)
368
+
369
+ # Create git worktree
370
+ result = resolved_git_mgr.create_worktree(
371
+ worktree_path=worktree_path,
372
+ branch_name=branch_name,
373
+ base_branch=base_branch,
374
+ create_branch=create_branch,
375
+ )
376
+
377
+ if not result.success:
378
+ return {
379
+ "success": False,
380
+ "error": result.error or "Failed to create git worktree",
381
+ }
382
+
383
+ # Record in database
384
+ worktree = worktree_storage.create(
385
+ project_id=resolved_project_id,
386
+ branch_name=branch_name,
387
+ worktree_path=worktree_path,
388
+ base_branch=base_branch,
389
+ task_id=task_id,
390
+ )
391
+
392
+ # Copy project.json and install provider hooks
393
+ _copy_project_json_to_worktree(resolved_git_mgr.repo_path, worktree.worktree_path)
394
+ hooks_installed = _install_provider_hooks(provider, worktree.worktree_path)
395
+
396
+ return {
397
+ "success": True,
398
+ "worktree_id": worktree.id,
399
+ "worktree_path": worktree.worktree_path,
400
+ "hooks_installed": hooks_installed,
401
+ }
402
+
403
+ @registry.tool(
404
+ name="get_worktree",
405
+ description="Get details of a specific worktree.",
406
+ )
407
+ async def get_worktree(worktree_id: str) -> dict[str, Any]:
408
+ """
409
+ Get worktree details by ID.
410
+
411
+ Args:
412
+ worktree_id: The worktree ID.
413
+
414
+ Returns:
415
+ Dict with full worktree details.
416
+ """
417
+ worktree = worktree_storage.get(worktree_id)
418
+ if not worktree:
419
+ return {
420
+ "success": False,
421
+ "error": f"Worktree '{worktree_id}' not found",
422
+ }
423
+
424
+ # Get git status if manager available
425
+ git_status = None
426
+ if git_manager and Path(worktree.worktree_path).exists():
427
+ status = git_manager.get_worktree_status(worktree.worktree_path)
428
+ if status:
429
+ git_status = {
430
+ "has_uncommitted_changes": status.has_uncommitted_changes,
431
+ "commits_ahead": status.ahead,
432
+ "commits_behind": status.behind,
433
+ "current_branch": status.branch,
434
+ }
435
+
436
+ return {
437
+ "success": True,
438
+ "worktree": worktree.to_dict(),
439
+ "git_status": git_status,
440
+ }
441
+
442
+ @registry.tool(
443
+ name="list_worktrees",
444
+ description="List worktrees with optional filters.",
445
+ )
446
+ async def list_worktrees(
447
+ status: str | None = None,
448
+ agent_session_id: str | None = None,
449
+ limit: int = 50,
450
+ ) -> dict[str, Any]:
451
+ """
452
+ List worktrees with optional filters.
453
+
454
+ Args:
455
+ status: Filter by status (active, stale, merged, abandoned).
456
+ agent_session_id: Filter by owning session.
457
+ limit: Maximum results (default: 50).
458
+
459
+ Returns:
460
+ Dict with list of worktrees.
461
+ """
462
+ worktrees = worktree_storage.list_worktrees(
463
+ project_id=project_id,
464
+ status=status,
465
+ agent_session_id=agent_session_id,
466
+ limit=limit,
467
+ )
468
+
469
+ return {
470
+ "success": True,
471
+ "worktrees": [
472
+ {
473
+ "id": wt.id,
474
+ "branch_name": wt.branch_name,
475
+ "worktree_path": wt.worktree_path,
476
+ "status": wt.status,
477
+ "task_id": wt.task_id,
478
+ "agent_session_id": wt.agent_session_id,
479
+ "created_at": wt.created_at,
480
+ }
481
+ for wt in worktrees
482
+ ],
483
+ "count": len(worktrees),
484
+ }
485
+
486
+ @registry.tool(
487
+ name="claim_worktree",
488
+ description="Claim ownership of a worktree for an agent session.",
489
+ )
490
+ async def claim_worktree(
491
+ worktree_id: str,
492
+ session_id: str,
493
+ ) -> dict[str, Any]:
494
+ """
495
+ Claim a worktree for an agent session.
496
+
497
+ Args:
498
+ worktree_id: The worktree ID to claim.
499
+ session_id: The session ID claiming ownership.
500
+
501
+ Returns:
502
+ Dict with success status.
503
+ """
504
+ worktree = worktree_storage.get(worktree_id)
505
+ if not worktree:
506
+ return {
507
+ "success": False,
508
+ "error": f"Worktree '{worktree_id}' not found",
509
+ }
510
+
511
+ if worktree.agent_session_id and worktree.agent_session_id != session_id:
512
+ return {
513
+ "success": False,
514
+ "error": f"Worktree already claimed by session '{worktree.agent_session_id}'",
515
+ }
516
+
517
+ updated = worktree_storage.claim(worktree_id, session_id)
518
+ if not updated:
519
+ return {"error": "Failed to claim worktree"}
520
+
521
+ return {}
522
+
523
+ @registry.tool(
524
+ name="release_worktree",
525
+ description="Release ownership of a worktree.",
526
+ )
527
+ async def release_worktree(worktree_id: str) -> dict[str, Any]:
528
+ """
529
+ Release a worktree from its current owner.
530
+
531
+ Args:
532
+ worktree_id: The worktree ID to release.
533
+
534
+ Returns:
535
+ Dict with success status.
536
+ """
537
+ worktree = worktree_storage.get(worktree_id)
538
+ if not worktree:
539
+ return {"error": f"Worktree '{worktree_id}' not found"}
540
+
541
+ updated = worktree_storage.release(worktree_id)
542
+ if not updated:
543
+ return {"error": "Failed to release worktree"}
544
+
545
+ return {}
546
+
547
+ @registry.tool(
548
+ name="delete_worktree",
549
+ description="Delete a worktree (both git and database record).",
550
+ )
551
+ async def delete_worktree(
552
+ worktree_id: str,
553
+ force: bool = False,
554
+ project_path: str | None = None,
555
+ ) -> dict[str, Any]:
556
+ """
557
+ Delete a worktree completely (handles all cleanup).
558
+
559
+ This is the proper way to remove a worktree. It handles:
560
+ - Removes the worktree directory and all temporary files
561
+ - Cleans up git's worktree tracking (.git/worktrees/)
562
+ - Deletes the associated git branch
563
+ - Removes the Gobby database record
564
+
565
+ Do NOT manually run `git worktree remove` - use this tool instead.
566
+
567
+ Args:
568
+ worktree_id: The worktree ID to delete (e.g., "wt-abc123").
569
+ force: Force deletion even if there are uncommitted changes.
570
+ project_path: Optional path to project root to resolve git context.
571
+
572
+ Returns:
573
+ Dict with success status.
574
+ """
575
+ worktree = worktree_storage.get(worktree_id)
576
+
577
+ if not worktree:
578
+ return {
579
+ "success": False,
580
+ "error": f"Worktree '{worktree_id}' not found",
581
+ }
582
+
583
+ # Resolve git manager
584
+ resolved_git_mgr = git_manager # Start with the module-level git_manager
585
+ if project_path:
586
+ try:
587
+ # _resolve_project_context is defined in this module scope
588
+ mgr, _, _ = _resolve_project_context(project_path, resolved_git_mgr, None)
589
+ if mgr:
590
+ resolved_git_mgr = mgr
591
+ except Exception:
592
+ # nosec B110 - if context resolution fails, continue without git manager
593
+ pass
594
+
595
+ # Check for uncommitted changes if not forcing
596
+ if resolved_git_mgr and Path(worktree.worktree_path).exists():
597
+ status = resolved_git_mgr.get_worktree_status(worktree.worktree_path)
598
+ if status and status.has_uncommitted_changes and not force:
599
+ return {
600
+ "success": False,
601
+ "error": "Worktree has uncommitted changes. Use force=True to delete anyway.",
602
+ "uncommitted_changes": True,
603
+ }
604
+
605
+ # Delete git worktree
606
+ if resolved_git_mgr:
607
+ result = resolved_git_mgr.delete_worktree(
608
+ worktree.worktree_path,
609
+ force=force,
610
+ delete_branch=True,
611
+ branch_name=worktree.branch_name,
612
+ )
613
+ if not result.success:
614
+ return {
615
+ "success": False,
616
+ "error": result.error or "Failed to delete git worktree",
617
+ }
618
+
619
+ # Delete database record
620
+ deleted = worktree_storage.delete(worktree_id)
621
+ if not deleted:
622
+ return {"error": "Failed to delete worktree record"}
623
+
624
+ return {}
625
+
626
+ @registry.tool(
627
+ name="sync_worktree",
628
+ description="Sync a worktree with the main branch.",
629
+ )
630
+ async def sync_worktree(
631
+ worktree_id: str,
632
+ strategy: str = "merge",
633
+ project_path: str | None = None,
634
+ ) -> dict[str, Any]:
635
+ """
636
+ Sync a worktree with the main branch.
637
+
638
+ Args:
639
+ worktree_id: The worktree ID to sync.
640
+ strategy: Sync strategy ('merge' or 'rebase').
641
+ project_path: Path to project directory (pass cwd from CLI).
642
+
643
+ Returns:
644
+ Dict with sync result.
645
+ """
646
+ # Resolve git manager from project_path or fall back to default
647
+ resolved_git_mgr, _, error = _resolve_project_context(project_path, git_manager, project_id)
648
+ if error:
649
+ return {"success": False, "error": error}
650
+
651
+ if resolved_git_mgr is None:
652
+ return {
653
+ "success": False,
654
+ "error": "Git manager not configured and no project_path provided.",
655
+ }
656
+
657
+ worktree = worktree_storage.get(worktree_id)
658
+ if not worktree:
659
+ return {
660
+ "success": False,
661
+ "error": f"Worktree '{worktree_id}' not found",
662
+ }
663
+
664
+ # Validate strategy
665
+ if strategy not in ("rebase", "merge"):
666
+ return {
667
+ "success": False,
668
+ "error": f"Invalid strategy '{strategy}'. Must be 'rebase' or 'merge'.",
669
+ }
670
+
671
+ strategy_literal = cast(Literal["rebase", "merge"], strategy)
672
+
673
+ result = resolved_git_mgr.sync_from_main(
674
+ worktree.worktree_path,
675
+ base_branch=worktree.base_branch,
676
+ strategy=strategy_literal,
677
+ )
678
+
679
+ if not result.success:
680
+ return {
681
+ "success": False,
682
+ "error": result.error or "Sync failed",
683
+ }
684
+
685
+ # Update last activity
686
+ worktree_storage.update(worktree_id)
687
+
688
+ return {
689
+ "success": True,
690
+ "message": result.message,
691
+ "output": result.output,
692
+ "strategy": strategy,
693
+ }
694
+
695
+ @registry.tool(
696
+ name="mark_worktree_merged",
697
+ description="Mark a worktree as merged (ready for cleanup).",
698
+ )
699
+ async def mark_worktree_merged(worktree_id: str) -> dict[str, Any]:
700
+ """
701
+ Mark a worktree as merged.
702
+
703
+ Args:
704
+ worktree_id: The worktree ID to mark.
705
+
706
+ Returns:
707
+ Dict with success status.
708
+ """
709
+ worktree = worktree_storage.get(worktree_id)
710
+ if not worktree:
711
+ return {
712
+ "success": False,
713
+ "error": f"Worktree '{worktree_id}' not found",
714
+ }
715
+
716
+ updated = worktree_storage.mark_merged(worktree_id)
717
+ if not updated:
718
+ return {"error": "Failed to mark worktree as merged"}
719
+
720
+ return {}
721
+
722
+ @registry.tool(
723
+ name="detect_stale_worktrees",
724
+ description="Find worktrees with no activity for a period.",
725
+ )
726
+ async def detect_stale_worktrees(
727
+ project_path: str | None = None,
728
+ hours: int = 24,
729
+ limit: int = 50,
730
+ ) -> dict[str, Any]:
731
+ """
732
+ Find stale worktrees (no activity for N hours).
733
+
734
+ Args:
735
+ project_path: Path to project directory (pass cwd from CLI).
736
+ hours: Hours of inactivity threshold (default: 24).
737
+ limit: Maximum results (default: 50).
738
+
739
+ Returns:
740
+ Dict with list of stale worktrees.
741
+ """
742
+ _, resolved_project_id, error = _resolve_project_context(
743
+ project_path, git_manager, project_id
744
+ )
745
+ if error:
746
+ return {"success": False, "error": error}
747
+ if resolved_project_id is None:
748
+ return {"success": False, "error": "Could not resolve project ID"}
749
+
750
+ stale = worktree_storage.find_stale(
751
+ project_id=resolved_project_id,
752
+ hours=hours,
753
+ limit=limit,
754
+ )
755
+
756
+ return {
757
+ "success": True,
758
+ "stale_worktrees": [
759
+ {
760
+ "id": wt.id,
761
+ "branch_name": wt.branch_name,
762
+ "worktree_path": wt.worktree_path,
763
+ "updated_at": wt.updated_at,
764
+ "task_id": wt.task_id,
765
+ }
766
+ for wt in stale
767
+ ],
768
+ "count": len(stale),
769
+ "threshold_hours": hours,
770
+ }
771
+
772
+ @registry.tool(
773
+ name="cleanup_stale_worktrees",
774
+ description="Mark and optionally delete stale worktrees.",
775
+ )
776
+ async def cleanup_stale_worktrees(
777
+ project_path: str | None = None,
778
+ hours: int = 24,
779
+ dry_run: bool = True,
780
+ delete_git: bool = False,
781
+ ) -> dict[str, Any]:
782
+ """
783
+ Cleanup stale worktrees.
784
+
785
+ Args:
786
+ project_path: Path to project directory (pass cwd from CLI).
787
+ hours: Hours of inactivity threshold (default: 24).
788
+ dry_run: If True, only report what would be cleaned (default: True).
789
+ delete_git: If True, also delete git worktrees (default: False).
790
+
791
+ Returns:
792
+ Dict with cleanup results.
793
+ """
794
+ resolved_git_manager, resolved_project_id, error = _resolve_project_context(
795
+ project_path, git_manager, project_id
796
+ )
797
+ if error:
798
+ return {"success": False, "error": error}
799
+ if resolved_project_id is None:
800
+ return {"success": False, "error": "Could not resolve project ID"}
801
+
802
+ # Find and mark stale worktrees
803
+ stale = worktree_storage.cleanup_stale(
804
+ project_id=resolved_project_id,
805
+ hours=hours,
806
+ dry_run=dry_run,
807
+ )
808
+
809
+ results = []
810
+ for wt in stale:
811
+ result = {
812
+ "id": wt.id,
813
+ "branch_name": wt.branch_name,
814
+ "worktree_path": wt.worktree_path,
815
+ "marked_abandoned": not dry_run,
816
+ "git_deleted": False,
817
+ }
818
+
819
+ # Optionally delete git worktrees
820
+ if delete_git and not dry_run and resolved_git_manager:
821
+ git_result = resolved_git_manager.delete_worktree(
822
+ wt.worktree_path,
823
+ force=True,
824
+ delete_branch=True,
825
+ branch_name=wt.branch_name,
826
+ )
827
+ result["git_deleted"] = git_result.success
828
+ if not git_result.success:
829
+ result["git_error"] = git_result.error or "Unknown error"
830
+
831
+ results.append(result)
832
+
833
+ return {
834
+ "success": True,
835
+ "dry_run": dry_run,
836
+ "cleaned": results,
837
+ "count": len(results),
838
+ "threshold_hours": hours,
839
+ }
840
+
841
+ @registry.tool(
842
+ name="get_worktree_stats",
843
+ description="Get worktree statistics for the project.",
844
+ )
845
+ async def get_worktree_stats(project_path: str | None = None) -> dict[str, Any]:
846
+ """
847
+ Get worktree statistics.
848
+
849
+ Args:
850
+ project_path: Path to project directory (pass cwd from CLI).
851
+
852
+ Returns:
853
+ Dict with counts by status.
854
+ """
855
+ # Resolve project context (git_manager not needed for stats)
856
+ _, resolved_project_id, error = _resolve_project_context(
857
+ project_path, git_manager, project_id
858
+ )
859
+ if error:
860
+ return {"success": False, "error": error}
861
+
862
+ # Type narrowing: if no error, resolved_project_id is guaranteed non-None
863
+ if resolved_project_id is None:
864
+ raise RuntimeError("Project ID unexpectedly None")
865
+
866
+ counts = worktree_storage.count_by_status(resolved_project_id)
867
+
868
+ return {
869
+ "success": True,
870
+ "project_id": resolved_project_id,
871
+ "counts": counts,
872
+ "total": sum(counts.values()),
873
+ }
874
+
875
+ @registry.tool(
876
+ name="get_worktree_by_task",
877
+ description="Get worktree linked to a specific task.",
878
+ )
879
+ async def get_worktree_by_task(task_id: str) -> dict[str, Any]:
880
+ """
881
+ Get worktree linked to a task.
882
+
883
+ Args:
884
+ task_id: The task ID to look up.
885
+
886
+ Returns:
887
+ Dict with worktree details or not found.
888
+ """
889
+ worktree = worktree_storage.get_by_task(task_id)
890
+ if not worktree:
891
+ return {
892
+ "success": False,
893
+ "error": f"No worktree linked to task '{task_id}'",
894
+ }
895
+
896
+ return {
897
+ "success": True,
898
+ "worktree": worktree.to_dict(),
899
+ }
900
+
901
+ @registry.tool(
902
+ name="link_task_to_worktree",
903
+ description="Link a task to an existing worktree.",
904
+ )
905
+ async def link_task_to_worktree(
906
+ worktree_id: str,
907
+ task_id: str,
908
+ ) -> dict[str, Any]:
909
+ """
910
+ Link a task to a worktree.
911
+
912
+ Args:
913
+ worktree_id: The worktree ID.
914
+ task_id: The task ID to link.
915
+
916
+ Returns:
917
+ Dict with success status.
918
+ """
919
+ worktree = worktree_storage.get(worktree_id)
920
+ if not worktree:
921
+ return {
922
+ "success": False,
923
+ "error": f"Worktree '{worktree_id}' not found",
924
+ }
925
+
926
+ updated = worktree_storage.update(worktree_id, task_id=task_id)
927
+ if not updated:
928
+ return {"error": "Failed to link task to worktree"}
929
+
930
+ return {}
931
+
932
+ @registry.tool(
933
+ name="spawn_agent_in_worktree",
934
+ description="Create a worktree and spawn an agent in it.",
935
+ )
936
+ async def spawn_agent_in_worktree(
937
+ prompt: str,
938
+ branch_name: str,
939
+ base_branch: str = "main",
940
+ task_id: str | None = None,
941
+ parent_session_id: str | None = None,
942
+ mode: str = "terminal", # Note: in_process mode is not supported
943
+ terminal: str = "auto",
944
+ provider: Literal["claude", "gemini", "codex", "antigravity"] = "claude",
945
+ model: str | None = None,
946
+ workflow: str | None = None,
947
+ timeout: float = 120.0,
948
+ max_turns: int = 10,
949
+ project_path: str | None = None,
950
+ ) -> dict[str, Any]:
951
+ """
952
+ Create a worktree and spawn an agent to work in it.
953
+
954
+ This combines worktree creation with agent spawning for isolated development.
955
+
956
+ Args:
957
+ prompt: The task/prompt for the agent.
958
+ branch_name: Name for the new branch/worktree.
959
+ base_branch: Branch to base the worktree on (default: main).
960
+ task_id: Optional task ID to link to this worktree.
961
+ parent_session_id: Parent session ID for context.
962
+ mode: Execution mode (terminal, embedded, headless). Note: in_process is not supported.
963
+ terminal: Terminal for terminal/embedded modes (auto, ghostty, etc.).
964
+ provider: LLM provider (claude, gemini, etc.).
965
+ model: Optional model override.
966
+ workflow: Workflow name to execute.
967
+ timeout: Execution timeout in seconds (default: 120).
968
+ max_turns: Maximum turns (default: 10).
969
+ project_path: Path to project directory (pass cwd from CLI).
970
+
971
+ Returns:
972
+ Dict with worktree_id, run_id, and status.
973
+ """
974
+ if agent_runner is None:
975
+ return {
976
+ "success": False,
977
+ "error": "Agent runner not configured. Cannot spawn agent.",
978
+ }
979
+
980
+ # Resolve project context
981
+ resolved_git_mgr, resolved_project_id, error = _resolve_project_context(
982
+ project_path, git_manager, project_id
983
+ )
984
+ if error:
985
+ return {"success": False, "error": error}
986
+
987
+ # Type narrowing: if no error, these are guaranteed non-None
988
+ if resolved_git_mgr is None or resolved_project_id is None:
989
+ raise RuntimeError("Git manager or project ID unexpectedly None")
990
+
991
+ if parent_session_id is None:
992
+ return {
993
+ "success": False,
994
+ "error": "parent_session_id is required for agent spawning.",
995
+ }
996
+
997
+ # Handle mode aliases and validation
998
+ # "interactive" is an alias for "terminal" mode
999
+ if mode == "interactive":
1000
+ mode = "terminal"
1001
+
1002
+ valid_modes = ["terminal", "embedded", "headless"]
1003
+ if mode not in valid_modes:
1004
+ return {
1005
+ "success": False,
1006
+ "error": (
1007
+ f"Invalid mode '{mode}'. Must be one of: {', '.join(valid_modes)} (or 'interactive' as alias for 'terminal'). "
1008
+ f"Note: 'in_process' mode is not supported for spawn_agent_in_worktree."
1009
+ ),
1010
+ }
1011
+
1012
+ # Normalize terminal parameter to lowercase for enum compatibility
1013
+ # (TerminalType enum values are lowercase, e.g., "terminal.app" not "Terminal.app")
1014
+ if isinstance(terminal, str):
1015
+ terminal = terminal.lower()
1016
+
1017
+ # Validate workflow (reject lifecycle workflows)
1018
+ if workflow:
1019
+ workflow_loader = WorkflowLoader()
1020
+ is_valid, error_msg = workflow_loader.validate_workflow_for_agent(
1021
+ workflow, project_path=project_path
1022
+ )
1023
+ if not is_valid:
1024
+ return {
1025
+ "success": False,
1026
+ "error": error_msg,
1027
+ }
1028
+
1029
+ # Check if worktree already exists for this branch
1030
+ existing = worktree_storage.get_by_branch(resolved_project_id, branch_name)
1031
+ if existing:
1032
+ # Use existing worktree
1033
+ worktree = existing
1034
+ logger.info(f"Using existing worktree for branch '{branch_name}'")
1035
+ else:
1036
+ # Generate worktree path in temp directory
1037
+ project_name = Path(resolved_git_mgr.repo_path).name
1038
+ worktree_path = _generate_worktree_path(branch_name, project_name)
1039
+
1040
+ # Create git worktree
1041
+ result = resolved_git_mgr.create_worktree(
1042
+ worktree_path=worktree_path,
1043
+ branch_name=branch_name,
1044
+ base_branch=base_branch,
1045
+ create_branch=True,
1046
+ )
1047
+
1048
+ if not result.success:
1049
+ return {
1050
+ "success": False,
1051
+ "error": result.error or "Failed to create git worktree",
1052
+ }
1053
+
1054
+ # Record in database
1055
+ worktree = worktree_storage.create(
1056
+ project_id=resolved_project_id,
1057
+ branch_name=branch_name,
1058
+ worktree_path=worktree_path,
1059
+ base_branch=base_branch,
1060
+ task_id=task_id,
1061
+ )
1062
+
1063
+ # Copy project.json and install provider hooks
1064
+ _copy_project_json_to_worktree(resolved_git_mgr.repo_path, worktree.worktree_path)
1065
+ _install_provider_hooks(provider, worktree.worktree_path)
1066
+
1067
+ # Check spawn depth limit
1068
+ can_spawn, reason, _depth = agent_runner.can_spawn(parent_session_id)
1069
+ if not can_spawn:
1070
+ return {
1071
+ "success": False,
1072
+ "error": reason,
1073
+ "worktree_id": worktree.id,
1074
+ }
1075
+
1076
+ # Import AgentConfig and get machine_id
1077
+ from gobby.agents.runner import AgentConfig
1078
+ from gobby.utils.machine_id import get_machine_id
1079
+
1080
+ # Auto-detect machine_id if not provided
1081
+ machine_id = get_machine_id()
1082
+
1083
+ # Create agent config with worktree
1084
+ config = AgentConfig(
1085
+ prompt=prompt,
1086
+ parent_session_id=parent_session_id,
1087
+ project_id=resolved_project_id,
1088
+ machine_id=machine_id,
1089
+ source=provider,
1090
+ workflow=workflow,
1091
+ task=task_id,
1092
+ session_context="summary_markdown",
1093
+ mode=mode,
1094
+ terminal=terminal,
1095
+ worktree_id=worktree.id,
1096
+ provider=provider,
1097
+ model=model,
1098
+ max_turns=max_turns,
1099
+ timeout=timeout,
1100
+ project_path=worktree.worktree_path,
1101
+ )
1102
+
1103
+ # For terminal/embedded/headless modes, use prepare_run + spawner
1104
+ # (runner.run() is only for in_process mode)
1105
+ from gobby.llm.executor import AgentResult
1106
+
1107
+ prepare_result = agent_runner.prepare_run(config)
1108
+ if isinstance(prepare_result, AgentResult):
1109
+ # prepare_run returns AgentResult on error
1110
+ return {
1111
+ "success": False,
1112
+ "worktree_id": worktree.id,
1113
+ "worktree_path": worktree.worktree_path,
1114
+ "branch_name": worktree.branch_name,
1115
+ "error": prepare_result.error,
1116
+ }
1117
+
1118
+ # Successfully prepared - we have context with session and run
1119
+ context = prepare_result
1120
+
1121
+ if context.session is None or context.run is None:
1122
+ return {
1123
+ "success": False,
1124
+ "worktree_id": worktree.id,
1125
+ "error": "Internal error: context missing session or run after prepare_run",
1126
+ }
1127
+
1128
+ child_session = context.session
1129
+ agent_run = context.run
1130
+
1131
+ # Claim worktree for the child session
1132
+ worktree_storage.claim(worktree.id, child_session.id)
1133
+
1134
+ # Pre-save workflow state with session_task if task_id is provided
1135
+ # This ensures suggest_next_task() will scope to this task's subtasks
1136
+ if task_id and workflow:
1137
+ try:
1138
+ workflow_state_manager = WorkflowStateManager(worktree_storage.db)
1139
+ initial_state = WorkflowState(
1140
+ session_id=child_session.id,
1141
+ workflow_name=workflow,
1142
+ step="", # Will be set when workflow actually starts
1143
+ variables={"session_task": task_id},
1144
+ )
1145
+ workflow_state_manager.save_state(initial_state)
1146
+ logger.debug(
1147
+ f"Pre-saved workflow state for session {child_session.id} "
1148
+ f"with session_task={task_id}"
1149
+ )
1150
+ except Exception as e:
1151
+ logger.warning(f"Failed to pre-save workflow state: {e}")
1152
+ # Continue anyway - this is an optimization, not a requirement
1153
+
1154
+ # Build enhanced prompt with worktree context
1155
+ # This helps the agent understand it's in an isolated worktree, not the main repo
1156
+ enhanced_prompt = _build_worktree_context_prompt(
1157
+ original_prompt=prompt,
1158
+ worktree_path=worktree.worktree_path,
1159
+ branch_name=worktree.branch_name,
1160
+ task_id=task_id,
1161
+ main_repo_path=str(resolved_git_mgr.repo_path),
1162
+ )
1163
+
1164
+ # Spawn in terminal using TerminalSpawner
1165
+ if mode == "terminal":
1166
+ from gobby.agents.spawn import TerminalSpawner
1167
+
1168
+ terminal_spawner = TerminalSpawner()
1169
+ terminal_result = terminal_spawner.spawn_agent(
1170
+ cli=provider, # claude, gemini, codex
1171
+ cwd=worktree.worktree_path,
1172
+ session_id=child_session.id,
1173
+ parent_session_id=parent_session_id,
1174
+ agent_run_id=agent_run.id,
1175
+ project_id=resolved_project_id,
1176
+ workflow_name=workflow,
1177
+ agent_depth=child_session.agent_depth,
1178
+ max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
1179
+ terminal=terminal,
1180
+ prompt=enhanced_prompt,
1181
+ )
1182
+
1183
+ if not terminal_result.success:
1184
+ return {
1185
+ "success": False,
1186
+ "worktree_id": worktree.id,
1187
+ "worktree_path": worktree.worktree_path,
1188
+ "branch_name": worktree.branch_name,
1189
+ "run_id": agent_run.id,
1190
+ "child_session_id": child_session.id,
1191
+ "error": terminal_result.error or terminal_result.message,
1192
+ }
1193
+
1194
+ return {
1195
+ "success": True,
1196
+ "worktree_id": worktree.id,
1197
+ "worktree_path": worktree.worktree_path,
1198
+ "branch_name": worktree.branch_name,
1199
+ "run_id": agent_run.id,
1200
+ "child_session_id": child_session.id,
1201
+ "status": "pending",
1202
+ "message": f"Agent spawned in {terminal_result.terminal_type} (PID: {terminal_result.pid})",
1203
+ "terminal_type": terminal_result.terminal_type,
1204
+ "pid": terminal_result.pid,
1205
+ }
1206
+
1207
+ elif mode == "embedded":
1208
+ from gobby.agents.spawn import EmbeddedSpawner
1209
+
1210
+ embedded_spawner = EmbeddedSpawner()
1211
+ embedded_result = embedded_spawner.spawn_agent(
1212
+ cli=provider,
1213
+ cwd=worktree.worktree_path,
1214
+ session_id=child_session.id,
1215
+ parent_session_id=parent_session_id,
1216
+ agent_run_id=agent_run.id,
1217
+ project_id=resolved_project_id,
1218
+ workflow_name=workflow,
1219
+ agent_depth=child_session.agent_depth,
1220
+ max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
1221
+ prompt=enhanced_prompt,
1222
+ )
1223
+
1224
+ return {
1225
+ "success": embedded_result.success,
1226
+ "worktree_id": worktree.id,
1227
+ "worktree_path": worktree.worktree_path,
1228
+ "branch_name": worktree.branch_name,
1229
+ "run_id": agent_run.id,
1230
+ "child_session_id": child_session.id,
1231
+ "status": "pending" if embedded_result.success else "error",
1232
+ "error": embedded_result.error if not embedded_result.success else None,
1233
+ }
1234
+
1235
+ else: # headless
1236
+ from gobby.agents.spawn import HeadlessSpawner
1237
+
1238
+ headless_spawner = HeadlessSpawner()
1239
+ headless_result = headless_spawner.spawn_agent(
1240
+ cli=provider,
1241
+ cwd=worktree.worktree_path,
1242
+ session_id=child_session.id,
1243
+ parent_session_id=parent_session_id,
1244
+ agent_run_id=agent_run.id,
1245
+ project_id=resolved_project_id,
1246
+ workflow_name=workflow,
1247
+ agent_depth=child_session.agent_depth,
1248
+ max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
1249
+ prompt=enhanced_prompt,
1250
+ )
1251
+
1252
+ return {
1253
+ "success": headless_result.success,
1254
+ "worktree_id": worktree.id,
1255
+ "worktree_path": worktree.worktree_path,
1256
+ "branch_name": worktree.branch_name,
1257
+ "run_id": agent_run.id,
1258
+ "child_session_id": child_session.id,
1259
+ "status": "pending" if headless_result.success else "error",
1260
+ "pid": headless_result.pid if headless_result.success else None,
1261
+ "error": headless_result.error if not headless_result.success else None,
1262
+ }
1263
+
1264
+ return registry