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,889 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from gobby.storage.database import DatabaseProtocol
7
+ from gobby.storage.tasks._aggregates import (
8
+ count_blocked_tasks as _count_blocked_tasks,
9
+ )
10
+ from gobby.storage.tasks._aggregates import (
11
+ count_by_status as _count_by_status,
12
+ )
13
+ from gobby.storage.tasks._aggregates import (
14
+ count_ready_tasks as _count_ready_tasks,
15
+ )
16
+ from gobby.storage.tasks._aggregates import (
17
+ count_tasks as _count_tasks,
18
+ )
19
+ from gobby.storage.tasks._crud import (
20
+ create_task as _create_task,
21
+ )
22
+ from gobby.storage.tasks._crud import (
23
+ find_task_by_prefix as _find_task_by_prefix,
24
+ )
25
+ from gobby.storage.tasks._crud import (
26
+ find_tasks_by_prefix as _find_tasks_by_prefix,
27
+ )
28
+ from gobby.storage.tasks._crud import (
29
+ get_task as _get_task,
30
+ )
31
+ from gobby.storage.tasks._crud import (
32
+ update_task as _update_task,
33
+ )
34
+ from gobby.storage.tasks._id import generate_task_id, resolve_task_reference
35
+ from gobby.storage.tasks._lifecycle import (
36
+ add_label as _add_label,
37
+ )
38
+ from gobby.storage.tasks._lifecycle import (
39
+ close_task as _close_task,
40
+ )
41
+ from gobby.storage.tasks._lifecycle import (
42
+ delete_task as _delete_task,
43
+ )
44
+ from gobby.storage.tasks._lifecycle import (
45
+ link_commit as _link_commit,
46
+ )
47
+ from gobby.storage.tasks._lifecycle import (
48
+ remove_label as _remove_label,
49
+ )
50
+ from gobby.storage.tasks._lifecycle import (
51
+ reopen_task as _reopen_task,
52
+ )
53
+ from gobby.storage.tasks._lifecycle import (
54
+ unlink_commit as _unlink_commit,
55
+ )
56
+ from gobby.storage.tasks._models import (
57
+ PRIORITY_MAP,
58
+ UNSET,
59
+ VALID_CATEGORIES,
60
+ Task,
61
+ TaskIDCollisionError,
62
+ TaskNotFoundError,
63
+ normalize_priority,
64
+ validate_category,
65
+ )
66
+ from gobby.storage.tasks._ordering import order_tasks_hierarchically
67
+ from gobby.storage.tasks._path_cache import (
68
+ compute_path_cache,
69
+ update_descendant_paths,
70
+ update_path_cache,
71
+ )
72
+ from gobby.storage.tasks._queries import (
73
+ list_blocked_tasks as _list_blocked_tasks,
74
+ )
75
+ from gobby.storage.tasks._queries import (
76
+ list_ready_tasks as _list_ready_tasks,
77
+ )
78
+ from gobby.storage.tasks._queries import (
79
+ list_tasks as _list_tasks,
80
+ )
81
+ from gobby.storage.tasks._queries import (
82
+ list_workflow_tasks as _list_workflow_tasks,
83
+ )
84
+ from gobby.storage.tasks._search import TaskSearcher
85
+
86
+ logger = logging.getLogger(__name__)
87
+
88
+ # Re-export for backward compatibility
89
+ __all__ = [
90
+ "PRIORITY_MAP",
91
+ "UNSET",
92
+ "VALID_CATEGORIES",
93
+ "Task",
94
+ "TaskIDCollisionError",
95
+ "TaskNotFoundError",
96
+ "normalize_priority",
97
+ "validate_category",
98
+ "generate_task_id",
99
+ "order_tasks_hierarchically",
100
+ "LocalTaskManager",
101
+ ]
102
+
103
+
104
+ class LocalTaskManager:
105
+ def __init__(self, db: DatabaseProtocol):
106
+ self.db = db
107
+ self._change_listeners: list[Callable[[], Any]] = []
108
+ self._searcher: TaskSearcher | None = None
109
+
110
+ def add_change_listener(self, listener: Callable[[], Any]) -> None:
111
+ """Add a listener to be called when tasks change."""
112
+ self._change_listeners.append(listener)
113
+
114
+ def _notify_listeners(self) -> None:
115
+ """Notify all listeners of a change and mark search index dirty."""
116
+ # Mark search index as needing refit
117
+ if self._searcher is not None:
118
+ self._searcher.mark_dirty()
119
+
120
+ for listener in self._change_listeners:
121
+ try:
122
+ listener()
123
+ except Exception as e:
124
+ logger.error(f"Error in task change listener: {e}")
125
+
126
+ def compute_path_cache(self, task_id: str) -> str | None:
127
+ """Compute the hierarchical path for a task.
128
+
129
+ Traverses up the parent chain to build a dotted path from seq_nums.
130
+ Format: 'ancestor_seq.parent_seq.task_seq' (e.g., '1.3.47')
131
+
132
+ Args:
133
+ task_id: The task ID to compute path for
134
+
135
+ Returns:
136
+ Dotted path string (e.g., '1.3.47'), or None if task not found
137
+ or any task in the chain is missing a seq_num.
138
+ """
139
+ return compute_path_cache(self.db, task_id)
140
+
141
+ def update_path_cache(self, task_id: str) -> str | None:
142
+ """Compute and store the path_cache for a task.
143
+
144
+ Args:
145
+ task_id: The task ID to update
146
+
147
+ Returns:
148
+ The computed path, or None if computation failed
149
+ """
150
+ return update_path_cache(self.db, task_id)
151
+
152
+ def update_descendant_paths(self, task_id: str) -> int:
153
+ """Update path_cache for a task and all its descendants.
154
+
155
+ Use this after reparenting a task to cascade path updates.
156
+
157
+ Args:
158
+ task_id: The root task ID to start updating from
159
+
160
+ Returns:
161
+ Number of tasks updated
162
+ """
163
+ return update_descendant_paths(self.db, task_id)
164
+
165
+ def create_task(
166
+ self,
167
+ project_id: str,
168
+ title: str,
169
+ description: str | None = None,
170
+ parent_task_id: str | None = None,
171
+ created_in_session_id: str | None = None,
172
+ priority: int = 2,
173
+ task_type: str = "task",
174
+ assignee: str | None = None,
175
+ labels: list[str] | None = None,
176
+ category: str | None = None,
177
+ complexity_score: int | None = None,
178
+ estimated_subtasks: int | None = None,
179
+ expansion_context: str | None = None,
180
+ validation_criteria: str | None = None,
181
+ use_external_validator: bool = False,
182
+ workflow_name: str | None = None,
183
+ verification: str | None = None,
184
+ sequence_order: int | None = None,
185
+ github_issue_number: int | None = None,
186
+ github_pr_number: int | None = None,
187
+ github_repo: str | None = None,
188
+ linear_issue_id: str | None = None,
189
+ linear_team_id: str | None = None,
190
+ agent_name: str | None = None,
191
+ reference_doc: str | None = None,
192
+ requires_user_review: bool = False,
193
+ ) -> Task:
194
+ """Create a new task with collision handling."""
195
+ task_id = _create_task(
196
+ self.db,
197
+ project_id=project_id,
198
+ title=title,
199
+ description=description,
200
+ parent_task_id=parent_task_id,
201
+ created_in_session_id=created_in_session_id,
202
+ priority=priority,
203
+ task_type=task_type,
204
+ assignee=assignee,
205
+ labels=labels,
206
+ category=category,
207
+ complexity_score=complexity_score,
208
+ estimated_subtasks=estimated_subtasks,
209
+ expansion_context=expansion_context,
210
+ validation_criteria=validation_criteria,
211
+ use_external_validator=use_external_validator,
212
+ workflow_name=workflow_name,
213
+ verification=verification,
214
+ sequence_order=sequence_order,
215
+ github_issue_number=github_issue_number,
216
+ github_pr_number=github_pr_number,
217
+ github_repo=github_repo,
218
+ linear_issue_id=linear_issue_id,
219
+ linear_team_id=linear_team_id,
220
+ agent_name=agent_name,
221
+ reference_doc=reference_doc,
222
+ requires_user_review=requires_user_review,
223
+ )
224
+ self._notify_listeners()
225
+ return self.get_task(task_id)
226
+
227
+ def get_task(self, task_id: str, project_id: str | None = None) -> Task:
228
+ """Get a task by ID or reference.
229
+
230
+ Accepts multiple formats:
231
+ - UUID: Direct lookup
232
+ - #N: Project-scoped seq_num (requires project_id)
233
+ - N: Plain seq_num (requires project_id)
234
+
235
+ Args:
236
+ task_id: Task identifier in any supported format
237
+ project_id: Required for #N and N formats
238
+
239
+ Returns:
240
+ The Task object
241
+
242
+ Raises:
243
+ ValueError: If task not found or format requires project_id
244
+ """
245
+ return _get_task(self.db, task_id, project_id)
246
+
247
+ def find_task_by_prefix(self, prefix: str) -> Task | None:
248
+ """Find a task by ID prefix. Returns None if no match or multiple matches."""
249
+ return _find_task_by_prefix(self.db, prefix)
250
+
251
+ def find_tasks_by_prefix(self, prefix: str) -> list[Task]:
252
+ """Find all tasks matching an ID prefix."""
253
+ return _find_tasks_by_prefix(self.db, prefix)
254
+
255
+ def resolve_task_reference(self, ref: str, project_id: str) -> str:
256
+ """Resolve a task reference to its UUID.
257
+
258
+ Accepts multiple reference formats:
259
+ - N: Plain seq_num (e.g., 47)
260
+ - #N: Project-scoped seq_num (e.g., #47)
261
+ - 1.2.3: Path cache format
262
+ - UUID: Direct UUID (validated to exist)
263
+
264
+ Args:
265
+ ref: Task reference in any supported format
266
+ project_id: Project ID for scoped lookups
267
+
268
+ Returns:
269
+ The task's UUID
270
+
271
+ Raises:
272
+ TaskNotFoundError: If the reference cannot be resolved
273
+ """
274
+ return resolve_task_reference(self.db, ref, project_id)
275
+
276
+ def update_task(
277
+ self,
278
+ task_id: str,
279
+ title: str | None | Any = UNSET,
280
+ description: str | None | Any = UNSET,
281
+ status: str | None | Any = UNSET,
282
+ priority: int | None | Any = UNSET,
283
+ task_type: str | None | Any = UNSET,
284
+ assignee: str | None | Any = UNSET,
285
+ labels: list[str] | None | Any = UNSET,
286
+ parent_task_id: str | None | Any = UNSET,
287
+ validation_status: str | None | Any = UNSET,
288
+ validation_feedback: str | None | Any = UNSET,
289
+ category: str | None | Any = UNSET,
290
+ complexity_score: int | None | Any = UNSET,
291
+ estimated_subtasks: int | None | Any = UNSET,
292
+ expansion_context: str | None | Any = UNSET,
293
+ validation_criteria: str | None | Any = UNSET,
294
+ use_external_validator: bool | None | Any = UNSET,
295
+ validation_fail_count: int | None | Any = UNSET,
296
+ workflow_name: str | None | Any = UNSET,
297
+ verification: str | None | Any = UNSET,
298
+ sequence_order: int | None | Any = UNSET,
299
+ escalated_at: str | None | Any = UNSET,
300
+ escalation_reason: str | None | Any = UNSET,
301
+ github_issue_number: int | None | Any = UNSET,
302
+ github_pr_number: int | None | Any = UNSET,
303
+ github_repo: str | None | Any = UNSET,
304
+ linear_issue_id: str | None | Any = UNSET,
305
+ linear_team_id: str | None | Any = UNSET,
306
+ agent_name: str | None | Any = UNSET,
307
+ reference_doc: str | None | Any = UNSET,
308
+ is_expanded: bool | None | Any = UNSET,
309
+ is_tdd_applied: bool | None | Any = UNSET,
310
+ validation_override_reason: str | None | Any = UNSET,
311
+ requires_user_review: bool | None | Any = UNSET,
312
+ ) -> Task:
313
+ """Update task fields."""
314
+ parent_changed = _update_task(
315
+ self.db,
316
+ task_id=task_id,
317
+ title=title,
318
+ description=description,
319
+ status=status,
320
+ priority=priority,
321
+ task_type=task_type,
322
+ assignee=assignee,
323
+ labels=labels,
324
+ parent_task_id=parent_task_id,
325
+ validation_status=validation_status,
326
+ validation_feedback=validation_feedback,
327
+ category=category,
328
+ complexity_score=complexity_score,
329
+ estimated_subtasks=estimated_subtasks,
330
+ expansion_context=expansion_context,
331
+ validation_criteria=validation_criteria,
332
+ use_external_validator=use_external_validator,
333
+ validation_fail_count=validation_fail_count,
334
+ workflow_name=workflow_name,
335
+ verification=verification,
336
+ sequence_order=sequence_order,
337
+ escalated_at=escalated_at,
338
+ escalation_reason=escalation_reason,
339
+ github_issue_number=github_issue_number,
340
+ github_pr_number=github_pr_number,
341
+ github_repo=github_repo,
342
+ linear_issue_id=linear_issue_id,
343
+ linear_team_id=linear_team_id,
344
+ agent_name=agent_name,
345
+ reference_doc=reference_doc,
346
+ is_expanded=is_expanded,
347
+ is_tdd_applied=is_tdd_applied,
348
+ validation_override_reason=validation_override_reason,
349
+ requires_user_review=requires_user_review,
350
+ )
351
+
352
+ # If parent_task_id was changed, update path_cache for this task and all descendants
353
+ if parent_changed:
354
+ self.update_descendant_paths(task_id)
355
+
356
+ self._notify_listeners()
357
+ return self.get_task(task_id)
358
+
359
+ def close_task(
360
+ self,
361
+ task_id: str,
362
+ reason: str | None = None,
363
+ force: bool = False,
364
+ closed_in_session_id: str | None = None,
365
+ closed_commit_sha: str | None = None,
366
+ validation_override_reason: str | None = None,
367
+ ) -> Task:
368
+ """Close a task.
369
+
370
+ Args:
371
+ task_id: The task ID to close
372
+ reason: Optional reason for closing
373
+ force: If True, close even if there are open children (default: False)
374
+ closed_in_session_id: Session ID where task was closed
375
+ closed_commit_sha: Git commit SHA at time of closing
376
+ validation_override_reason: Why agent bypassed validation (if applicable)
377
+
378
+ Raises:
379
+ ValueError: If task not found or has open children (and force=False)
380
+ """
381
+ _close_task(
382
+ self.db,
383
+ task_id=task_id,
384
+ reason=reason,
385
+ force=force,
386
+ closed_in_session_id=closed_in_session_id,
387
+ closed_commit_sha=closed_commit_sha,
388
+ validation_override_reason=validation_override_reason,
389
+ )
390
+ self._notify_listeners()
391
+ return self.get_task(task_id)
392
+
393
+ def reopen_task(
394
+ self,
395
+ task_id: str,
396
+ reason: str | None = None,
397
+ ) -> Task:
398
+ """Reopen a closed or review task.
399
+
400
+ Args:
401
+ task_id: The task ID to reopen
402
+ reason: Optional reason for reopening
403
+
404
+ Raises:
405
+ ValueError: If task not found or not closed/review
406
+ """
407
+ _reopen_task(self.db, task_id=task_id, reason=reason)
408
+ self._notify_listeners()
409
+ return self.get_task(task_id)
410
+
411
+ def add_label(self, task_id: str, label: str) -> Task:
412
+ """Add a label to a task if not present."""
413
+ result = _add_label(self.db, task_id, label)
414
+ self._notify_listeners()
415
+ return result
416
+
417
+ def remove_label(self, task_id: str, label: str) -> Task:
418
+ """Remove a label from a task if present."""
419
+ result = _remove_label(self.db, task_id, label)
420
+ self._notify_listeners()
421
+ return result
422
+
423
+ def link_commit(self, task_id: str, commit_sha: str, cwd: str | Path | None = None) -> Task:
424
+ """Link a commit SHA to a task.
425
+
426
+ Adds the commit SHA to the task's commits array if not already present.
427
+ The SHA is normalized to dynamic short format for consistency.
428
+
429
+ Args:
430
+ task_id: The task ID to link the commit to.
431
+ commit_sha: The git commit SHA to link (short or full).
432
+ cwd: Working directory for git operations (defaults to current directory).
433
+
434
+ Returns:
435
+ Updated Task object.
436
+
437
+ Raises:
438
+ ValueError: If task not found or SHA cannot be resolved.
439
+ """
440
+ if _link_commit(self.db, task_id, commit_sha, cwd):
441
+ self._notify_listeners()
442
+ return self.get_task(task_id)
443
+
444
+ def unlink_commit(self, task_id: str, commit_sha: str, cwd: str | Path | None = None) -> Task:
445
+ """Unlink a commit SHA from a task.
446
+
447
+ Removes the commit SHA from the task's commits array if present.
448
+ Handles both normalized and legacy SHA formats via prefix matching.
449
+
450
+ Args:
451
+ task_id: The task ID to unlink the commit from.
452
+ commit_sha: The git commit SHA to unlink (short or full).
453
+ cwd: Working directory for git operations (defaults to current directory).
454
+
455
+ Returns:
456
+ Updated Task object.
457
+
458
+ Raises:
459
+ ValueError: If task not found.
460
+ """
461
+ if _unlink_commit(self.db, task_id, commit_sha, cwd):
462
+ self._notify_listeners()
463
+ return self.get_task(task_id)
464
+
465
+ def delete_task(self, task_id: str, cascade: bool = False) -> bool:
466
+ """Delete a task. If cascade is True, delete children recursively.
467
+
468
+ Returns:
469
+ True if task was deleted, False if task not found.
470
+ """
471
+ result = _delete_task(self.db, task_id, cascade)
472
+ if result:
473
+ self._notify_listeners()
474
+ return result
475
+
476
+ def list_tasks(
477
+ self,
478
+ project_id: str | None = None,
479
+ status: str | list[str] | None = None,
480
+ priority: int | None = None,
481
+ assignee: str | None = None,
482
+ task_type: str | None = None,
483
+ label: str | None = None,
484
+ parent_task_id: str | None = None,
485
+ title_like: str | None = None,
486
+ limit: int = 50,
487
+ offset: int = 0,
488
+ ) -> list[Task]:
489
+ """List tasks with filtering.
490
+
491
+ Args:
492
+ status: Filter by status. Can be a single status string, a list of statuses,
493
+ or None to include all statuses.
494
+
495
+ Results are ordered hierarchically: parents appear before their children,
496
+ with siblings sorted by priority ASC, then created_at ASC.
497
+ """
498
+ return _list_tasks(
499
+ self.db,
500
+ project_id=project_id,
501
+ status=status,
502
+ priority=priority,
503
+ assignee=assignee,
504
+ task_type=task_type,
505
+ label=label,
506
+ parent_task_id=parent_task_id,
507
+ title_like=title_like,
508
+ limit=limit,
509
+ offset=offset,
510
+ )
511
+
512
+ def list_ready_tasks(
513
+ self,
514
+ project_id: str | None = None,
515
+ priority: int | None = None,
516
+ task_type: str | None = None,
517
+ assignee: str | None = None,
518
+ parent_task_id: str | None = None,
519
+ limit: int = 50,
520
+ offset: int = 0,
521
+ ) -> list[Task]:
522
+ """List tasks that are ready to work on (open or in_progress) and not blocked.
523
+
524
+ A task is ready if:
525
+ 1. It is open or in_progress
526
+ 2. It has no open blocking dependencies
527
+ 3. Its parent (if any) is also ready (recursive check up the chain)
528
+
529
+ Note: in_progress tasks are included because they represent active work
530
+ that should remain visible in the ready queue.
531
+
532
+ Results are ordered hierarchically: parents appear before their children,
533
+ with siblings sorted by priority ASC, then created_at ASC.
534
+ """
535
+ return _list_ready_tasks(
536
+ self.db,
537
+ project_id=project_id,
538
+ priority=priority,
539
+ task_type=task_type,
540
+ assignee=assignee,
541
+ parent_task_id=parent_task_id,
542
+ limit=limit,
543
+ offset=offset,
544
+ )
545
+
546
+ def list_blocked_tasks(
547
+ self,
548
+ project_id: str | None = None,
549
+ parent_task_id: str | None = None,
550
+ limit: int = 50,
551
+ offset: int = 0,
552
+ ) -> list[Task]:
553
+ """List tasks that are blocked by at least one open blocking dependency.
554
+
555
+ Only considers "external" blockers - excludes parent tasks being blocked
556
+ by their own descendants (which is a "completion" block, not a "work" block).
557
+
558
+ Results are ordered hierarchically: parents appear before their children,
559
+ with siblings sorted by priority ASC, then created_at ASC.
560
+ """
561
+ return _list_blocked_tasks(
562
+ self.db,
563
+ project_id=project_id,
564
+ parent_task_id=parent_task_id,
565
+ limit=limit,
566
+ offset=offset,
567
+ )
568
+
569
+ def list_workflow_tasks(
570
+ self,
571
+ workflow_name: str,
572
+ project_id: str | None = None,
573
+ status: str | None = None,
574
+ limit: int = 100,
575
+ offset: int = 0,
576
+ ) -> list[Task]:
577
+ """List tasks associated with a workflow, ordered by sequence_order.
578
+
579
+ Args:
580
+ workflow_name: The workflow name to filter by
581
+ project_id: Optional project ID filter
582
+ status: Optional status filter ('open', 'in_progress', 'closed')
583
+ limit: Maximum tasks to return
584
+ offset: Pagination offset
585
+
586
+ Returns:
587
+ List of tasks ordered by sequence_order (nulls last), then created_at
588
+ """
589
+ return _list_workflow_tasks(
590
+ self.db,
591
+ workflow_name=workflow_name,
592
+ project_id=project_id,
593
+ status=status,
594
+ limit=limit,
595
+ offset=offset,
596
+ )
597
+
598
+ def count_tasks(
599
+ self,
600
+ project_id: str | None = None,
601
+ status: str | None = None,
602
+ ) -> int:
603
+ """Count tasks with optional filters.
604
+
605
+ Args:
606
+ project_id: Filter by project
607
+ status: Filter by status
608
+
609
+ Returns:
610
+ Count of matching tasks
611
+ """
612
+ return _count_tasks(self.db, project_id=project_id, status=status)
613
+
614
+ def count_by_status(self, project_id: str | None = None) -> dict[str, int]:
615
+ """Count tasks grouped by status.
616
+
617
+ Args:
618
+ project_id: Optional project filter
619
+
620
+ Returns:
621
+ Dictionary mapping status to count
622
+ """
623
+ return _count_by_status(self.db, project_id=project_id)
624
+
625
+ def count_ready_tasks(self, project_id: str | None = None) -> int:
626
+ """Count tasks that are ready (open or in_progress) and not blocked.
627
+
628
+ A task is ready if it has no external blocking dependencies.
629
+ Excludes parent tasks blocked by their own descendants (completion block, not work block).
630
+
631
+ Args:
632
+ project_id: Optional project filter
633
+
634
+ Returns:
635
+ Count of ready tasks
636
+ """
637
+ return _count_ready_tasks(self.db, project_id=project_id)
638
+
639
+ def count_blocked_tasks(self, project_id: str | None = None) -> int:
640
+ """Count tasks that are blocked by at least one external blocking dependency.
641
+
642
+ Excludes parent tasks blocked by their own descendants (completion block, not work block).
643
+
644
+ Args:
645
+ project_id: Optional project filter
646
+
647
+ Returns:
648
+ Count of blocked tasks
649
+ """
650
+ return _count_blocked_tasks(self.db, project_id=project_id)
651
+
652
+ def create_task_with_decomposition(
653
+ self,
654
+ project_id: str,
655
+ title: str,
656
+ description: str | None = None,
657
+ parent_task_id: str | None = None,
658
+ created_in_session_id: str | None = None,
659
+ priority: int = 2,
660
+ task_type: str = "task",
661
+ assignee: str | None = None,
662
+ labels: list[str] | None = None,
663
+ category: str | None = None,
664
+ complexity_score: int | None = None,
665
+ estimated_subtasks: int | None = None,
666
+ expansion_context: str | None = None,
667
+ validation_criteria: str | None = None,
668
+ use_external_validator: bool = False,
669
+ workflow_name: str | None = None,
670
+ verification: str | None = None,
671
+ sequence_order: int | None = None,
672
+ ) -> dict[str, Any]:
673
+ """Create a task and return result dict.
674
+
675
+ Args:
676
+ project_id: Project ID
677
+ title: Task title
678
+ description: Task description
679
+ parent_task_id: Optional parent task ID
680
+ created_in_session_id: Session ID where task was created
681
+ priority: Task priority
682
+ task_type: Task type
683
+ assignee: Optional assignee
684
+ labels: Optional labels list
685
+ category: Task domain category
686
+ complexity_score: Complexity score
687
+ estimated_subtasks: Estimated number of subtasks
688
+ expansion_context: Additional context for expansion
689
+ validation_criteria: Validation criteria for completion
690
+ use_external_validator: Whether to use external validator
691
+ workflow_name: Workflow name
692
+ verification: Verification steps
693
+ sequence_order: Sequence order in parent
694
+
695
+ Returns:
696
+ Dict with task details.
697
+ """
698
+ task = self.create_task(
699
+ project_id=project_id,
700
+ title=title,
701
+ description=description,
702
+ parent_task_id=parent_task_id,
703
+ created_in_session_id=created_in_session_id,
704
+ priority=priority,
705
+ task_type=task_type,
706
+ assignee=assignee,
707
+ labels=labels,
708
+ category=category,
709
+ complexity_score=complexity_score,
710
+ estimated_subtasks=estimated_subtasks,
711
+ expansion_context=expansion_context,
712
+ validation_criteria=validation_criteria,
713
+ use_external_validator=use_external_validator,
714
+ workflow_name=workflow_name,
715
+ verification=verification,
716
+ sequence_order=sequence_order,
717
+ )
718
+ return {"task": task.to_dict()}
719
+
720
+ def update_task_with_result(
721
+ self,
722
+ task_id: str,
723
+ description: str | None = None,
724
+ ) -> dict[str, Any]:
725
+ """Update a task's description and return result dict.
726
+
727
+ Args:
728
+ task_id: Task ID
729
+ description: New description
730
+
731
+ Returns:
732
+ Dict with task details.
733
+ """
734
+ updated = self.update_task(task_id, description=description)
735
+ return {"task": updated.to_dict()}
736
+
737
+ # --- Search Methods ---
738
+
739
+ def _ensure_searcher(self) -> TaskSearcher:
740
+ """Get or create the task searcher instance."""
741
+ if self._searcher is None:
742
+ self._searcher = TaskSearcher()
743
+ return self._searcher
744
+
745
+ def _ensure_search_fitted(self, project_id: str | None = None) -> None:
746
+ """Ensure the search index is fitted with current tasks.
747
+
748
+ Note: The index is always built from ALL tasks (not project-scoped) to ensure
749
+ the index remains valid for searches against any project. Project filtering
750
+ is applied in search_tasks() after TF-IDF ranking.
751
+
752
+ Args:
753
+ project_id: Unused - kept for API compatibility. Index always includes all tasks.
754
+ """
755
+ _ = project_id # Unused - index is always global
756
+ searcher = self._ensure_searcher()
757
+
758
+ if not searcher.needs_refit():
759
+ return
760
+
761
+ # Always fetch ALL tasks to build a global index
762
+ # Project-scoped filtering happens in search_tasks() after ranking
763
+ index_limit = 10000
764
+ tasks = _list_tasks(
765
+ self.db,
766
+ project_id=None, # Always global
767
+ limit=index_limit,
768
+ )
769
+
770
+ if len(tasks) == index_limit:
771
+ logger.warning(
772
+ f"Task search index may be incomplete: fetched exactly {index_limit} tasks. "
773
+ "Consider increasing the index limit or implementing pagination."
774
+ )
775
+
776
+ searcher.fit(tasks)
777
+ logger.info(f"Task search index fitted with {len(tasks)} tasks")
778
+
779
+ def mark_search_refit_needed(self) -> None:
780
+ """Mark that the search index needs to be rebuilt."""
781
+ if self._searcher is not None:
782
+ self._searcher.mark_dirty()
783
+
784
+ def search_tasks(
785
+ self,
786
+ query: str,
787
+ project_id: str | None = None,
788
+ status: str | list[str] | None = None,
789
+ task_type: str | None = None,
790
+ priority: int | None = None,
791
+ parent_task_id: str | None = None,
792
+ category: str | None = None,
793
+ limit: int = 20,
794
+ min_score: float = 0.0,
795
+ ) -> list[tuple[Task, float]]:
796
+ """Search tasks using TF-IDF semantic search.
797
+
798
+ Two-phase search: TF-IDF ranking first, then apply SQL filters.
799
+
800
+ Args:
801
+ query: Search query text
802
+ project_id: Filter by project
803
+ status: Filter by status (string or list of strings)
804
+ task_type: Filter by task type
805
+ priority: Filter by priority
806
+ parent_task_id: Filter by parent task ID (UUID)
807
+ category: Filter by task category
808
+ limit: Maximum results to return
809
+ min_score: Minimum similarity score threshold (0.0-1.0)
810
+
811
+ Returns:
812
+ List of (Task, similarity_score) tuples, sorted by score descending
813
+ """
814
+ # Ensure the search index is fitted
815
+ self._ensure_search_fitted(project_id)
816
+
817
+ searcher = self._ensure_searcher()
818
+
819
+ # Phase 1: TF-IDF search to get candidate task IDs
820
+ # Get more candidates than limit to allow for filtering
821
+ search_results = searcher.search(query, top_k=limit * 3)
822
+
823
+ if not search_results:
824
+ return []
825
+
826
+ # Phase 2: Fetch tasks and apply filters
827
+ results: list[tuple[Task, float]] = []
828
+
829
+ for task_id, score in search_results:
830
+ if score < min_score:
831
+ continue
832
+
833
+ try:
834
+ task = self.get_task(task_id)
835
+ except (ValueError, TaskNotFoundError):
836
+ # Task may have been deleted since indexing
837
+ continue
838
+
839
+ # Apply filters
840
+ if project_id and task.project_id != project_id:
841
+ continue
842
+
843
+ if status:
844
+ if isinstance(status, list):
845
+ if task.status not in status:
846
+ continue
847
+ elif task.status != status:
848
+ continue
849
+
850
+ if task_type and task.task_type != task_type:
851
+ continue
852
+
853
+ if priority is not None and task.priority != priority:
854
+ continue
855
+
856
+ if parent_task_id and task.parent_task_id != parent_task_id:
857
+ continue
858
+
859
+ if category and task.category != category:
860
+ continue
861
+
862
+ results.append((task, score))
863
+
864
+ if len(results) >= limit:
865
+ break
866
+
867
+ return results
868
+
869
+ def reindex_search(self, project_id: str | None = None) -> dict[str, Any]:
870
+ """Force rebuild of the task search index.
871
+
872
+ Note: The index is always global (includes all tasks). Project-scoped
873
+ filtering is applied at search time in search_tasks().
874
+
875
+ Args:
876
+ project_id: Unused - kept for API compatibility. Index always rebuilds globally.
877
+
878
+ Returns:
879
+ Dict with index statistics
880
+ """
881
+ searcher = self._ensure_searcher()
882
+
883
+ # Force refit by marking dirty
884
+ searcher.mark_dirty()
885
+
886
+ # Ensure fitted will rebuild the index
887
+ self._ensure_search_fitted(project_id)
888
+
889
+ return searcher.get_stats()