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,311 @@
1
+ """Task lifecycle operations.
2
+
3
+ This module provides operations for managing task lifecycle:
4
+ - close_task: Close a task
5
+ - reopen_task: Reopen a closed/review task
6
+ - add_label, remove_label: Manage task labels
7
+ - link_commit, unlink_commit: Manage task-commit associations
8
+ - delete_task: Delete a task
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ from gobby.storage.database import DatabaseProtocol
17
+ from gobby.storage.tasks._crud import get_task, update_task
18
+ from gobby.storage.tasks._models import Task
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def close_task(
24
+ db: DatabaseProtocol,
25
+ task_id: str,
26
+ reason: str | None = None,
27
+ force: bool = False,
28
+ closed_in_session_id: str | None = None,
29
+ closed_commit_sha: str | None = None,
30
+ validation_override_reason: str | None = None,
31
+ ) -> None:
32
+ """Close a task.
33
+
34
+ Args:
35
+ db: Database protocol instance
36
+ task_id: The task ID to close
37
+ reason: Optional reason for closing
38
+ force: If True, close even if there are open children (default: False)
39
+ closed_in_session_id: Session ID where task was closed
40
+ closed_commit_sha: Git commit SHA at time of closing
41
+ validation_override_reason: Why agent bypassed validation (if applicable)
42
+
43
+ Raises:
44
+ ValueError: If task not found or has open children (and force=False)
45
+ """
46
+ # Check for open children unless force=True
47
+ if not force:
48
+ open_children = db.fetchall(
49
+ "SELECT id, title FROM tasks WHERE parent_task_id = ? AND status != 'closed'",
50
+ (task_id,),
51
+ )
52
+ if open_children:
53
+ child_list = ", ".join(f"{c['id']} ({c['title']})" for c in open_children[:3])
54
+ if len(open_children) > 3:
55
+ child_list += f" and {len(open_children) - 3} more"
56
+ raise ValueError(
57
+ f"Cannot close task {task_id}: has {len(open_children)} open child task(s): {child_list}"
58
+ )
59
+
60
+ # Check if task is being closed from review state (user acceptance)
61
+ current_task = get_task(db, task_id)
62
+ accepted_by_user = current_task.status == "review" if current_task else False
63
+
64
+ now = datetime.now(UTC).isoformat()
65
+ with db.transaction() as conn:
66
+ cursor = conn.execute(
67
+ """UPDATE tasks SET
68
+ status = 'closed',
69
+ closed_reason = ?,
70
+ closed_at = ?,
71
+ closed_in_session_id = ?,
72
+ closed_commit_sha = ?,
73
+ validation_override_reason = ?,
74
+ accepted_by_user = ?,
75
+ updated_at = ?
76
+ WHERE id = ?""",
77
+ (
78
+ reason,
79
+ now,
80
+ closed_in_session_id,
81
+ closed_commit_sha,
82
+ validation_override_reason,
83
+ 1 if accepted_by_user else 0,
84
+ now,
85
+ task_id,
86
+ ),
87
+ )
88
+ if cursor.rowcount == 0:
89
+ raise ValueError(f"Task {task_id} not found")
90
+
91
+ # Update any associated worktrees to merged status (outside transaction)
92
+ # This is best-effort and should not roll back the task close
93
+ try:
94
+ db.execute(
95
+ """UPDATE worktrees SET status = 'merged', updated_at = ?
96
+ WHERE task_id = ? AND status = 'active'""",
97
+ (now, task_id),
98
+ )
99
+ except Exception as wt_err:
100
+ # Worktree update is best-effort, don't fail task close
101
+ logger.debug(f"Failed to update worktree status for task {task_id}: {wt_err}")
102
+
103
+
104
+ def reopen_task(
105
+ db: DatabaseProtocol,
106
+ task_id: str,
107
+ reason: str | None = None,
108
+ ) -> None:
109
+ """Reopen a closed or review task.
110
+
111
+ Args:
112
+ db: Database protocol instance
113
+ task_id: The task ID to reopen
114
+ reason: Optional reason for reopening
115
+
116
+ Raises:
117
+ ValueError: If task not found or not closed/review
118
+ """
119
+ task = get_task(db, task_id)
120
+ if task.status not in ("closed", "review"):
121
+ raise ValueError(f"Task {task_id} is not closed or in review (status: {task.status})")
122
+
123
+ now = datetime.now(UTC).isoformat()
124
+
125
+ # Build description update if reason provided
126
+ new_description = task.description or ""
127
+ if reason:
128
+ reopen_note = f"\n\n[Reopened: {reason}]"
129
+ new_description = new_description + reopen_note
130
+
131
+ with db.transaction() as conn:
132
+ conn.execute(
133
+ """UPDATE tasks SET
134
+ status = 'open',
135
+ closed_reason = NULL,
136
+ closed_at = NULL,
137
+ closed_in_session_id = NULL,
138
+ closed_commit_sha = NULL,
139
+ accepted_by_user = 0,
140
+ description = ?,
141
+ updated_at = ?
142
+ WHERE id = ?""",
143
+ (new_description if reason else task.description, now, task_id),
144
+ )
145
+
146
+ # Reactivate any merged or abandoned worktrees for this task (outside transaction)
147
+ # This is best-effort and should not roll back the task reopen
148
+ try:
149
+ db.execute(
150
+ """UPDATE worktrees SET status = 'active', updated_at = ?
151
+ WHERE task_id = ? AND status IN ('merged', 'abandoned')""",
152
+ (now, task_id),
153
+ )
154
+ except Exception as wt_err:
155
+ # Worktree update is best-effort, don't fail task reopen
156
+ logger.debug(f"Failed to reactivate worktree for task {task_id}: {wt_err}")
157
+
158
+
159
+ def add_label(db: DatabaseProtocol, task_id: str, label: str) -> Task:
160
+ """Add a label to a task if not present."""
161
+ task = get_task(db, task_id)
162
+ labels = task.labels or []
163
+ if label not in labels:
164
+ labels.append(label)
165
+ update_task(db, task_id, labels=labels)
166
+ return get_task(db, task_id)
167
+ return task
168
+
169
+
170
+ def remove_label(db: DatabaseProtocol, task_id: str, label: str) -> Task:
171
+ """Remove a label from a task if present."""
172
+ task = get_task(db, task_id)
173
+ labels = task.labels or []
174
+ if label in labels:
175
+ labels.remove(label)
176
+ update_task(db, task_id, labels=labels)
177
+ return get_task(db, task_id)
178
+ return task
179
+
180
+
181
+ def link_commit(
182
+ db: DatabaseProtocol, task_id: str, commit_sha: str, cwd: str | Path | None = None
183
+ ) -> bool:
184
+ """Link a commit SHA to a task.
185
+
186
+ Adds the commit SHA to the task's commits array if not already present.
187
+ The SHA is normalized to dynamic short format for consistency.
188
+
189
+ Args:
190
+ db: Database protocol instance
191
+ task_id: The task ID to link the commit to.
192
+ commit_sha: The git commit SHA to link (short or full).
193
+ cwd: Working directory for git operations (defaults to current directory).
194
+
195
+ Returns:
196
+ True if commit was added, False if already present.
197
+
198
+ Raises:
199
+ ValueError: If task not found or SHA cannot be resolved.
200
+ """
201
+ from gobby.utils.git import normalize_commit_sha
202
+
203
+ # Normalize SHA to dynamic short format
204
+ normalized_sha = normalize_commit_sha(commit_sha, cwd=cwd)
205
+ if not normalized_sha:
206
+ raise ValueError(f"Invalid or unresolved commit SHA: {commit_sha}")
207
+
208
+ task = get_task(db, task_id) # Raises if not found
209
+ commits = task.commits or []
210
+ if normalized_sha not in commits:
211
+ commits.append(normalized_sha)
212
+ # Update the commits column in the database
213
+ now = datetime.now(UTC).isoformat()
214
+ with db.transaction() as conn:
215
+ conn.execute(
216
+ "UPDATE tasks SET commits = ?, updated_at = ? WHERE id = ?",
217
+ (json.dumps(commits), now, task_id),
218
+ )
219
+ return True
220
+ return False
221
+
222
+
223
+ def unlink_commit(
224
+ db: DatabaseProtocol, task_id: str, commit_sha: str, cwd: str | Path | None = None
225
+ ) -> bool:
226
+ """Unlink a commit SHA from a task.
227
+
228
+ Removes the commit SHA from the task's commits array if present.
229
+ Handles both normalized and legacy SHA formats via prefix matching.
230
+
231
+ Args:
232
+ db: Database protocol instance
233
+ task_id: The task ID to unlink the commit from.
234
+ commit_sha: The git commit SHA to unlink (short or full).
235
+ cwd: Working directory for git operations (defaults to current directory).
236
+
237
+ Returns:
238
+ True if commit was removed, False if not found.
239
+
240
+ Raises:
241
+ ValueError: If task not found.
242
+ """
243
+ from gobby.utils.git import normalize_commit_sha
244
+
245
+ # Try to normalize - if it fails, fall back to prefix matching
246
+ normalized_sha = normalize_commit_sha(commit_sha, cwd=cwd)
247
+
248
+ task = get_task(db, task_id) # Raises if not found
249
+ commits = task.commits or []
250
+
251
+ # Find matching commit (handle both normalized and legacy SHAs)
252
+ sha_to_remove = None
253
+ for stored_sha in commits:
254
+ # Exact match with normalized SHA
255
+ if normalized_sha and stored_sha == normalized_sha:
256
+ sha_to_remove = stored_sha
257
+ break
258
+ # Prefix matching for legacy mixed-format data
259
+ if stored_sha.startswith(commit_sha) or commit_sha.startswith(stored_sha):
260
+ sha_to_remove = stored_sha
261
+ break
262
+
263
+ if sha_to_remove:
264
+ commits.remove(sha_to_remove)
265
+ # Update the commits column in the database
266
+ now = datetime.now(UTC).isoformat()
267
+ commits_json = json.dumps(commits) if commits else None
268
+ with db.transaction() as conn:
269
+ conn.execute(
270
+ "UPDATE tasks SET commits = ?, updated_at = ? WHERE id = ?",
271
+ (commits_json, now, task_id),
272
+ )
273
+ return True
274
+ return False
275
+
276
+
277
+ def delete_task(db: DatabaseProtocol, task_id: str, cascade: bool = False) -> bool:
278
+ """Delete a task. If cascade is True, delete children recursively.
279
+
280
+ Args:
281
+ db: Database protocol instance
282
+ task_id: The task ID to delete
283
+ cascade: If True, delete children recursively (default: False)
284
+
285
+ Returns:
286
+ True if task was deleted, False if task not found.
287
+
288
+ Raises:
289
+ ValueError: If task has children and cascade is False.
290
+ """
291
+ # Check if task exists first
292
+ existing = db.fetchone("SELECT 1 FROM tasks WHERE id = ?", (task_id,))
293
+ if not existing:
294
+ return False
295
+
296
+ if not cascade:
297
+ # Check for children
298
+ row = db.fetchone("SELECT 1 FROM tasks WHERE parent_task_id = ?", (task_id,))
299
+ if row:
300
+ raise ValueError(f"Task {task_id} has children. Use cascade=True to delete.")
301
+
302
+ if cascade:
303
+ # Recursive delete
304
+ # Find all children
305
+ children = db.fetchall("SELECT id FROM tasks WHERE parent_task_id = ?", (task_id,))
306
+ for child in children:
307
+ delete_task(db, child["id"], cascade=True)
308
+
309
+ with db.transaction() as conn:
310
+ conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
311
+ return True