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,30 @@
1
+ """
2
+ Task management CLI commands.
3
+
4
+ This package contains the task management commands, split into logical modules:
5
+ - _utils: Shared utilities (formatting, task resolution)
6
+ - ai: AI-powered commands (validate, expand, suggest, complexity)
7
+ - crud: CRUD operations (list, create, show, update, close, delete)
8
+ - deps: Dependency management subgroup
9
+ - hooks: Git hooks management subgroup
10
+ - labels: Label management subgroup
11
+ - main: Entry point and misc commands (sync, compact, import, doctor, clean)
12
+ """
13
+
14
+ from gobby.cli.tasks._utils import (
15
+ cascade_progress,
16
+ check_tasks_enabled,
17
+ get_sync_manager,
18
+ get_task_manager,
19
+ parse_task_refs,
20
+ )
21
+ from gobby.cli.tasks.main import tasks
22
+
23
+ __all__ = [
24
+ "cascade_progress",
25
+ "check_tasks_enabled",
26
+ "get_task_manager",
27
+ "get_sync_manager",
28
+ "parse_task_refs",
29
+ "tasks",
30
+ ]
@@ -0,0 +1,658 @@
1
+ """
2
+ Shared utilities for task CLI commands.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import sys
8
+ from collections.abc import Callable, Generator, Iterator
9
+ from contextlib import contextmanager
10
+ from typing import TYPE_CHECKING
11
+
12
+ import click
13
+ from wcwidth import wcswidth
14
+
15
+ from gobby.config.app import load_config
16
+ from gobby.storage.database import LocalDatabase
17
+ from gobby.storage.migrations import run_migrations
18
+ from gobby.storage.tasks import LocalTaskManager, Task
19
+ from gobby.sync.tasks import TaskSyncManager
20
+ from gobby.utils.project_context import get_project_context
21
+
22
+ if TYPE_CHECKING:
23
+ pass # LocalTaskManager already imported above
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def check_tasks_enabled() -> None:
29
+ """Check if gobby-tasks is enabled, exit if not."""
30
+ try:
31
+ config = load_config()
32
+ if not config.gobby_tasks.enabled:
33
+ click.echo("Error: gobby-tasks is disabled in config.yaml", err=True)
34
+ sys.exit(1)
35
+ except (FileNotFoundError, AttributeError, ImportError):
36
+ # Expected errors if config missing or invalid
37
+ # Fail open to allow CLI to work even if config is borked
38
+ pass
39
+ except Exception as e:
40
+ # Unexpected errors handling config
41
+ logger.warning(f"Error checking tasks config: {e}")
42
+ pass
43
+
44
+
45
+ def get_task_manager() -> LocalTaskManager:
46
+ """Get initialized task manager."""
47
+ db = LocalDatabase()
48
+ run_migrations(db)
49
+ return LocalTaskManager(db)
50
+
51
+
52
+ def get_sync_manager() -> TaskSyncManager:
53
+ """Get initialized sync manager."""
54
+ manager = get_task_manager()
55
+ return TaskSyncManager(manager, export_path=".gobby/tasks.jsonl")
56
+
57
+
58
+ def normalize_status(status: str) -> str:
59
+ """Normalize status values for user-friendly CLI input.
60
+
61
+ Converts hyphen-separated status names to underscore format:
62
+ in-progress -> in_progress
63
+ needs-decomposition -> needs_decomposition
64
+
65
+ Also handles common variations.
66
+ """
67
+ # Replace hyphens with underscores for user convenience
68
+ return status.replace("-", "_")
69
+
70
+
71
+ def get_claimed_task_ids() -> set[str]:
72
+ """Get task IDs that are claimed by active sessions via session_task variable.
73
+
74
+ Queries workflow_states for active sessions that have a session_task variable set,
75
+ indicating the task is being actively worked on by that session.
76
+
77
+ Supports session_task in multiple formats:
78
+ - #N: Resolved to UUID via seq_num lookup
79
+ - UUID: Used directly
80
+ - Partial UUID prefix: Used for prefix matching
81
+
82
+ Returns:
83
+ Set of task UUIDs claimed by active sessions
84
+ """
85
+ try:
86
+ db = LocalDatabase()
87
+ try:
88
+ # Join workflow_states with sessions to find active sessions with session_task
89
+ rows = db.fetchall(
90
+ """
91
+ SELECT ws.variables, s.project_id
92
+ FROM workflow_states ws
93
+ JOIN sessions s ON ws.session_id = s.id
94
+ WHERE s.status = 'active'
95
+ AND ws.variables IS NOT NULL
96
+ AND ws.variables != '{}'
97
+ """
98
+ )
99
+
100
+ claimed_ids: set[str] = set()
101
+
102
+ def resolve_task_ref(ref: str, project_id: str | None) -> str | None:
103
+ """Resolve a task reference to UUID."""
104
+ if not ref or ref == "*":
105
+ return None
106
+
107
+ # #N format - resolve via seq_num
108
+ if ref.startswith("#"):
109
+ try:
110
+ seq_num = int(ref[1:])
111
+ row = db.fetchone(
112
+ "SELECT id FROM tasks WHERE project_id = ? AND seq_num = ?",
113
+ (project_id, seq_num),
114
+ )
115
+ return row["id"] if row else None
116
+ except (ValueError, TypeError):
117
+ return None
118
+
119
+ # Check if it looks like a UUID (36 chars with dashes)
120
+ if len(ref) == 36 and ref.count("-") == 4:
121
+ return ref
122
+
123
+ # Partial UUID prefix - find matching task
124
+ row = db.fetchone(
125
+ "SELECT id FROM tasks WHERE id LIKE ? AND project_id = ?",
126
+ (f"%{ref}%", project_id),
127
+ )
128
+ return row["id"] if row else None
129
+
130
+ for row in rows:
131
+ try:
132
+ variables = json.loads(row["variables"]) if row["variables"] else {}
133
+ project_id = row["project_id"]
134
+ if session_task := variables.get("session_task"):
135
+ # session_task can be: string, list of strings, or "*" (wildcard)
136
+ if isinstance(session_task, list):
137
+ for task_ref in session_task:
138
+ if resolved := resolve_task_ref(task_ref, project_id):
139
+ claimed_ids.add(resolved)
140
+ elif session_task != "*":
141
+ if resolved := resolve_task_ref(session_task, project_id):
142
+ claimed_ids.add(resolved)
143
+ except (json.JSONDecodeError, TypeError):
144
+ continue
145
+
146
+ return claimed_ids
147
+ finally:
148
+ db.close()
149
+ except Exception as e:
150
+ logger.debug(f"Failed to get claimed task IDs: {e}")
151
+ return set()
152
+
153
+
154
+ def pad_to_width(text: str, width: int) -> str:
155
+ """Pad a string to a visual width, accounting for wide characters like emoji."""
156
+ visual_width: int = wcswidth(text)
157
+ if visual_width < 0:
158
+ visual_width = len(text) # Fallback if wcswidth fails
159
+ padding: int = width - visual_width
160
+ return text + " " * max(0, padding)
161
+
162
+
163
+ def collect_ancestors(
164
+ tasks: list[Task], task_manager: "LocalTaskManager"
165
+ ) -> tuple[list[Task], set[str]]:
166
+ """Collect ancestor tasks to maintain tree hierarchy.
167
+
168
+ When filtering tasks (e.g., --ready), we may have tasks whose parents
169
+ are not in the filtered list. This function fetches those ancestors
170
+ so the tree structure is preserved.
171
+
172
+ Args:
173
+ tasks: The filtered list of tasks
174
+ task_manager: Task manager for fetching ancestors
175
+
176
+ Returns:
177
+ Tuple of (combined task list with ancestors, set of original task IDs)
178
+ """
179
+ task_by_id = {t.id: t for t in tasks}
180
+ original_ids = set(task_by_id.keys())
181
+ ancestors_to_fetch: set[str] = set()
182
+
183
+ # Find all ancestors that are missing from the list
184
+ for task in tasks:
185
+ parent_id = task.parent_task_id
186
+ while parent_id and parent_id not in task_by_id:
187
+ ancestors_to_fetch.add(parent_id)
188
+ # We need to fetch the parent to check its parent
189
+ try:
190
+ parent = task_manager.get_task(parent_id)
191
+ task_by_id[parent_id] = parent
192
+ parent_id = parent.parent_task_id
193
+ except (ValueError, Exception):
194
+ break
195
+
196
+ # Combine original tasks with ancestors
197
+ combined = list(tasks)
198
+ for ancestor_id in ancestors_to_fetch:
199
+ if ancestor_id in task_by_id:
200
+ combined.append(task_by_id[ancestor_id])
201
+
202
+ return combined, original_ids
203
+
204
+
205
+ def sort_tasks_for_tree(tasks: list[Task]) -> list[Task]:
206
+ """Sort tasks for tree display (parent before children, depth-first).
207
+
208
+ Returns a new list with tasks sorted in tree traversal order.
209
+ Preserves the input order within each parent group (respecting
210
+ topological sort from storage layer).
211
+ """
212
+ task_by_id = {t.id: t for t in tasks}
213
+ # Preserve input order via index lookup
214
+ input_order = {t.id: i for i, t in enumerate(tasks)}
215
+
216
+ # Group children by parent
217
+ children_by_parent: dict[str | None, list[Task]] = {}
218
+ for task in tasks:
219
+ parent_id = task.parent_task_id
220
+ if parent_id and parent_id not in task_by_id:
221
+ parent_id = None
222
+ if parent_id not in children_by_parent:
223
+ children_by_parent[parent_id] = []
224
+ children_by_parent[parent_id].append(task)
225
+
226
+ # Sort children within each parent by input order (preserves topological sort)
227
+ for children in children_by_parent.values():
228
+ children.sort(key=lambda t: input_order.get(t.id, float("inf")))
229
+
230
+ # Build sorted list via depth-first traversal
231
+ sorted_tasks: list[Task] = []
232
+
233
+ def traverse(task: Task) -> None:
234
+ sorted_tasks.append(task)
235
+ for child in children_by_parent.get(task.id, []):
236
+ traverse(child)
237
+
238
+ for root_task in children_by_parent.get(None, []):
239
+ traverse(root_task)
240
+
241
+ return sorted_tasks
242
+
243
+
244
+ def compute_tree_prefixes(
245
+ tasks: list[Task], primary_ids: set[str] | None = None
246
+ ) -> dict[str, tuple[str, bool]]:
247
+ """Compute tree-style prefixes for each task in the hierarchy.
248
+
249
+ Args:
250
+ tasks: List of tasks to compute prefixes for
251
+ primary_ids: Optional set of "primary" task IDs. Tasks not in this set
252
+ are considered ancestors (shown muted). If None, all tasks
253
+ are considered primary.
254
+
255
+ Returns:
256
+ Dict mapping task_id -> (prefix string, is_primary).
257
+ prefix is e.g., "├── ", "│ └── "
258
+ is_primary is True if task is in primary_ids (or primary_ids is None)
259
+ """
260
+ task_by_id = {t.id: t for t in tasks}
261
+ # Preserve input order via index lookup
262
+ input_order = {t.id: i for i, t in enumerate(tasks)}
263
+ if primary_ids is None:
264
+ primary_ids = set(task_by_id.keys())
265
+
266
+ # Group children by parent
267
+ children_by_parent: dict[str | None, list[Task]] = {}
268
+ for task in tasks:
269
+ parent_id = task.parent_task_id
270
+ if parent_id and parent_id not in task_by_id:
271
+ parent_id = None
272
+ if parent_id not in children_by_parent:
273
+ children_by_parent[parent_id] = []
274
+ children_by_parent[parent_id].append(task)
275
+
276
+ # Sort children within each parent by input order (preserves topological sort)
277
+ for children in children_by_parent.values():
278
+ children.sort(key=lambda t: input_order.get(t.id, float("inf")))
279
+
280
+ prefixes: dict[str, tuple[str, bool]] = {}
281
+
282
+ def compute_prefix(task: Task, ancestor_continues: list[bool]) -> None:
283
+ """Recursively compute prefix for task and its children."""
284
+ is_primary = task.id in primary_ids
285
+
286
+ if not task.parent_task_id or task.parent_task_id not in task_by_id:
287
+ # Root task - no prefix
288
+ prefixes[task.id] = ("", is_primary)
289
+ else:
290
+ # Build prefix from ancestor continuation markers
291
+ prefix_parts = []
292
+ for continues in ancestor_continues[:-1]:
293
+ prefix_parts.append("│ " if continues else " ")
294
+ # Add the branch for this task
295
+ if ancestor_continues:
296
+ is_last = not ancestor_continues[-1]
297
+ prefix_parts.append("└── " if is_last else "├── ")
298
+ prefixes[task.id] = ("".join(prefix_parts), is_primary)
299
+
300
+ # Process children
301
+ children = children_by_parent.get(task.id, [])
302
+ for i, child in enumerate(children):
303
+ is_last_child = i == len(children) - 1
304
+ compute_prefix(child, ancestor_continues + [not is_last_child])
305
+
306
+ # Start with root tasks
307
+ for root_task in children_by_parent.get(None, []):
308
+ compute_prefix(root_task, [])
309
+
310
+ return prefixes
311
+
312
+
313
+ # Column widths for task table
314
+ COL_STATUS = 1 # Status icon
315
+ COL_PRIORITY = 2 # Priority emoji (2 visual chars)
316
+ COL_ID = 6 # #N format (e.g., #1234)
317
+
318
+
319
+ def format_task_row(
320
+ task: Task,
321
+ tree_prefix: str = "",
322
+ is_primary: bool = True,
323
+ muted: bool = False,
324
+ claimed_task_ids: set[str] | None = None,
325
+ ) -> str:
326
+ """Format a task for list output.
327
+
328
+ Args:
329
+ task: The task to format
330
+ tree_prefix: Tree-style prefix (e.g., "├── ", "│ └── ")
331
+ is_primary: If False, task is an ancestor shown for context (muted style)
332
+ muted: Explicit muted flag (overrides is_primary)
333
+ claimed_task_ids: Set of task IDs claimed by active sessions
334
+ """
335
+ show_muted = muted or not is_primary
336
+ is_claimed = claimed_task_ids is not None and task.id in claimed_task_ids
337
+
338
+ # Status icons:
339
+ # ○ = open, unclaimed
340
+ # ◐ = open, claimed by active session
341
+ # ● = in_progress
342
+ # ✓ = completed/closed
343
+ # ⊗ = blocked
344
+ # ⚠ = escalated
345
+ if task.status == "open" and is_claimed:
346
+ status_icon = "◐" # Open but claimed by active session
347
+ else:
348
+ status_icon = {
349
+ "open": "○",
350
+ "in_progress": "●",
351
+ "completed": "✓",
352
+ "closed": "✓",
353
+ "blocked": "⊗",
354
+ "escalated": "⚠",
355
+ }.get(task.status, "?")
356
+
357
+ priority_icon = {
358
+ 0: "🟣", # Critical
359
+ 1: "🔴", # High
360
+ 2: "🟡", # Medium
361
+ 3: "🔵", # Low
362
+ 4: "⚪", # Backlog
363
+ }.get(task.priority, "⚪")
364
+
365
+ # Build row with proper visual width padding
366
+ status_col = pad_to_width(status_icon, COL_STATUS)
367
+ priority_col = pad_to_width(priority_icon, COL_PRIORITY)
368
+ # Use #N format for display (seq_num), fallback to short UUID prefix
369
+ task_ref = f"#{task.seq_num}" if task.seq_num else task.id[:8]
370
+ id_col = pad_to_width(task_ref, COL_ID)
371
+
372
+ title = task.title
373
+ if show_muted:
374
+ # Use dim ANSI escape for muted ancestors
375
+ # \033[2m = dim, \033[0m = reset
376
+ title = f"\033[2m{task.title}\033[0m"
377
+
378
+ return f"{status_col} {priority_col} {id_col} {tree_prefix}{title}"
379
+
380
+
381
+ def format_task_header() -> str:
382
+ """Return header row for task list."""
383
+ status_col = pad_to_width("", COL_STATUS)
384
+ priority_col = pad_to_width("", COL_PRIORITY)
385
+ id_col = pad_to_width("#", COL_ID)
386
+
387
+ return f"{status_col} {priority_col} {id_col} TITLE"
388
+
389
+
390
+ def resolve_task_id(
391
+ manager: LocalTaskManager, task_id: str, project_id: str | None = None
392
+ ) -> Task | None:
393
+ """Resolve a task ID to a Task with user-friendly errors.
394
+
395
+ Supports multiple reference formats:
396
+ - #N: Project-scoped seq_num (e.g., #1, #47) - requires project_id
397
+ - 1.2.3: Path cache format - requires project_id
398
+ - UUID: Direct UUID lookup
399
+ - Prefix: ID prefix matching for partial UUIDs
400
+
401
+ Args:
402
+ manager: The task manager
403
+ task_id: Task reference in any supported format
404
+ project_id: Project ID for scoped lookups (#N and path formats).
405
+ If not provided, will try to get from project context.
406
+
407
+ Returns:
408
+ The resolved Task, or None if not found (with error message printed)
409
+ """
410
+ from gobby.storage.tasks import TaskNotFoundError
411
+
412
+ # Get project_id from context if not provided
413
+ if project_id is None:
414
+ ctx = get_project_context()
415
+ project_id = ctx.get("id") if ctx else None
416
+
417
+ # Try #N format, numeric format (treated as #N), or path format (requires project_id)
418
+ if project_id and (task_id.startswith("#") or task_id.isdigit() or _is_path_format(task_id)):
419
+ # Auto-prefix numeric IDs with #
420
+ if task_id.isdigit():
421
+ task_id = f"#{task_id}"
422
+
423
+ try:
424
+ resolved_uuid = manager.resolve_task_reference(task_id, project_id)
425
+ return manager.get_task(resolved_uuid)
426
+ except TaskNotFoundError as e:
427
+ click.echo(f"Task '{task_id}' not found: {e}", err=True)
428
+ return None
429
+ except ValueError as e:
430
+ # Deprecation or format errors
431
+ click.echo(f"Error: {e}", err=True)
432
+ return None
433
+
434
+ # Try exact UUID match
435
+ try:
436
+ return manager.get_task(task_id)
437
+ except ValueError:
438
+ pass
439
+
440
+ # Try prefix matching for partial UUIDs
441
+ matches = manager.find_tasks_by_prefix(task_id)
442
+
443
+ if len(matches) == 0:
444
+ click.echo(f"Task '{task_id}' not found", err=True)
445
+ return None
446
+ elif len(matches) == 1:
447
+ return matches[0]
448
+ else:
449
+ click.echo(f"Ambiguous task ID '{task_id}' matches {len(matches)} tasks:", err=True)
450
+ for task in matches[:5]:
451
+ click.echo(f" {task.id}: {task.title}", err=True)
452
+ if len(matches) > 5:
453
+ click.echo(f" ... and {len(matches) - 5} more", err=True)
454
+ return None
455
+
456
+
457
+ def _is_path_format(ref: str) -> bool:
458
+ """Check if a reference is in path format (e.g., 1.2.3)."""
459
+ if "." not in ref:
460
+ return False
461
+ parts = ref.split(".")
462
+ return all(part.isdigit() for part in parts)
463
+
464
+
465
+ class _CascadeIterator:
466
+ """Iterator wrapper that handles errors via callback."""
467
+
468
+ def __init__(
469
+ self,
470
+ tasks: list[Task],
471
+ label: str,
472
+ on_error: Callable[[Task, Exception], bool] | None,
473
+ ):
474
+ self._tasks = tasks
475
+ self._label = label
476
+ self._on_error = on_error
477
+ self._index = 0
478
+ self._total = len(tasks)
479
+ self._stop = False
480
+ self._current_task: Task | None = None
481
+ self._pending_error: Exception | None = None
482
+ self._completed_count = 0
483
+
484
+ def __iter__(self) -> "_CascadeIterator":
485
+ return self
486
+
487
+ def __next__(self) -> tuple[Task, Callable[[], None]]:
488
+ # Handle any pending error from previous iteration
489
+ if self._pending_error is not None:
490
+ error = self._pending_error
491
+ self._pending_error = None
492
+ task = self._current_task
493
+
494
+ if self._on_error is not None and task is not None:
495
+ should_continue = self._on_error(task, error)
496
+ if not should_continue:
497
+ self._stop = True
498
+ raise StopIteration
499
+ else:
500
+ raise error
501
+
502
+ if self._stop or self._index >= self._total:
503
+ raise StopIteration
504
+
505
+ task = self._tasks[self._index]
506
+ self._current_task = task
507
+ self._index += 1
508
+
509
+ task_ref = f"#{task.seq_num}" if task.seq_num else task.id[:8]
510
+
511
+ # Truncate long titles
512
+ max_title_len = 40
513
+ title = task.title
514
+ if len(title) > max_title_len:
515
+ title = title[: max_title_len - 3] + "..."
516
+
517
+ # Print progress line with label
518
+ progress_str = f"{self._label} [{self._index}/{self._total}] {task_ref}: {title}"
519
+ click.echo(progress_str)
520
+
521
+ def update() -> None:
522
+ """Mark the current task as completed."""
523
+ self._completed_count += 1
524
+
525
+ return task, update
526
+
527
+ def report_error(self, error: Exception) -> None:
528
+ """Report an error for the current task."""
529
+ self._pending_error = error
530
+
531
+
532
+ @contextmanager
533
+ def cascade_progress(
534
+ tasks: list[Task],
535
+ label: str = "Processing",
536
+ on_error: Callable[[Task, Exception], bool] | None = None,
537
+ ) -> Generator[Iterator[tuple[Task, Callable[[], None]]]]:
538
+ """Context manager for cascade operations with progress display.
539
+
540
+ Yields (task, update) pairs for each task. Call update() after
541
+ processing each task to advance the progress bar.
542
+
543
+ Args:
544
+ tasks: List of tasks to process
545
+ label: Label to show before progress bar (e.g., "Expanding")
546
+ on_error: Optional callback for errors. Receives (task, error).
547
+ Return True to continue, False to stop processing.
548
+
549
+ Yields:
550
+ Iterator of (task, update_fn) tuples
551
+
552
+ Example:
553
+ with cascade_progress(tasks, label="Expanding") as progress:
554
+ for task, update in progress:
555
+ await expand_task(task)
556
+ update() # Mark complete
557
+ """
558
+ if not tasks:
559
+ yield iter([])
560
+ return
561
+
562
+ iterator = _CascadeIterator(tasks, label, on_error)
563
+ try:
564
+ yield iterator
565
+ except KeyboardInterrupt:
566
+ click.echo("\nOperation interrupted by user.")
567
+ raise
568
+ except Exception as e:
569
+ # Handle error on current task
570
+ if on_error is not None and iterator._current_task is not None:
571
+ # Call on_error callback for logging, but always re-raise
572
+ # The on_error return value is only used in next-iteration logic
573
+ on_error(iterator._current_task, e)
574
+ raise
575
+ finally:
576
+ # Handle any pending error from final iteration (report_error called on last task)
577
+ if iterator._pending_error is not None:
578
+ error = iterator._pending_error
579
+ iterator._pending_error = None
580
+ task = iterator._current_task
581
+ # Capture any exception from the iterator body to preserve as __cause__
582
+ body_exception = sys.exc_info()[1]
583
+ if on_error is not None and task is not None:
584
+ # Call on_error callback for pending error, preserving both exceptions if callback fails
585
+ try:
586
+ on_error(task, error)
587
+ # Error handled via callback, don't re-raise
588
+ except Exception as callback_exc:
589
+ # Chain exceptions: pending error from body exception (if any) or callback failure
590
+ if body_exception is not None:
591
+ raise error from body_exception
592
+ raise error from callback_exc
593
+ else:
594
+ # No on_error callback - re-raise the pending error chained to body exception if present
595
+ if body_exception is not None:
596
+ raise error from body_exception
597
+ raise error from None
598
+
599
+
600
+ def get_all_descendants(manager: LocalTaskManager, task_id: str) -> list[Task]:
601
+ """Recursively get all descendants of a task (children, grandchildren, etc.).
602
+
603
+ Returns tasks in depth-first order (parent before children).
604
+
605
+ Args:
606
+ manager: The task manager
607
+ task_id: UUID of the parent task
608
+
609
+ Returns:
610
+ List of all descendant tasks
611
+ """
612
+ descendants: list[Task] = []
613
+
614
+ def collect_children(parent_id: str) -> None:
615
+ children = manager.list_tasks(parent_task_id=parent_id)
616
+ for child in children:
617
+ descendants.append(child)
618
+ collect_children(child.id) # Recurse into grandchildren
619
+
620
+ collect_children(task_id)
621
+ return descendants
622
+
623
+
624
+ def parse_task_refs(refs: tuple[str, ...]) -> list[str]:
625
+ """Parse task references from various CLI input formats.
626
+
627
+ Handles multiple input formats commonly used in CLI:
628
+ - Single reference: "42", "#42", "abc123-def"
629
+ - Comma-separated: "#42,#43,#44" or "42,43,44"
630
+ - Space-separated: passed as tuple from Click variadic args
631
+ - Mixed: "#42,#43 #44" with both separators
632
+
633
+ Numeric references are normalized to #N format.
634
+ UUID-like references are passed through unchanged.
635
+
636
+ Args:
637
+ refs: Tuple of reference strings from Click variadic argument
638
+
639
+ Returns:
640
+ List of normalized task references
641
+ """
642
+ result: list[str] = []
643
+
644
+ for arg in refs:
645
+ # Split on commas first
646
+ parts = arg.split(",")
647
+ for part in parts:
648
+ ref = part.strip()
649
+ if not ref:
650
+ continue
651
+
652
+ # Normalize pure numeric to #N format
653
+ if ref.isdigit():
654
+ ref = f"#{ref}"
655
+
656
+ result.append(ref)
657
+
658
+ return result