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/tasks/commits.py ADDED
@@ -0,0 +1,633 @@
1
+ """Commit linking and diff functionality for Task System V2.
2
+
3
+ Provides utilities for linking commits to tasks and computing diffs.
4
+ """
5
+
6
+ import logging
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from gobby.utils.git import run_git_command
13
+
14
+ if TYPE_CHECKING:
15
+ from gobby.storage.tasks import LocalTaskManager
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class TaskDiffResult:
22
+ """Result of computing a task's diff.
23
+
24
+ Attributes:
25
+ diff: Combined diff content from all linked commits
26
+ commits: List of commit SHAs included in the diff
27
+ has_uncommitted_changes: Whether uncommitted changes were included
28
+ file_count: Number of files modified in the diff
29
+ """
30
+
31
+ diff: str
32
+ commits: list[str] = field(default_factory=list)
33
+ has_uncommitted_changes: bool = False
34
+ file_count: int = 0
35
+
36
+
37
+ def get_task_diff(
38
+ task_id: str,
39
+ task_manager: "LocalTaskManager",
40
+ include_uncommitted: bool = False,
41
+ cwd: str | Path | None = None,
42
+ ) -> TaskDiffResult:
43
+ """Get the combined diff for all commits linked to a task.
44
+
45
+ Args:
46
+ task_id: The task ID to get diff for.
47
+ task_manager: LocalTaskManager instance to fetch task data.
48
+ include_uncommitted: If True, include uncommitted changes in diff.
49
+ cwd: Working directory for git commands. Defaults to current directory.
50
+
51
+ Returns:
52
+ TaskDiffResult with combined diff and metadata.
53
+
54
+ Raises:
55
+ ValueError: If task not found.
56
+ """
57
+ # Get the task (raises ValueError if not found)
58
+ task = task_manager.get_task(task_id)
59
+
60
+ # Handle no commits
61
+ commits = task.commits or []
62
+ if not commits and not include_uncommitted:
63
+ return TaskDiffResult(diff="", commits=[], has_uncommitted_changes=False)
64
+
65
+ working_dir = Path(cwd) if cwd else Path.cwd()
66
+ diff_parts = []
67
+ has_uncommitted = False
68
+
69
+ # Get diff for each linked commit
70
+ if commits:
71
+ # For multiple commits, we get the combined diff
72
+ # git diff <first_commit>^..<last_commit> shows all changes
73
+ if len(commits) == 1:
74
+ # Single commit: show its changes
75
+ result = run_git_command(
76
+ ["git", "show", "--format=", commits[0]],
77
+ cwd=working_dir,
78
+ )
79
+ if result:
80
+ diff_parts.append(result)
81
+ else:
82
+ # Multiple commits: get combined diff
83
+ # Commits are stored in chronological order (oldest at index 0, newest at index -1)
84
+ # git diff oldest^..newest shows all changes in the range
85
+ result = run_git_command(
86
+ ["git", "diff", f"{commits[0]}^..{commits[-1]}"],
87
+ cwd=working_dir,
88
+ )
89
+ if result:
90
+ diff_parts.append(result)
91
+
92
+ # Include uncommitted changes if requested
93
+ if include_uncommitted:
94
+ uncommitted = run_git_command(
95
+ ["git", "diff", "HEAD"],
96
+ cwd=working_dir,
97
+ )
98
+ if uncommitted:
99
+ diff_parts.append(uncommitted)
100
+ has_uncommitted = True
101
+
102
+ # Combine all diff parts
103
+ combined_diff = "\n".join(diff_parts)
104
+
105
+ # Count files in the diff
106
+ file_count = len(re.findall(r"^diff --git", combined_diff, re.MULTILINE))
107
+
108
+ return TaskDiffResult(
109
+ diff=combined_diff,
110
+ commits=commits,
111
+ has_uncommitted_changes=has_uncommitted,
112
+ file_count=file_count,
113
+ )
114
+
115
+
116
+ # Doc file extensions that don't need LLM validation
117
+ DOC_EXTENSIONS = {".md", ".txt", ".rst", ".adoc", ".markdown"}
118
+
119
+
120
+ def is_doc_only_diff(diff: str) -> bool:
121
+ """Check if a diff only affects documentation files.
122
+
123
+ Args:
124
+ diff: Git diff string.
125
+
126
+ Returns:
127
+ True if all modified files are documentation files.
128
+ """
129
+ if not diff:
130
+ return False
131
+
132
+ # Find all file paths in the diff
133
+ file_pattern = r"^diff --git a/(.+?) b/"
134
+ matches = re.findall(file_pattern, diff, re.MULTILINE)
135
+
136
+ if not matches:
137
+ return False
138
+
139
+ # Check if all files are doc files
140
+ for file_path in matches:
141
+ ext = Path(file_path).suffix.lower()
142
+ if ext not in DOC_EXTENSIONS:
143
+ return False
144
+
145
+ return True
146
+
147
+
148
+ def summarize_diff_for_validation(
149
+ diff: str,
150
+ max_chars: int = 30000,
151
+ max_hunk_lines: int = 50,
152
+ priority_files: list[str] | None = None,
153
+ ) -> str:
154
+ """Summarize a diff for LLM validation, ensuring all files are visible.
155
+
156
+ For large diffs, this:
157
+ 1. Always shows the complete file list with stats
158
+ 2. Truncates individual hunks to avoid overwhelming the LLM
159
+ 3. Prioritizes showing file names over full content
160
+ 4. When priority_files provided, shows those files first with more space
161
+
162
+ Args:
163
+ diff: Full git diff string.
164
+ max_chars: Maximum characters to return.
165
+ max_hunk_lines: Maximum lines per hunk before truncation.
166
+ priority_files: Optional list of file paths to prioritize.
167
+ These files appear first and get 60% of the space allocation.
168
+
169
+ Returns:
170
+ Summarized diff string that fits within max_chars.
171
+ """
172
+ if not diff or len(diff) <= max_chars:
173
+ return diff
174
+
175
+ # Parse the diff into files
176
+ file_diffs = re.split(r"(?=^diff --git)", diff, flags=re.MULTILINE)
177
+ file_diffs = [f for f in file_diffs if f.strip()]
178
+
179
+ if not file_diffs:
180
+ return diff[:max_chars] + "\n\n... [diff truncated] ..."
181
+
182
+ # First, collect file stats
183
+ file_stats: list[dict[str, str | int]] = []
184
+ for file_diff in file_diffs:
185
+ # Extract file name
186
+ name_match = re.match(r"diff --git a/(.+?) b/", file_diff)
187
+ if name_match:
188
+ file_name = name_match.group(1)
189
+ else:
190
+ file_name = "(unknown)"
191
+
192
+ # Count additions/deletions
193
+ additions = len(re.findall(r"^\+[^+]", file_diff, re.MULTILINE))
194
+ deletions = len(re.findall(r"^-[^-]", file_diff, re.MULTILINE))
195
+
196
+ file_stats.append(
197
+ {
198
+ "name": file_name,
199
+ "additions": additions,
200
+ "deletions": deletions,
201
+ "diff": file_diff,
202
+ }
203
+ )
204
+
205
+ # Separate into priority and non-priority groups if priority_files provided
206
+ priority_stats: list[dict[str, str | int]] = []
207
+ non_priority_stats: list[dict[str, str | int]] = []
208
+
209
+ if priority_files:
210
+ priority_set = set(priority_files)
211
+ for f in file_stats:
212
+ if str(f["name"]) in priority_set:
213
+ priority_stats.append(f)
214
+ else:
215
+ non_priority_stats.append(f)
216
+ else:
217
+ # No priority files - all are non-priority (original behavior)
218
+ non_priority_stats = file_stats
219
+
220
+ # Build summary header
221
+ total_additions = sum(int(f["additions"]) for f in file_stats)
222
+ total_deletions = sum(int(f["deletions"]) for f in file_stats)
223
+
224
+ summary_parts: list[str] = [
225
+ f"## Diff Summary ({len(file_stats)} files, +{total_additions}/-{total_deletions})\n",
226
+ "### Files Changed:\n",
227
+ ]
228
+
229
+ # Show priority files first in the summary
230
+ if priority_stats:
231
+ summary_parts.append("#### Priority Files:\n")
232
+ for f in priority_stats:
233
+ summary_parts.append(f"- {f['name']} (+{f['additions']}/-{f['deletions']})\n")
234
+ if non_priority_stats:
235
+ summary_parts.append("\n#### Other Files:\n")
236
+
237
+ for f in non_priority_stats:
238
+ summary_parts.append(f"- {f['name']} (+{f['additions']}/-{f['deletions']})\n")
239
+
240
+ summary_parts.append("\n### File Details:\n\n")
241
+
242
+ # Calculate remaining space for file contents
243
+ header_size = sum(len(p) for p in summary_parts)
244
+ remaining_chars = max_chars - header_size - 100 # Buffer for truncation message
245
+
246
+ # Allocate space: 60% to priority files, 40% to non-priority (if priority_files provided)
247
+ if priority_files and priority_stats:
248
+ priority_space = int(remaining_chars * 0.6)
249
+ non_priority_space = remaining_chars - priority_space
250
+
251
+ chars_per_priority = priority_space // len(priority_stats) if priority_stats else 0
252
+ chars_per_non_priority = (
253
+ non_priority_space // len(non_priority_stats) if non_priority_stats else 0
254
+ )
255
+ else:
256
+ # Original behavior: equal distribution
257
+ chars_per_priority = 0
258
+ chars_per_non_priority = (
259
+ remaining_chars // len(file_stats) if file_stats else remaining_chars
260
+ )
261
+
262
+ def truncate_file_content(file_content: str, max_file_chars: int) -> str:
263
+ """Truncate a file diff to fit within max_file_chars."""
264
+ if len(file_content) <= max_file_chars:
265
+ return file_content
266
+
267
+ # Truncate this file's diff but keep the header
268
+ header_end = file_content.find("@@")
269
+ if header_end > 0:
270
+ header = file_content[:header_end]
271
+ hunks = file_content[header_end:]
272
+ # Keep first part of hunks
273
+ truncated_hunks = hunks[: max_file_chars - len(header) - 50]
274
+ return header + truncated_hunks + "\n... [file diff truncated] ...\n"
275
+ else:
276
+ return file_content[:max_file_chars] + "\n... [file diff truncated] ...\n"
277
+
278
+ # Add priority files first
279
+ for f in priority_stats:
280
+ file_content = truncate_file_content(str(f["diff"]), chars_per_priority)
281
+ summary_parts.append(file_content)
282
+
283
+ # Add non-priority files
284
+ for f in non_priority_stats:
285
+ file_content = truncate_file_content(str(f["diff"]), chars_per_non_priority)
286
+ summary_parts.append(file_content)
287
+
288
+ result = "".join(summary_parts)
289
+
290
+ # Final safety check
291
+ if len(result) > max_chars:
292
+ result = result[:max_chars] + "\n\n... [diff truncated] ..."
293
+
294
+ return result
295
+
296
+
297
+ def _build_file_patterns(
298
+ file_extensions: list[str] | None = None,
299
+ path_prefixes: list[str] | None = None,
300
+ ) -> list[str]:
301
+ """Build regex patterns for file path extraction.
302
+
303
+ Args:
304
+ file_extensions: List of file extensions to match (e.g., [".py", ".ts"]).
305
+ If None, uses a basic default set.
306
+ path_prefixes: List of path prefixes to match (e.g., ["src/", "tests/"]).
307
+ If None, uses a basic default set.
308
+
309
+ Returns:
310
+ List of regex patterns for file path matching.
311
+ """
312
+ # Build extension pattern from config
313
+ if file_extensions:
314
+ # Strip leading dots and escape for regex
315
+ exts = [ext.lstrip(".") for ext in file_extensions]
316
+ ext_pattern = "|".join(re.escape(ext) for ext in exts)
317
+ else:
318
+ ext_pattern = "py|ts|js|json|yaml|yml|toml|md|go|rs|cfg|ini|sh"
319
+
320
+ # Build prefix pattern from config
321
+ if path_prefixes:
322
+ # Strip trailing slashes for regex alternation
323
+ prefixes = [p.rstrip("/") for p in path_prefixes]
324
+ prefix_pattern = "|".join(re.escape(p) for p in prefixes)
325
+ else:
326
+ prefix_pattern = "src|tests?|lib|config|scripts?|docs?|bin|pkg|internal|cmd"
327
+
328
+ return [
329
+ # Backtick-quoted paths: `path/to/file.py`
330
+ r"`([^`]+/[^`]+)`",
331
+ r"`([^`]+\.[a-zA-Z0-9]+)`",
332
+ # Paths with directory separators and extensions
333
+ r"(?<![a-zA-Z0-9_])([a-zA-Z0-9_./-]+/[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)",
334
+ # Paths starting with common prefixes (using config)
335
+ rf"(?<![a-zA-Z0-9_])((?:{prefix_pattern})/[a-zA-Z0-9_./+-]+)",
336
+ # Absolute paths
337
+ r"(/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)+)",
338
+ # Relative paths with ./
339
+ r"(\./[a-zA-Z0-9_./+-]+)",
340
+ # Standalone filenames with common extensions (using config)
341
+ rf"(?<![a-zA-Z0-9_/])([a-zA-Z0-9_-]+\.(?:{ext_pattern}))\b",
342
+ ]
343
+
344
+
345
+ # Default known files (used when no config provided)
346
+ _DEFAULT_KNOWN_FILES = {
347
+ "Makefile",
348
+ "Dockerfile",
349
+ "Jenkinsfile",
350
+ "Vagrantfile",
351
+ "Rakefile",
352
+ "Gemfile",
353
+ }
354
+
355
+
356
+ def extract_mentioned_files(
357
+ task: dict[str, Any],
358
+ file_extensions: list[str] | None = None,
359
+ known_files: list[str] | None = None,
360
+ path_prefixes: list[str] | None = None,
361
+ ) -> list[str]:
362
+ """Extract file paths mentioned in task title, description, and validation_criteria.
363
+
364
+ Searches for file path patterns in the task's text fields and returns
365
+ a deduplicated list of file paths. Useful for prioritizing relevant files
366
+ in validation context.
367
+
368
+ Args:
369
+ task: Task dictionary with title, description, and optionally validation_criteria.
370
+ file_extensions: List of file extensions to recognize (from config).
371
+ If None, uses basic defaults.
372
+ known_files: List of known filenames without extensions (from config).
373
+ If None, uses basic defaults.
374
+ path_prefixes: List of common path prefixes (from config).
375
+ If None, uses basic defaults.
376
+
377
+ Returns:
378
+ List of unique file paths mentioned in the task.
379
+ """
380
+ # Combine text from all relevant fields
381
+ text_parts = []
382
+ if task.get("title"):
383
+ text_parts.append(task["title"])
384
+ if task.get("description"):
385
+ text_parts.append(task["description"])
386
+ if task.get("validation_criteria"):
387
+ text_parts.append(task["validation_criteria"])
388
+
389
+ if not text_parts:
390
+ return []
391
+
392
+ combined_text = "\n".join(text_parts)
393
+ found_paths: set[str] = set()
394
+
395
+ # Build patterns based on config
396
+ patterns = _build_file_patterns(file_extensions, path_prefixes)
397
+
398
+ # Apply each pattern
399
+ for pattern in patterns:
400
+ matches = re.findall(pattern, combined_text)
401
+ for match in matches:
402
+ # Clean up the match
403
+ path = match.strip()
404
+ # Skip if it looks like a URL
405
+ if path.startswith("http://") or path.startswith("https://"):
406
+ continue
407
+ # Skip if too short or doesn't look like a path
408
+ if len(path) < 3:
409
+ continue
410
+ found_paths.add(path)
411
+
412
+ # Check for known filenames without extensions
413
+ files_to_check = set(known_files) if known_files else _DEFAULT_KNOWN_FILES
414
+ for filename in files_to_check:
415
+ if filename in combined_text:
416
+ # Only add if it appears as a word boundary (escape special chars in filename)
417
+ escaped_filename = re.escape(filename)
418
+ if re.search(rf"(?<![a-zA-Z0-9_/]){escaped_filename}(?![a-zA-Z0-9_])", combined_text):
419
+ found_paths.add(filename)
420
+
421
+ return list(found_paths)
422
+
423
+
424
+ def extract_mentioned_symbols(task: dict[str, Any]) -> list[str]:
425
+ """Extract function/class names mentioned in task description.
426
+
427
+ Searches for symbol patterns in backticks and extracts function/class names.
428
+ Useful for providing enhanced context to validators.
429
+
430
+ Args:
431
+ task: Task dictionary with title, description, and optionally validation_criteria.
432
+
433
+ Returns:
434
+ List of unique symbol names mentioned in the task.
435
+ """
436
+ # Combine text from all relevant fields
437
+ text_parts = []
438
+ if task.get("title"):
439
+ text_parts.append(task["title"])
440
+ if task.get("description"):
441
+ text_parts.append(task["description"])
442
+ if task.get("validation_criteria"):
443
+ text_parts.append(task["validation_criteria"])
444
+
445
+ if not text_parts:
446
+ return []
447
+
448
+ combined_text = "\n".join(text_parts)
449
+ found_symbols: set[str] = set()
450
+
451
+ # Pattern to match backtick-quoted content
452
+ backtick_pattern = r"`([^`]+)`"
453
+ backtick_matches = re.findall(backtick_pattern, combined_text)
454
+
455
+ for match in backtick_matches:
456
+ match = match.strip()
457
+
458
+ # Skip if it looks like a file path (contains / or has file extension pattern)
459
+ if "/" in match:
460
+ continue
461
+ # Skip if it looks like a filename with common extensions
462
+ if re.search(r"\.[a-zA-Z]{1,4}$", match) and "." in match:
463
+ # But allow method calls like obj.method()
464
+ if not re.search(r"^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*(?:\(\))?$", match):
465
+ continue
466
+
467
+ # Extract the symbol name
468
+ # Remove trailing () if present
469
+ symbol = re.sub(r"\(\)$", "", match)
470
+
471
+ # Handle Class.method pattern - extract the method name
472
+ if "." in symbol:
473
+ parts = symbol.split(".")
474
+ # Add the method name (last part)
475
+ method_name = parts[-1]
476
+ if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", method_name):
477
+ found_symbols.add(method_name)
478
+ # Optionally also add the full reference
479
+ if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$", symbol):
480
+ found_symbols.add(symbol)
481
+ else:
482
+ # Simple identifier (function name, class name, etc.)
483
+ if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", symbol):
484
+ found_symbols.add(symbol)
485
+
486
+ return list(found_symbols)
487
+
488
+
489
+ # Task ID patterns to search for in commit messages
490
+ # Supports #N format (e.g., #1, #47) - human-friendly task references
491
+ TASK_ID_PATTERNS = [
492
+ # [#N] - bracket format
493
+ r"\[#(\d+)\]",
494
+ # #N: - hash-colon format (at start of line or after space)
495
+ r"(?:^|\s)#(\d+):",
496
+ # Implements/Fixes/Closes/Refs #N (supports multiple: #1, #2, #3)
497
+ r"(?:implements|fixes|closes|refs)\s+#(\d+)",
498
+ # Standalone #N after whitespace (with word boundary to avoid false positives)
499
+ r"(?:^|\s)#(\d+)\b(?![\d.])",
500
+ ]
501
+
502
+
503
+ def extract_task_ids_from_message(message: str) -> list[str]:
504
+ """Extract task IDs from a commit message.
505
+
506
+ Supports patterns:
507
+ - [#N] - bracket format
508
+ - #N: - hash-colon format (at start of message)
509
+ - Implements/Fixes/Closes/Refs #N
510
+ - Multiple references: #1, #2, #3
511
+
512
+ Args:
513
+ message: Commit message to parse.
514
+
515
+ Returns:
516
+ List of unique task references found (e.g., ["#1", "#42"]).
517
+ """
518
+ task_ids = set()
519
+
520
+ for pattern in TASK_ID_PATTERNS:
521
+ matches = re.findall(pattern, message, re.IGNORECASE | re.MULTILINE)
522
+ for match in matches:
523
+ # Format as #N
524
+ task_id = f"#{match}"
525
+ task_ids.add(task_id)
526
+
527
+ return list(task_ids)
528
+
529
+
530
+ @dataclass
531
+ class AutoLinkResult:
532
+ """Result of auto-linking commits to tasks.
533
+
534
+ Attributes:
535
+ linked_tasks: Dict mapping task_id -> list of newly linked commit SHAs.
536
+ total_linked: Total number of commits newly linked.
537
+ skipped: Number of commits skipped (already linked or task not found).
538
+ """
539
+
540
+ linked_tasks: dict[str, list[str]] = field(default_factory=dict)
541
+ total_linked: int = 0
542
+ skipped: int = 0
543
+
544
+
545
+ def auto_link_commits(
546
+ task_manager: "LocalTaskManager",
547
+ task_id: str | None = None,
548
+ since: str | None = None,
549
+ cwd: str | Path | None = None,
550
+ ) -> AutoLinkResult:
551
+ """Auto-detect and link commits that mention task IDs.
552
+
553
+ Searches commit messages for task ID patterns and links matching commits
554
+ to the corresponding tasks.
555
+
556
+ Args:
557
+ task_manager: LocalTaskManager instance for task operations.
558
+ task_id: Optional specific task ID to filter for.
559
+ since: Optional git --since parameter (e.g., "1 week ago", "2024-01-01").
560
+ cwd: Working directory for git commands.
561
+
562
+ Returns:
563
+ AutoLinkResult with details of linked and skipped commits.
564
+ """
565
+ working_dir = Path(cwd) if cwd else Path.cwd()
566
+
567
+ # Build git log command
568
+ # Format: "sha|message" for easy parsing
569
+ git_cmd = ["git", "log", "--pretty=format:%h|%s"]
570
+
571
+ if since:
572
+ git_cmd.append(f"--since={since}")
573
+
574
+ # Get git log output
575
+ log_output = run_git_command(git_cmd, cwd=working_dir)
576
+
577
+ if not log_output:
578
+ return AutoLinkResult()
579
+
580
+ result = AutoLinkResult()
581
+
582
+ # Parse each commit line
583
+ for line in log_output.strip().split("\n"):
584
+ if not line or "|" not in line:
585
+ continue
586
+
587
+ parts = line.split("|", 1)
588
+ if len(parts) != 2:
589
+ continue
590
+
591
+ commit_sha, message = parts
592
+
593
+ # Extract task IDs from message
594
+ found_task_ids = extract_task_ids_from_message(message)
595
+
596
+ if not found_task_ids:
597
+ continue
598
+
599
+ # Filter to specific task if requested
600
+ if task_id:
601
+ if task_id not in found_task_ids:
602
+ continue
603
+ found_task_ids = [task_id]
604
+
605
+ # Try to link each found task
606
+ for tid in found_task_ids:
607
+ try:
608
+ task = task_manager.get_task(tid)
609
+
610
+ # Check if already linked
611
+ existing_commits = task.commits or []
612
+ if commit_sha in existing_commits:
613
+ result.skipped += 1
614
+ continue
615
+
616
+ # Link the commit
617
+ task_manager.link_commit(tid, commit_sha)
618
+
619
+ # Track in result
620
+ if tid not in result.linked_tasks:
621
+ result.linked_tasks[tid] = []
622
+ result.linked_tasks[tid].append(commit_sha)
623
+ result.total_linked += 1
624
+
625
+ logger.debug(f"Auto-linked commit {commit_sha} to task {tid}")
626
+
627
+ except ValueError:
628
+ # Task doesn't exist, skip
629
+ logger.debug(f"Skipping commit {commit_sha}: task {tid} not found")
630
+ result.skipped += 1
631
+ continue
632
+
633
+ return result