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
gobby/cli/tasks/ai.py ADDED
@@ -0,0 +1,1025 @@
1
+ """
2
+ AI-powered task commands (expand, validate, suggest, etc.)
3
+ """
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+ from gobby.cli.tasks._utils import get_task_manager, resolve_task_id
11
+ from gobby.storage.tasks import LocalTaskManager, Task
12
+ from gobby.utils.project_context import get_project_context
13
+
14
+
15
+ @click.command("validate")
16
+ @click.argument("task_id", metavar="TASK")
17
+ @click.option(
18
+ "--summary", "-s", default=None, help="Changes summary text (required for leaf tasks)"
19
+ )
20
+ @click.option(
21
+ "--file",
22
+ "-f",
23
+ "summary_file",
24
+ type=click.Path(exists=True),
25
+ help="File containing changes summary",
26
+ )
27
+ @click.option("--max-iterations", "-i", type=int, default=1, help="Max validation retry attempts")
28
+ @click.option("--external", is_flag=True, help="Use external validator agent")
29
+ @click.option("--skip-build", is_flag=True, help="Skip build verification before validation")
30
+ @click.option("--history", is_flag=True, help="Show validation history instead of validating")
31
+ @click.option("--recurring", is_flag=True, help="Show recurring issues instead of validating")
32
+ def validate_task_cmd(
33
+ task_id: str,
34
+ summary: str | None,
35
+ summary_file: str | None,
36
+ max_iterations: int,
37
+ external: bool,
38
+ skip_build: bool,
39
+ history: bool,
40
+ recurring: bool,
41
+ ) -> None:
42
+ """Validate a task.
43
+
44
+ TASK can be: #N (e.g., #1, #47), path (e.g., 1.2.3), or UUID.
45
+
46
+ For parent tasks (with children), validates that all children are closed.
47
+ For leaf tasks, uses LLM-based validation against criteria.
48
+
49
+ Use --history to view past validation iterations.
50
+ Use --recurring to see issues that keep appearing.
51
+ """
52
+ import asyncio
53
+
54
+ from gobby.config.app import load_config
55
+ from gobby.llm import LLMService
56
+ from gobby.tasks.validation import TaskValidator, ValidationResult
57
+ from gobby.tasks.validation_history import ValidationHistoryManager
58
+
59
+ manager = get_task_manager()
60
+ resolved = resolve_task_id(manager, task_id)
61
+ if not resolved:
62
+ return
63
+
64
+ # Handle --history flag: show validation history
65
+ if history:
66
+ history_manager = ValidationHistoryManager(manager.db)
67
+ iterations = history_manager.get_iteration_history(resolved.id)
68
+ if not iterations:
69
+ click.echo(f"No validation history for task {resolved.id}")
70
+ return
71
+ click.echo(f"Validation history for {resolved.id}:")
72
+ for it in iterations:
73
+ click.echo(f"\n Iteration {it.iteration}: {it.status}")
74
+ if it.feedback:
75
+ click.echo(f" Feedback: {it.feedback[:100]}...")
76
+ if it.issues:
77
+ click.echo(f" Issues: {len(it.issues)}")
78
+ return
79
+
80
+ # Handle --recurring flag: show recurring issues
81
+ if recurring:
82
+ history_manager = ValidationHistoryManager(manager.db)
83
+ summary_data = history_manager.get_recurring_issue_summary(resolved.id)
84
+ has_recurring = history_manager.has_recurring_issues(resolved.id)
85
+ click.echo(f"Recurring issues for {resolved.id}:")
86
+ click.echo(f" Has recurring issues: {has_recurring}")
87
+ click.echo(f" Total iterations: {summary_data['total_iterations']}")
88
+ if summary_data["recurring_issues"]:
89
+ for issue in summary_data["recurring_issues"]:
90
+ click.echo(f" - {issue['title']} (count: {issue['count']})")
91
+ else:
92
+ click.echo(" No recurring issues found.")
93
+ return
94
+
95
+ # Check if task has children (is a parent task)
96
+ children = manager.list_tasks(parent_task_id=resolved.id, limit=1000)
97
+
98
+ if children:
99
+ # Parent task: validate based on child completion
100
+ open_children = [c for c in children if c.status != "closed"]
101
+ all_closed = len(open_children) == 0
102
+
103
+ if all_closed:
104
+ result = ValidationResult(
105
+ status="valid",
106
+ feedback=f"All {len(children)} child tasks are completed.",
107
+ )
108
+ else:
109
+ open_titles = [f"- {c.id}: {c.title}" for c in open_children[:5]]
110
+ remaining = len(open_children) - 5 if len(open_children) > 5 else 0
111
+ feedback = f"{len(open_children)} of {len(children)} child tasks still open:\n"
112
+ feedback += "\n".join(open_titles)
113
+ if remaining > 0:
114
+ feedback += f"\n... and {remaining} more"
115
+ result = ValidationResult(status="invalid", feedback=feedback)
116
+
117
+ click.echo(f"Validation Status: {result.status.upper()}")
118
+ if result.feedback:
119
+ click.echo(f"Feedback:\n{result.feedback}")
120
+
121
+ # Update validation status
122
+ updates: dict[str, Any] = {
123
+ "validation_status": result.status,
124
+ "validation_feedback": result.feedback,
125
+ }
126
+ if result.status == "valid":
127
+ manager.close_task(resolved.id, reason="All child tasks completed")
128
+ click.echo("Task closed.")
129
+ manager.update_task(resolved.id, **updates)
130
+ return
131
+
132
+ # Leaf task: need changes summary
133
+ changes_summary = ""
134
+ if summary_file:
135
+ try:
136
+ with open(summary_file, encoding="utf-8") as f:
137
+ changes_summary = f.read()
138
+ except Exception as e:
139
+ click.echo(f"Error reading summary file: {e}", err=True)
140
+ return
141
+ elif summary:
142
+ changes_summary = summary
143
+ else:
144
+ # Prompt from stdin
145
+ click.echo("Enter changes summary (Ctrl+D to finish):")
146
+ changes_summary = sys.stdin.read()
147
+
148
+ if not changes_summary.strip():
149
+ click.echo("Error: Changes summary is required for leaf tasks.", err=True)
150
+ return
151
+
152
+ click.echo(f"Validating task {resolved.id}...")
153
+
154
+ # Initialize validator
155
+ try:
156
+ config = load_config()
157
+ llm_service = LLMService(config)
158
+ validator = TaskValidator(config.gobby_tasks.validation, llm_service)
159
+ except Exception as e:
160
+ click.echo(f"Error initializing validator: {e}", err=True)
161
+ return
162
+
163
+ # Run validation
164
+ try:
165
+ result = asyncio.run(
166
+ validator.validate_task(
167
+ task_id=resolved.id,
168
+ title=resolved.title,
169
+ description=resolved.description,
170
+ changes_summary=changes_summary,
171
+ validation_criteria=resolved.validation_criteria,
172
+ )
173
+ )
174
+
175
+ click.echo(f"Validation Status: {result.status.upper()}")
176
+ if result.feedback:
177
+ click.echo(f"Feedback:\n{result.feedback}")
178
+
179
+ # Apply validation updates
180
+ validation_updates: dict[str, Any] = {
181
+ "validation_status": result.status,
182
+ "validation_feedback": result.feedback,
183
+ }
184
+ MAX_RETRIES = 3
185
+
186
+ if result.status == "valid":
187
+ manager.close_task(resolved.id, reason="Completed via validation")
188
+ click.echo("Task closed.")
189
+ elif result.status == "invalid":
190
+ current_fail_count = resolved.validation_fail_count or 0
191
+ new_fail_count = current_fail_count + 1
192
+ validation_updates["validation_fail_count"] = new_fail_count
193
+
194
+ if new_fail_count < MAX_RETRIES:
195
+ fix_task = manager.create_task(
196
+ project_id=resolved.project_id,
197
+ title=f"Fix validation failures for {resolved.title}",
198
+ description=f"Validation failed with feedback:\n{result.feedback}\n\nPlease fix the issues and re-validate.",
199
+ parent_task_id=resolved.id,
200
+ priority=1,
201
+ task_type="bug",
202
+ )
203
+ validation_updates["validation_feedback"] = (
204
+ result.feedback or ""
205
+ ) + f"\n\nCreated fix task: {fix_task.id}"
206
+ click.echo(f"Created fix task: {fix_task.id}")
207
+ else:
208
+ validation_updates["status"] = "failed"
209
+ validation_updates["validation_feedback"] = (
210
+ result.feedback or ""
211
+ ) + f"\n\nExceeded max retries ({MAX_RETRIES}). Marked as failed."
212
+ click.echo("Exceeded max retries. Task marked as FAILED.")
213
+
214
+ manager.update_task(resolved.id, **validation_updates)
215
+
216
+ except Exception as e:
217
+ click.echo(f"Validation error: {e}", err=True)
218
+
219
+
220
+ @click.command("generate-criteria")
221
+ @click.argument("task_id", required=False)
222
+ @click.option(
223
+ "--all", "generate_all", is_flag=True, help="Generate criteria for all tasks missing it"
224
+ )
225
+ def generate_criteria_cmd(task_id: str | None, generate_all: bool) -> None:
226
+ """Generate validation criteria for a task.
227
+
228
+ For parent tasks (with children), sets criteria to 'All child tasks completed'.
229
+ For leaf tasks, uses AI to generate criteria from title/description.
230
+
231
+ Use --all to generate criteria for all tasks that don't have it set.
232
+ """
233
+ import asyncio
234
+
235
+ from gobby.config.app import load_config
236
+ from gobby.llm import LLMService
237
+ from gobby.tasks.validation import TaskValidator
238
+
239
+ manager = get_task_manager()
240
+
241
+ if generate_all:
242
+ _generate_criteria_for_all(manager)
243
+ return
244
+
245
+ if not task_id:
246
+ click.echo("Error: TASK_ID is required (or use --all)", err=True)
247
+ return
248
+
249
+ resolved = resolve_task_id(manager, task_id)
250
+ if not resolved:
251
+ return
252
+
253
+ if resolved.validation_criteria:
254
+ click.echo("Task already has validation criteria:")
255
+ click.echo(resolved.validation_criteria)
256
+ return
257
+
258
+ # Check if task has children (is a parent task)
259
+ children = manager.list_tasks(parent_task_id=resolved.id, limit=1)
260
+
261
+ if children:
262
+ # Parent task: criteria is child completion
263
+ criteria = "All child tasks must be completed (status: closed)."
264
+ manager.update_task(resolved.id, validation_criteria=criteria)
265
+ click.echo(f"Parent task detected. Set validation criteria:\n{criteria}")
266
+ return
267
+
268
+ # Leaf task: use LLM to generate criteria
269
+ click.echo(f"Generating validation criteria for task {resolved.id}...")
270
+
271
+ try:
272
+ config = load_config()
273
+ llm_service = LLMService(config)
274
+ validator = TaskValidator(config.gobby_tasks.validation, llm_service)
275
+ except Exception as e:
276
+ click.echo(f"Error initializing validator: {e}", err=True)
277
+ return
278
+
279
+ try:
280
+ generated_criteria: str | None = asyncio.run(
281
+ validator.generate_criteria(
282
+ title=resolved.title,
283
+ description=resolved.description,
284
+ )
285
+ )
286
+
287
+ if not generated_criteria:
288
+ click.echo("Failed to generate criteria.", err=True)
289
+ return
290
+
291
+ # Update task with generated criteria
292
+ manager.update_task(resolved.id, validation_criteria=generated_criteria)
293
+ click.echo(f"Generated and saved validation criteria:\n{generated_criteria}")
294
+
295
+ except Exception as e:
296
+ click.echo(f"Error generating criteria: {e}", err=True)
297
+
298
+
299
+ def _generate_criteria_for_all(manager: LocalTaskManager) -> None:
300
+ """Generate validation criteria for all tasks missing it."""
301
+ import asyncio
302
+
303
+ from gobby.config.app import load_config
304
+ from gobby.llm import LLMService
305
+ from gobby.tasks.validation import TaskValidator
306
+
307
+ # Get all open tasks without validation criteria
308
+ all_tasks = manager.list_tasks(status="open", limit=1000)
309
+ tasks_needing_criteria = [t for t in all_tasks if not t.validation_criteria]
310
+
311
+ if not tasks_needing_criteria:
312
+ click.echo("All tasks already have validation criteria.")
313
+ return
314
+
315
+ click.echo(f"Found {len(tasks_needing_criteria)} tasks without validation criteria.")
316
+
317
+ # Initialize validator for leaf tasks
318
+ try:
319
+ config = load_config()
320
+ llm_service = LLMService(config)
321
+ validator = TaskValidator(config.gobby_tasks.validation, llm_service)
322
+ except Exception as e:
323
+ click.echo(f"Error initializing validator: {e}", err=True)
324
+ return
325
+
326
+ parent_count = 0
327
+ leaf_count = 0
328
+ error_count = 0
329
+
330
+ # Get project context for display
331
+ project_ctx = get_project_context()
332
+ project_name = project_ctx.get("name") if project_ctx else None
333
+
334
+ for task in tasks_needing_criteria:
335
+ # Format task ref
336
+ task_ref = task.id
337
+ if task.seq_num:
338
+ if project_name:
339
+ task_ref = f"{project_name}-#{task.seq_num}"
340
+ else:
341
+ task_ref = f"#{task.seq_num}"
342
+
343
+ # Check if task has children (is a parent task)
344
+ children = manager.list_tasks(parent_task_id=task.id, limit=1)
345
+
346
+ if children:
347
+ # Parent task: criteria is child completion
348
+ parent_criteria = "All child tasks must be completed (status: closed)."
349
+ manager.update_task(task.id, validation_criteria=parent_criteria)
350
+ click.echo(f"\n[parent] {task_ref}: {task.title}")
351
+ click.echo(f" → {parent_criteria}")
352
+ parent_count += 1
353
+ else:
354
+ # Leaf task: use LLM to generate criteria
355
+ try:
356
+ leaf_criteria: str | None = asyncio.run(
357
+ validator.generate_criteria(
358
+ title=task.title,
359
+ description=task.description,
360
+ )
361
+ )
362
+ if leaf_criteria:
363
+ manager.update_task(task.id, validation_criteria=leaf_criteria)
364
+ click.echo(f"\n[leaf] {task_ref}: {task.title}")
365
+ # Indent each line of criteria
366
+ for line in leaf_criteria.strip().split("\n"):
367
+ click.echo(f" {line}")
368
+ leaf_count += 1
369
+ else:
370
+ click.echo(f"\n[error] {task_ref}: {task.title}")
371
+ click.echo(" Failed to generate criteria", err=True)
372
+ error_count += 1
373
+ except Exception as e:
374
+ click.echo(f"\n[error] {task_ref}: {task.title}")
375
+ click.echo(f" {e}", err=True)
376
+ error_count += 1
377
+
378
+ click.echo(
379
+ f"\nDone: {parent_count} parent tasks, {leaf_count} leaf tasks, {error_count} errors"
380
+ )
381
+
382
+
383
+ def _find_unexpanded_epic(manager: LocalTaskManager, root_task_id: str) -> Task | None:
384
+ """Depth-first search for first unexpanded epic in the task tree."""
385
+ task = manager.get_task(root_task_id)
386
+ if not task:
387
+ return None
388
+
389
+ # Check if this task itself is an unexpanded epic
390
+ if task.task_type == "epic" and not task.is_expanded:
391
+ return task
392
+
393
+ # Search children depth-first
394
+ children = manager.list_tasks(parent_task_id=root_task_id, limit=1000)
395
+ for child in children:
396
+ if child.task_type == "epic":
397
+ result = _find_unexpanded_epic(manager, child.id)
398
+ if result:
399
+ return result
400
+
401
+ return None
402
+
403
+
404
+ def _count_unexpanded_epics(manager: LocalTaskManager, root_task_id: str) -> int:
405
+ """Count unexpanded epics in the task tree."""
406
+ count = 0
407
+ task = manager.get_task(root_task_id)
408
+ if not task:
409
+ return 0
410
+
411
+ # Count this task if it's an unexpanded epic
412
+ if task.task_type == "epic" and not task.is_expanded:
413
+ count += 1
414
+
415
+ # Count children recursively
416
+ children = manager.list_tasks(parent_task_id=root_task_id, limit=1000)
417
+ for child in children:
418
+ count += _count_unexpanded_epics(manager, child.id)
419
+
420
+ return count
421
+
422
+
423
+ @click.command("expand")
424
+ @click.argument("task_refs", nargs=-1, required=True, metavar="TASKS...")
425
+ @click.option("--context", "-c", help="Additional context for expansion")
426
+ @click.option(
427
+ "--web-research/--no-web-research",
428
+ default=False,
429
+ help="Enable/disable agentic web research",
430
+ )
431
+ @click.option(
432
+ "--code-context/--no-code-context",
433
+ default=True,
434
+ help="Enable/disable codebase context gathering",
435
+ )
436
+ @click.option(
437
+ "--cascade", is_flag=True, help="Iteratively expand all child epics (default for epics)"
438
+ )
439
+ @click.option("--force", "-f", is_flag=True, help="Re-expand already expanded tasks")
440
+ @click.option("--project", "-p", "project_name", help="Project name or ID")
441
+ def expand_task_cmd(
442
+ task_refs: tuple[str, ...],
443
+ context: str | None,
444
+ web_research: bool,
445
+ code_context: bool,
446
+ cascade: bool,
447
+ force: bool,
448
+ project_name: str | None,
449
+ ) -> None:
450
+ """Expand a task tree. Runs iteratively until complete.
451
+
452
+ TASKS can be: #N (e.g., #1, #47), comma-separated (#1,#2,#3), or UUIDs.
453
+
454
+ For epics, automatically expands all child epics iteratively (--cascade).
455
+ Use --no-cascade to expand only the root task.
456
+
457
+ Examples:
458
+ gobby tasks expand #42 # Expands #42 and all child epics
459
+ gobby tasks expand #42 --force # Re-expand even if already expanded
460
+ """
461
+ import asyncio
462
+
463
+ from gobby.cli.tasks._utils import parse_task_refs
464
+ from gobby.cli.utils import get_active_session_id
465
+ from gobby.config.app import load_config
466
+ from gobby.llm import LLMService
467
+ from gobby.storage.task_dependencies import TaskDependencyManager
468
+ from gobby.tasks.expansion import TaskExpander
469
+ from gobby.tasks.tdd import (
470
+ TDD_CATEGORIES,
471
+ apply_tdd_sandwich,
472
+ build_expansion_context,
473
+ should_skip_expansion,
474
+ should_skip_tdd,
475
+ )
476
+ from gobby.tasks.validation import TaskValidator
477
+
478
+ # Parse task references
479
+ refs = parse_task_refs(task_refs)
480
+ if not refs:
481
+ click.echo("Error: No task references provided", err=True)
482
+ return
483
+
484
+ manager = get_task_manager()
485
+
486
+ # Resolve all tasks
487
+ root_tasks: list[Task] = []
488
+ for ref in refs:
489
+ task = resolve_task_id(manager, ref)
490
+ if not task:
491
+ continue
492
+ root_tasks.append(task)
493
+
494
+ if not root_tasks:
495
+ click.echo("No valid tasks to expand.", err=True)
496
+ return
497
+
498
+ # Initialize services
499
+ try:
500
+ config = load_config()
501
+ if not config.gobby_tasks.expansion.enabled:
502
+ click.echo("Error: Task expansion is disabled in config.", err=True)
503
+ return
504
+
505
+ llm_service = LLMService(config)
506
+ expander = TaskExpander(
507
+ config.gobby_tasks.expansion, llm_service, manager, mcp_manager=None
508
+ )
509
+ dep_manager = TaskDependencyManager(manager.db)
510
+ validator = TaskValidator(config.gobby_tasks.validation, llm_service)
511
+ auto_generate_validation = config.gobby_tasks.validation.auto_generate_on_expand
512
+
513
+ except Exception as e:
514
+ click.echo(f"Error initializing services: {e}", err=True)
515
+ return
516
+
517
+ async def _post_expansion_processing(task: Task, subtask_ids: list[str]) -> dict[str, Any]:
518
+ """Apply MCP-parity post-expansion processing.
519
+
520
+ - Wire parent → subtask blocking dependencies
521
+ - Apply TDD sandwich for code/config subtasks (non-epic only)
522
+ - Generate validation criteria for subtasks
523
+ """
524
+ result: dict[str, Any] = {"tdd_applied": False, "validation_generated": 0}
525
+
526
+ if not subtask_ids:
527
+ return result
528
+
529
+ # 1. Wire parent → subtask blocking dependencies
530
+ for subtask_id in subtask_ids:
531
+ try:
532
+ dep_manager.add_dependency(
533
+ task_id=task.id, depends_on=subtask_id, dep_type="blocks"
534
+ )
535
+ except ValueError:
536
+ pass # Dependency already exists
537
+
538
+ # 2. Apply TDD sandwich (non-epic tasks with code/config subtasks)
539
+ if task.task_type != "epic":
540
+ impl_task_ids = []
541
+ for sid in subtask_ids:
542
+ subtask = manager.get_task(sid)
543
+ if subtask and subtask.category in TDD_CATEGORIES:
544
+ if not should_skip_tdd(subtask.title):
545
+ impl_task_ids.append(sid)
546
+
547
+ if impl_task_ids:
548
+ tdd_result = await apply_tdd_sandwich(manager, dep_manager, task.id, impl_task_ids)
549
+ if tdd_result.get("success"):
550
+ result["tdd_applied"] = True
551
+
552
+ # 3. Generate validation criteria for subtasks
553
+ if auto_generate_validation:
554
+ for sid in subtask_ids:
555
+ subtask = manager.get_task(sid)
556
+ if subtask and not subtask.validation_criteria and subtask.task_type != "epic":
557
+ try:
558
+ criteria = await validator.generate_criteria(
559
+ title=subtask.title,
560
+ description=subtask.description,
561
+ )
562
+ if criteria:
563
+ manager.update_task(sid, validation_criteria=criteria)
564
+ result["validation_generated"] += 1
565
+ except Exception:
566
+ pass # nosec B110 - best effort validation generation
567
+
568
+ # 4. Update parent task: set is_expanded and validation criteria
569
+ manager.update_task(
570
+ task.id,
571
+ is_expanded=True,
572
+ validation_criteria="All child tasks must be completed (status: closed).",
573
+ )
574
+
575
+ return result
576
+
577
+ # Get current session ID for expansion context
578
+ session_id = get_active_session_id()
579
+
580
+ # Process each root task
581
+ total_iterations = 0
582
+ total_subtasks = 0
583
+
584
+ for root_task in root_tasks:
585
+ root_ref = f"#{root_task.seq_num}" if root_task.seq_num else root_task.id[:8]
586
+
587
+ # For epics, default to cascade mode
588
+ should_cascade = cascade or root_task.task_type == "epic"
589
+
590
+ if should_cascade:
591
+ # Iterative expansion mode
592
+ click.echo(f"Expanding {root_ref}: {root_task.title[:50]}...")
593
+ if web_research:
594
+ click.echo(" • Web research enabled")
595
+ if code_context:
596
+ click.echo(" • Code context enabled")
597
+
598
+ iteration = 0
599
+ while True:
600
+ iteration += 1
601
+
602
+ # Find next unexpanded epic
603
+ target = _find_unexpanded_epic(manager, root_task.id)
604
+
605
+ if target is None:
606
+ click.echo(f"✓ Expansion complete after {iteration - 1} iterations")
607
+ break
608
+
609
+ # Re-fetch to get latest state
610
+ target = manager.get_task(target.id)
611
+ if target is None:
612
+ click.echo(" Task deleted during expansion", err=True)
613
+ break
614
+
615
+ # Check if task should be skipped (TDD prefixes or already expanded)
616
+ skip, reason = should_skip_expansion(target.title, target.is_expanded, force)
617
+ if skip:
618
+ target_ref = f"#{target.seq_num}" if target.seq_num else target.id[:8]
619
+ click.echo(f" Skipping {target_ref}: {reason}")
620
+ continue
621
+
622
+ target_ref = f"#{target.seq_num}" if target.seq_num else target.id[:8]
623
+ click.echo(f"[{iteration}] Expanding {target_ref}: {target.title[:40]}...")
624
+
625
+ # Build merged context from stored expansion_context + user context
626
+ merged_context = build_expansion_context(target.expansion_context, context)
627
+
628
+ try:
629
+ result = asyncio.run(
630
+ expander.expand_task(
631
+ task_id=target.id,
632
+ title=target.title,
633
+ description=target.description,
634
+ context=merged_context,
635
+ enable_web_research=web_research,
636
+ enable_code_context=code_context,
637
+ session_id=session_id,
638
+ )
639
+ )
640
+ except Exception as e:
641
+ click.echo(f" Error: {e}", err=True)
642
+ break
643
+
644
+ if "error" in result:
645
+ click.echo(f" Error: {result['error']}", err=True)
646
+ break
647
+
648
+ subtasks = result.get("subtask_ids", [])
649
+ click.echo(f" → Created {len(subtasks)} subtasks")
650
+ total_subtasks += len(subtasks)
651
+
652
+ # Apply post-expansion processing (deps, TDD sandwich, validation)
653
+ post_result = asyncio.run(_post_expansion_processing(target, subtasks))
654
+ if post_result.get("tdd_applied"):
655
+ click.echo(" → Applied TDD sandwich")
656
+ if post_result.get("validation_generated", 0) > 0:
657
+ click.echo(
658
+ f" → Generated {post_result['validation_generated']} validation criteria"
659
+ )
660
+
661
+ remaining = _count_unexpanded_epics(manager, root_task.id)
662
+ if remaining > 0:
663
+ click.echo(f" → {remaining} epic(s) remaining")
664
+
665
+ total_iterations += iteration - 1
666
+
667
+ else:
668
+ # Single task expansion (non-cascade)
669
+ skip, reason = should_skip_expansion(root_task.title, root_task.is_expanded, force)
670
+ if skip:
671
+ click.echo(f"Skipping {root_ref}: {reason}")
672
+ continue
673
+
674
+ click.echo(f"Expanding {root_ref}: {root_task.title[:50]}...")
675
+
676
+ # Build merged context from stored expansion_context + user context
677
+ merged_context = build_expansion_context(root_task.expansion_context, context)
678
+
679
+ try:
680
+ result = asyncio.run(
681
+ expander.expand_task(
682
+ task_id=root_task.id,
683
+ title=root_task.title,
684
+ description=root_task.description,
685
+ context=merged_context,
686
+ enable_web_research=web_research,
687
+ enable_code_context=code_context,
688
+ session_id=session_id,
689
+ )
690
+ )
691
+ except Exception as e:
692
+ click.echo(f" Error: {e}", err=True)
693
+ continue
694
+
695
+ if "error" in result:
696
+ click.echo(f" Error: {result['error']}", err=True)
697
+ continue
698
+
699
+ subtasks = result.get("subtask_ids", [])
700
+ click.echo(f" Created {len(subtasks)} subtasks")
701
+
702
+ # Apply post-expansion processing (deps, TDD sandwich, validation)
703
+ post_result = asyncio.run(_post_expansion_processing(root_task, subtasks))
704
+ if post_result.get("tdd_applied"):
705
+ click.echo(" → Applied TDD sandwich")
706
+ if post_result.get("validation_generated", 0) > 0:
707
+ click.echo(
708
+ f" → Generated {post_result['validation_generated']} validation criteria"
709
+ )
710
+ total_subtasks += len(subtasks)
711
+ total_iterations += 1
712
+
713
+ if len(root_tasks) > 1:
714
+ click.echo(f"\nTotal: {total_subtasks} subtasks across {total_iterations} expansions")
715
+
716
+
717
+ @click.command("complexity")
718
+ @click.argument("task_id", required=False)
719
+ @click.option("--all", "analyze_all", is_flag=True, help="Analyze all pending tasks")
720
+ @click.option("--pending", is_flag=True, help="Only analyze pending (open) tasks (use with --all)")
721
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
722
+ def complexity_cmd(
723
+ task_id: str | None,
724
+ analyze_all: bool,
725
+ pending: bool,
726
+ json_format: bool,
727
+ ) -> None:
728
+ """Analyze task complexity based on subtasks or description."""
729
+ import json as json_mod
730
+
731
+ manager = get_task_manager()
732
+
733
+ if analyze_all:
734
+ # Batch analysis
735
+ project_ctx = get_project_context()
736
+ project_id = project_ctx.get("id") if project_ctx else None
737
+
738
+ status_filter = "open" if pending else None
739
+ tasks_list = manager.list_tasks(
740
+ project_id=project_id,
741
+ status=status_filter,
742
+ limit=100,
743
+ )
744
+
745
+ if not tasks_list:
746
+ click.echo("No tasks found to analyze.")
747
+ return
748
+
749
+ results = []
750
+ for task in tasks_list:
751
+ result = _analyze_task_complexity(manager, task)
752
+ results.append(result)
753
+
754
+ if json_format:
755
+ click.echo(json_mod.dumps(results, indent=2))
756
+ return
757
+
758
+ click.echo(f"Analyzed {len(results)} tasks:\n")
759
+ for r in results:
760
+ click.echo(
761
+ f" {r['task_id'][:12]} | Score: {r['complexity_score']:2}/10 | {r['title'][:50]}"
762
+ )
763
+
764
+ else:
765
+ # Single task analysis
766
+ if not task_id:
767
+ click.echo("Error: TASK_ID required (or use --all)", err=True)
768
+ return
769
+
770
+ resolved = resolve_task_id(manager, task_id)
771
+ if not resolved:
772
+ return
773
+
774
+ result = _analyze_task_complexity(manager, resolved)
775
+
776
+ if json_format:
777
+ click.echo(json_mod.dumps(result, indent=2))
778
+ return
779
+
780
+ click.echo(f"Task: {result['title']}")
781
+ click.echo(f"ID: {result['task_id']}")
782
+ click.echo(f"Complexity Score: {result['complexity_score']}/10")
783
+ click.echo(f"Reasoning: {result['reasoning']}")
784
+ click.echo(f"Recommended Subtasks: {result['recommended_subtasks']}")
785
+ if result["existing_subtasks"] > 0:
786
+ click.echo(f"Existing Subtasks: {result['existing_subtasks']}")
787
+
788
+
789
+ def _analyze_task_complexity(manager: LocalTaskManager, task: Task) -> dict[str, Any]:
790
+ """Analyze complexity for a single task. Returns dict with results."""
791
+ # Check for existing subtasks
792
+ subtasks = manager.list_tasks(parent_task_id=task.id, limit=100)
793
+ subtask_count = len(subtasks)
794
+
795
+ # Simple heuristic-based complexity
796
+ if subtask_count > 0:
797
+ score = min(10, 1 + subtask_count // 2)
798
+ reasoning = f"Task has {subtask_count} subtasks"
799
+ recommended = subtask_count
800
+ else:
801
+ desc_len = len(task.description or "")
802
+ if desc_len < 100:
803
+ score = 2
804
+ reasoning = "Short description, likely simple task"
805
+ recommended = 2
806
+ elif desc_len < 500:
807
+ score = 5
808
+ reasoning = "Medium description, moderate complexity"
809
+ recommended = 5
810
+ else:
811
+ score = 8
812
+ reasoning = "Long description, likely complex task"
813
+ recommended = 10
814
+
815
+ # Update task with complexity score
816
+ manager.update_task(
817
+ task.id,
818
+ complexity_score=score,
819
+ estimated_subtasks=recommended,
820
+ )
821
+
822
+ return {
823
+ "task_id": task.id,
824
+ "title": task.title,
825
+ "complexity_score": score,
826
+ "reasoning": reasoning,
827
+ "recommended_subtasks": recommended,
828
+ "existing_subtasks": subtask_count,
829
+ }
830
+
831
+
832
+ @click.command("expand-all")
833
+ @click.option("--max", "-m", "max_tasks", default=5, help="Maximum tasks to expand")
834
+ @click.option("--min-complexity", default=1, help="Only expand tasks with complexity >= this")
835
+ @click.option("--type", "task_type", help="Filter by task type")
836
+ @click.option("--web-research/--no-web-research", default=False, help="Enable web research")
837
+ @click.option("--dry-run", "-d", is_flag=True, help="Show what would be expanded without doing it")
838
+ def expand_all_cmd(
839
+ max_tasks: int,
840
+ min_complexity: int,
841
+ task_type: str | None,
842
+ web_research: bool,
843
+ dry_run: bool,
844
+ ) -> None:
845
+ """Expand all unexpanded tasks (tasks without subtasks)."""
846
+ import asyncio
847
+
848
+ from gobby.config.app import load_config
849
+ from gobby.llm import LLMService
850
+ from gobby.tasks.expansion import TaskExpander
851
+
852
+ manager = get_task_manager()
853
+
854
+ # Find tasks without children
855
+ all_tasks = manager.list_tasks(status="open", task_type=task_type, limit=100)
856
+
857
+ unexpanded = []
858
+ for t in all_tasks:
859
+ children = manager.list_tasks(parent_task_id=t.id, limit=1)
860
+ if not children:
861
+ if t.complexity_score is None or t.complexity_score >= min_complexity:
862
+ unexpanded.append(t)
863
+
864
+ to_expand = unexpanded[:max_tasks]
865
+
866
+ if not to_expand:
867
+ click.echo("No unexpanded tasks found matching criteria.")
868
+ return
869
+
870
+ if dry_run:
871
+ click.echo(f"Would expand {len(to_expand)} tasks:")
872
+ for t in to_expand:
873
+ score = t.complexity_score or "?"
874
+ click.echo(f" {t.id[:12]} | Complexity: {score} | {t.title[:50]}")
875
+ return
876
+
877
+ # Initialize services
878
+ try:
879
+ config = load_config()
880
+ if not config.gobby_tasks.expansion.enabled:
881
+ click.echo("Error: Task expansion is disabled in config.", err=True)
882
+ return
883
+
884
+ llm_service = LLMService(config)
885
+ expander = TaskExpander(
886
+ config.gobby_tasks.expansion, llm_service, manager, mcp_manager=None
887
+ )
888
+ except Exception as e:
889
+ click.echo(f"Error initializing services: {e}", err=True)
890
+ return
891
+
892
+ click.echo(f"Expanding {len(to_expand)} tasks...")
893
+
894
+ async def expand_tasks() -> list[dict[str, Any]]:
895
+ results = []
896
+ for task in to_expand:
897
+ click.echo(f"\nExpanding: {task.title[:60]}...")
898
+ try:
899
+ result = await expander.expand_task(
900
+ task_id=task.id,
901
+ title=task.title,
902
+ description=task.description,
903
+ enable_web_research=web_research,
904
+ enable_code_context=True,
905
+ )
906
+ subtask_ids = result.get("subtask_ids", [])
907
+ results.append(
908
+ {
909
+ "task_id": task.id,
910
+ "title": task.title,
911
+ "subtasks_created": len(subtask_ids),
912
+ "status": "success" if not result.get("error") else "error",
913
+ "error": result.get("error"),
914
+ }
915
+ )
916
+ if result.get("error"):
917
+ click.echo(f" Error: {result['error']}")
918
+ else:
919
+ click.echo(f" Created {len(subtask_ids)} subtasks")
920
+ except Exception as e:
921
+ results.append(
922
+ {
923
+ "task_id": task.id,
924
+ "title": task.title,
925
+ "status": "error",
926
+ "error": str(e),
927
+ }
928
+ )
929
+ click.echo(f" Error: {e}")
930
+ return results
931
+
932
+ results = asyncio.run(expand_tasks())
933
+
934
+ success_count = len([r for r in results if r["status"] == "success"])
935
+ click.echo(f"\nExpanded {success_count}/{len(results)} tasks successfully.")
936
+
937
+
938
+ @click.command("suggest")
939
+ @click.option("--type", "-t", "task_type", help="Filter by task type")
940
+ @click.option("--no-prefer-subtasks", is_flag=True, help="Don't prefer leaf tasks over parents")
941
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
942
+ def suggest_cmd(task_type: str | None, no_prefer_subtasks: bool, json_format: bool) -> None:
943
+ """Suggest the next task to work on based on priority and readiness."""
944
+ import json as json_mod
945
+
946
+ manager = get_task_manager()
947
+ prefer_subtasks = not no_prefer_subtasks
948
+
949
+ ready_tasks = manager.list_ready_tasks(task_type=task_type, limit=50)
950
+
951
+ if not ready_tasks:
952
+ if json_format:
953
+ click.echo(json_mod.dumps({"suggestion": None, "reason": "No ready tasks found"}))
954
+ else:
955
+ click.echo("No ready tasks found.")
956
+ return
957
+
958
+ # Score each task
959
+ scored = []
960
+ for task in ready_tasks:
961
+ score = 0
962
+
963
+ # Priority boost (1=high gets +30, 2=medium gets +20, 3=low gets +10)
964
+ score += (4 - task.priority) * 10
965
+
966
+ # Check if it's a leaf task (no children)
967
+ children = manager.list_tasks(parent_task_id=task.id, status="open", limit=1)
968
+ is_leaf = len(children) == 0
969
+
970
+ if prefer_subtasks and is_leaf:
971
+ score += 25
972
+
973
+ # Bonus for tasks with clear complexity
974
+ if task.complexity_score and task.complexity_score <= 5:
975
+ score += 15
976
+
977
+ # Bonus for tasks with category defined
978
+ if task.category:
979
+ score += 10
980
+
981
+ scored.append((task, score, is_leaf))
982
+
983
+ # Sort by score descending
984
+ scored.sort(key=lambda x: x[1], reverse=True)
985
+ best_task, best_score, is_leaf = scored[0]
986
+
987
+ reasons = []
988
+ if best_task.priority == 1:
989
+ reasons.append("high priority")
990
+ if is_leaf:
991
+ reasons.append("actionable leaf task")
992
+ if best_task.complexity_score and best_task.complexity_score <= 5:
993
+ reasons.append("manageable complexity")
994
+ if best_task.category:
995
+ reasons.append(f"has category ({best_task.category})")
996
+
997
+ reason_str = f"Selected because: {', '.join(reasons) if reasons else 'best available option'}"
998
+
999
+ if json_format:
1000
+ result = {
1001
+ "suggestion": best_task.to_dict(),
1002
+ "score": best_score,
1003
+ "reason": reason_str,
1004
+ "alternatives": [
1005
+ {"task_id": t.id, "title": t.title, "score": s} for t, s, _ in scored[1:4]
1006
+ ],
1007
+ }
1008
+ click.echo(json_mod.dumps(result, indent=2, default=str))
1009
+ return
1010
+
1011
+ click.echo("Suggested next task:\n")
1012
+ click.echo(f" {best_task.id}")
1013
+ click.echo(f" {best_task.title}")
1014
+ click.echo(f" Priority: {best_task.priority} | Status: {best_task.status}")
1015
+ if best_task.description:
1016
+ desc_preview = best_task.description[:200]
1017
+ if len(best_task.description) > 200:
1018
+ desc_preview += "..."
1019
+ click.echo(f"\n {desc_preview}")
1020
+ click.echo(f"\n {reason_str}")
1021
+
1022
+ if len(scored) > 1:
1023
+ click.echo("\nAlternatives:")
1024
+ for task, _score, _ in scored[1:4]:
1025
+ click.echo(f" {task.id[:12]}: {task.title[:50]}")