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,177 @@
1
+ """Conflict extraction utilities for parsing Git merge conflicts.
2
+
3
+ Parses Git conflict markers (<<<<<<< HEAD, =======, >>>>>>> branch) and
4
+ extracts conflict regions with context windowing.
5
+ """
6
+
7
+ import re
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass
12
+ class ConflictHunk:
13
+ """A single merge conflict extracted from a file.
14
+
15
+ Attributes:
16
+ ours: Content from the HEAD/current branch side
17
+ theirs: Content from the incoming/merging branch side
18
+ base: Content from the common ancestor (for diff3-style conflicts)
19
+ start_line: Line number where conflict starts (1-indexed)
20
+ end_line: Line number where conflict ends (1-indexed)
21
+ context_before: Lines of context before the conflict
22
+ context_after: Lines of context after the conflict
23
+ ours_marker: Full <<<<<<< marker line
24
+ theirs_marker: Full >>>>>>> marker line
25
+ """
26
+
27
+ ours: str
28
+ theirs: str
29
+ base: str | None
30
+ start_line: int
31
+ end_line: int
32
+ context_before: str
33
+ context_after: str
34
+ ours_marker: str = ""
35
+ theirs_marker: str = ""
36
+
37
+
38
+ # Regex patterns for conflict markers
39
+ # Must be at start of line, with optional whitespace before
40
+ CONFLICT_START = re.compile(r"^(<<<<<<<\s+.*)$", re.MULTILINE)
41
+ CONFLICT_SEPARATOR = re.compile(r"^(=======)\s*$", re.MULTILINE)
42
+ CONFLICT_BASE_SEPARATOR = re.compile(r"^(\|\|\|\|\|\|\|\s+.*)$", re.MULTILINE) # diff3 base
43
+ CONFLICT_END = re.compile(r"^(>>>>>>>\s+.*)$", re.MULTILINE)
44
+
45
+
46
+ def extract_conflict_hunks(file_content: str, context_lines: int = 3) -> list[ConflictHunk]:
47
+ """Extract conflict hunks from file content.
48
+
49
+ Parses Git conflict markers and extracts conflict regions with
50
+ surrounding context.
51
+
52
+ Args:
53
+ file_content: The full file content with conflict markers
54
+ context_lines: Number of context lines before/after conflict (default: 3)
55
+
56
+ Returns:
57
+ List of ConflictHunk objects, one per conflict region.
58
+ Returns empty list if no conflicts found.
59
+ """
60
+ if not file_content:
61
+ return []
62
+
63
+ # Normalize line endings
64
+ content = file_content.replace("\r\n", "\n").replace("\r", "\n")
65
+ lines = content.split("\n")
66
+
67
+ hunks: list[ConflictHunk] = []
68
+ i = 0
69
+
70
+ while i < len(lines):
71
+ line = lines[i]
72
+
73
+ # Look for conflict start marker
74
+ if line.startswith("<<<<<<<"):
75
+ hunk = _parse_conflict_at(lines, i, context_lines)
76
+ if hunk:
77
+ hunks.append(hunk)
78
+ # Skip to end of conflict
79
+ i = hunk.end_line
80
+ else:
81
+ i += 1
82
+ else:
83
+ i += 1
84
+
85
+ return hunks
86
+
87
+
88
+ def _parse_conflict_at(lines: list[str], start_idx: int, context_lines: int) -> ConflictHunk | None:
89
+ """Parse a single conflict starting at the given line index.
90
+
91
+ Args:
92
+ lines: All lines in the file
93
+ start_idx: Index of the <<<<<<< line
94
+ context_lines: Number of context lines to include
95
+
96
+ Returns:
97
+ ConflictHunk if valid conflict found, None if malformed
98
+ """
99
+ n = len(lines)
100
+
101
+ # Validate start marker
102
+ if start_idx >= n or not lines[start_idx].startswith("<<<<<<<"):
103
+ return None
104
+
105
+ ours_marker = lines[start_idx]
106
+ start_line = start_idx + 1 # Convert to 1-indexed
107
+
108
+ # Find separator (=======)
109
+ separator_idx = None
110
+ base_separator_idx = None
111
+
112
+ for idx in range(start_idx + 1, n):
113
+ line = lines[idx]
114
+ if line.startswith("|||||||"): # diff3 base separator
115
+ base_separator_idx = idx
116
+ elif line.startswith("======="):
117
+ separator_idx = idx
118
+ break
119
+ elif line.startswith(">>>>>>>"):
120
+ # End marker before separator - malformed
121
+ return None
122
+ elif line.startswith("<<<<<<<"):
123
+ # Nested conflict start - malformed
124
+ return None
125
+
126
+ if separator_idx is None:
127
+ # No separator found - malformed
128
+ return None
129
+
130
+ # Find end marker (>>>>>>>)
131
+ end_idx = None
132
+ for idx in range(separator_idx + 1, n):
133
+ line = lines[idx]
134
+ if line.startswith(">>>>>>>"):
135
+ end_idx = idx
136
+ break
137
+ elif line.startswith("<<<<<<<") or line.startswith("======="):
138
+ # Another conflict start or extra separator - malformed
139
+ return None
140
+
141
+ if end_idx is None:
142
+ # No end marker found - malformed
143
+ return None
144
+
145
+ theirs_marker = lines[end_idx]
146
+ end_line = end_idx + 1 # Convert to 1-indexed
147
+
148
+ # Extract content sections
149
+ if base_separator_idx is not None:
150
+ # diff3-style: ours | base | theirs
151
+ ours_content = "\n".join(lines[start_idx + 1 : base_separator_idx])
152
+ base_content = "\n".join(lines[base_separator_idx + 1 : separator_idx])
153
+ theirs_content = "\n".join(lines[separator_idx + 1 : end_idx])
154
+ else:
155
+ # Standard: ours | theirs
156
+ ours_content = "\n".join(lines[start_idx + 1 : separator_idx])
157
+ base_content = None
158
+ theirs_content = "\n".join(lines[separator_idx + 1 : end_idx])
159
+
160
+ # Extract context
161
+ context_start = max(0, start_idx - context_lines)
162
+ context_end = min(n, end_idx + 1 + context_lines)
163
+
164
+ context_before = "\n".join(lines[context_start:start_idx]) if start_idx > 0 else ""
165
+ context_after = "\n".join(lines[end_idx + 1 : context_end]) if end_idx + 1 < n else ""
166
+
167
+ return ConflictHunk(
168
+ ours=ours_content,
169
+ theirs=theirs_content,
170
+ base=base_content,
171
+ start_line=start_line,
172
+ end_line=end_line,
173
+ context_before=context_before,
174
+ context_after=context_after,
175
+ ours_marker=ours_marker,
176
+ theirs_marker=theirs_marker,
177
+ )
@@ -0,0 +1,485 @@
1
+ """Merge conflict resolver with tiered resolution strategy.
2
+
3
+ Implements a four-tier resolution strategy:
4
+ 1. Git auto-merge (no conflicts)
5
+ 2. Conflict-only AI resolution (sends only conflict hunks to LLM)
6
+ 3. Full-file AI resolution (sends entire file for complex conflicts)
7
+ 4. Human review fallback (marks as needs-human-review)
8
+ """
9
+
10
+ import asyncio
11
+ import logging
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from gobby.llm.service import LLMService
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ResolutionTier(Enum):
24
+ """Resolution strategy tiers, from fastest to most expensive."""
25
+
26
+ GIT_AUTO = "git_auto"
27
+ CONFLICT_ONLY_AI = "conflict_only_ai"
28
+ FULL_FILE_AI = "full_file_ai"
29
+ HUMAN_REVIEW = "human_review"
30
+
31
+
32
+ # Alias for spec compatibility
33
+ ResolutionStrategy = ResolutionTier
34
+
35
+
36
+ @dataclass
37
+ class MergeResult:
38
+ """Result of a merge resolution attempt.
39
+
40
+ Attributes:
41
+ success: Whether the merge was fully resolved
42
+ tier: The tier that completed the resolution (or escalated to)
43
+ conflicts: List of conflicts found during merge
44
+ resolved_files: List of files that were successfully resolved
45
+ unresolved_conflicts: List of conflicts that could not be resolved
46
+ needs_human_review: Whether manual intervention is required
47
+ """
48
+
49
+ success: bool
50
+ tier: ResolutionTier
51
+ conflicts: list[dict[str, Any]]
52
+ resolved_files: list[str] = field(default_factory=list)
53
+ unresolved_conflicts: list[dict[str, Any]] = field(default_factory=list)
54
+ needs_human_review: bool = False
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ """Convert to dictionary for serialization."""
58
+ return {
59
+ "success": self.success,
60
+ "tier": self.tier.value,
61
+ "conflicts": self.conflicts,
62
+ "resolved_files": self.resolved_files,
63
+ "unresolved_conflicts": self.unresolved_conflicts,
64
+ "needs_human_review": self.needs_human_review,
65
+ }
66
+
67
+
68
+ # Alias for spec compatibility
69
+ ResolutionResult = MergeResult
70
+
71
+
72
+ class MergeResolver:
73
+ """Merge conflict resolver with tiered strategy.
74
+
75
+ Attempts resolution in order of increasing complexity/cost:
76
+ 1. Git auto-merge
77
+ 2. Conflict-only AI resolution
78
+ 3. Full-file AI resolution
79
+ 4. Human review fallback
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ conflict_size_threshold: int = 100,
85
+ max_parallel_files: int = 5,
86
+ ):
87
+ """Initialize MergeResolver.
88
+
89
+ Args:
90
+ conflict_size_threshold: Lines of conflict above which to escalate to full-file
91
+ max_parallel_files: Maximum files to resolve in parallel
92
+ """
93
+ self.conflict_size_threshold = conflict_size_threshold
94
+ self.max_parallel_files = max_parallel_files
95
+ self._llm_service: LLMService | None = None # LLM service integration point
96
+
97
+ async def resolve_file(
98
+ self,
99
+ path: Path | str,
100
+ conflict_hunks: list[Any],
101
+ ) -> "ResolutionResult":
102
+ """Resolve conflicts in a single file using tiered strategy.
103
+
104
+ Args:
105
+ path: Path to the file with conflicts
106
+ conflict_hunks: List of ConflictHunk objects or conflict dicts
107
+
108
+ Returns:
109
+ ResolutionResult with resolution status
110
+ """
111
+ file_path = str(path) if isinstance(path, Path) else path
112
+
113
+ # Convert hunks to conflict dict format
114
+ conflict = {
115
+ "file": file_path,
116
+ "hunks": conflict_hunks,
117
+ }
118
+
119
+ # Check if conflict is too large for conflict-only resolution
120
+ def get_hunk_lines(h: Any) -> int:
121
+ """Get line count from hunk, handling both objects and dicts."""
122
+ if isinstance(h, dict):
123
+ ours = h.get("ours", "")
124
+ theirs = h.get("theirs", "")
125
+ else:
126
+ ours = getattr(h, "ours", "")
127
+ theirs = getattr(h, "theirs", "")
128
+ return len(ours.split("\n")) + len(theirs.split("\n"))
129
+
130
+ total_lines = sum(get_hunk_lines(h) for h in conflict_hunks)
131
+
132
+ # Tier 2: Try conflict-only if under threshold
133
+ if total_lines <= self.conflict_size_threshold:
134
+ result = await self._resolve_conflicts_only([conflict])
135
+ if result["success"]:
136
+ return ResolutionResult(
137
+ success=True,
138
+ tier=ResolutionTier.CONFLICT_ONLY_AI,
139
+ conflicts=[conflict],
140
+ resolved_files=[file_path],
141
+ unresolved_conflicts=[],
142
+ needs_human_review=False,
143
+ )
144
+
145
+ # Tier 3: Full-file resolution
146
+ result = await self._resolve_full_file([conflict])
147
+ if result["success"]:
148
+ return ResolutionResult(
149
+ success=True,
150
+ tier=ResolutionTier.FULL_FILE_AI,
151
+ conflicts=[conflict],
152
+ resolved_files=[file_path],
153
+ unresolved_conflicts=[],
154
+ needs_human_review=False,
155
+ )
156
+
157
+ # Tier 4: Human review fallback
158
+ return ResolutionResult(
159
+ success=False,
160
+ tier=ResolutionTier.HUMAN_REVIEW,
161
+ conflicts=[conflict],
162
+ resolved_files=[],
163
+ unresolved_conflicts=[conflict],
164
+ needs_human_review=True,
165
+ )
166
+
167
+ async def resolve(
168
+ self,
169
+ worktree_path: str,
170
+ source_branch: str,
171
+ target_branch: str,
172
+ force_tier: ResolutionTier | None = None,
173
+ ) -> MergeResult:
174
+ """Resolve merge conflicts using tiered strategy.
175
+
176
+ Args:
177
+ worktree_path: Path to the git worktree
178
+ source_branch: Branch being merged in
179
+ target_branch: Target branch (e.g., main)
180
+ force_tier: Optional tier to force (skips lower tiers)
181
+
182
+ Returns:
183
+ MergeResult with resolution status and details
184
+ """
185
+ # Tier 1: Git auto-merge (unless forcing a higher tier)
186
+ if force_tier is None or force_tier == ResolutionTier.GIT_AUTO:
187
+ git_result = await self._git_merge(worktree_path, source_branch, target_branch)
188
+
189
+ if git_result["success"]:
190
+ return MergeResult(
191
+ success=True,
192
+ tier=ResolutionTier.GIT_AUTO,
193
+ conflicts=[],
194
+ resolved_files=[],
195
+ unresolved_conflicts=[],
196
+ needs_human_review=False,
197
+ )
198
+
199
+ conflicts = git_result.get("conflicts", [])
200
+ else:
201
+ # Skipping git merge, assume conflicts exist
202
+ conflicts = []
203
+
204
+ # If forcing full-file AI, skip tier 2
205
+ if force_tier == ResolutionTier.FULL_FILE_AI:
206
+ return await self._try_full_file_resolution(worktree_path, conflicts or [{}])
207
+
208
+ # Tier 2: Conflict-only AI resolution
209
+ if conflicts:
210
+ tier2_result = await self._resolve_conflicts_only(conflicts)
211
+
212
+ if tier2_result["success"]:
213
+ return MergeResult(
214
+ success=True,
215
+ tier=ResolutionTier.CONFLICT_ONLY_AI,
216
+ conflicts=conflicts,
217
+ resolved_files=[c.get("file", "") for c in conflicts],
218
+ unresolved_conflicts=[],
219
+ needs_human_review=False,
220
+ )
221
+
222
+ # Tier 3: Full-file AI resolution
223
+ return await self._try_full_file_resolution(worktree_path, conflicts)
224
+
225
+ # No conflicts from git, but no git result - unusual state
226
+ return MergeResult(
227
+ success=True,
228
+ tier=ResolutionTier.GIT_AUTO,
229
+ conflicts=[],
230
+ resolved_files=[],
231
+ unresolved_conflicts=[],
232
+ needs_human_review=False,
233
+ )
234
+
235
+ async def _try_full_file_resolution(
236
+ self,
237
+ worktree_path: str,
238
+ conflicts: list[dict[str, Any]],
239
+ ) -> MergeResult:
240
+ """Attempt Tier 3 full-file resolution, fallback to human review."""
241
+ tier3_result = await self._resolve_full_file(conflicts)
242
+
243
+ if tier3_result["success"]:
244
+ return MergeResult(
245
+ success=True,
246
+ tier=ResolutionTier.FULL_FILE_AI,
247
+ conflicts=conflicts,
248
+ resolved_files=[c.get("file", "") for c in conflicts],
249
+ unresolved_conflicts=[],
250
+ needs_human_review=False,
251
+ )
252
+
253
+ # Tier 4: Human review fallback
254
+ return MergeResult(
255
+ success=False,
256
+ tier=ResolutionTier.HUMAN_REVIEW,
257
+ conflicts=conflicts,
258
+ resolved_files=[],
259
+ unresolved_conflicts=conflicts,
260
+ needs_human_review=True,
261
+ )
262
+
263
+ async def _git_merge(
264
+ self,
265
+ worktree_path: str,
266
+ source_branch: str,
267
+ target_branch: str,
268
+ ) -> dict[str, Any]:
269
+ """Attempt git auto-merge.
270
+
271
+ Args:
272
+ worktree_path: Path to git worktree
273
+ source_branch: Branch to merge in
274
+ target_branch: Target branch
275
+
276
+ Returns:
277
+ Dict with 'success' bool and 'conflicts' list if any
278
+ """
279
+ # Run git merge without committing
280
+ process = await asyncio.create_subprocess_exec(
281
+ "git",
282
+ "merge",
283
+ "--no-commit",
284
+ "--no-ff",
285
+ source_branch,
286
+ cwd=worktree_path,
287
+ stdout=asyncio.subprocess.PIPE,
288
+ stderr=asyncio.subprocess.PIPE,
289
+ )
290
+ await process.communicate()
291
+
292
+ if process.returncode == 0:
293
+ return {"success": True, "conflicts": []}
294
+
295
+ # Merge failed, find conflicting files
296
+ diff_process = await asyncio.create_subprocess_exec(
297
+ "git",
298
+ "diff",
299
+ "--name-only",
300
+ "--diff-filter=U",
301
+ cwd=worktree_path,
302
+ stdout=asyncio.subprocess.PIPE,
303
+ stderr=asyncio.subprocess.PIPE,
304
+ )
305
+ stdout, _ = await diff_process.communicate()
306
+ conflicted_files = stdout.decode().strip().splitlines()
307
+
308
+ from gobby.worktrees.merge.conflict_parser import extract_conflict_hunks
309
+
310
+ conflicts = []
311
+ for file_rel_path in conflicted_files:
312
+ file_path = Path(worktree_path) / file_rel_path
313
+ try:
314
+ content = file_path.read_text()
315
+ hunks = extract_conflict_hunks(content)
316
+ if hunks:
317
+ conflicts.append({"file": str(file_rel_path), "hunks": hunks})
318
+ except Exception as e:
319
+ logger.error(f"Failed to parse conflicts in {file_rel_path}: {e}")
320
+
321
+ return {"success": False, "conflicts": conflicts}
322
+
323
+ async def _resolve_conflicts_only(
324
+ self,
325
+ conflicts: list[dict[str, Any]],
326
+ ) -> dict[str, Any]:
327
+ """Resolve conflicts by sending only conflict hunks to LLM.
328
+
329
+ Args:
330
+ conflicts: List of conflict dicts with hunks
331
+
332
+ Returns:
333
+ Dict with 'success' bool and 'resolutions' list
334
+ """
335
+ if not self._llm_service:
336
+ logger.warning("No LLM service available for resolution")
337
+ return {"success": False, "resolutions": []}
338
+
339
+ resolutions = []
340
+ for conflict in conflicts:
341
+ file_path = conflict.get("file", "unknown")
342
+ hunks = conflict.get("hunks", [])
343
+
344
+ prompt = f"Resolve the following merge conflicts in {file_path}. Return ONLY the resolved code content for each hunk.\n\n"
345
+
346
+ for i, hunk in enumerate(hunks):
347
+ # Handle both dict and object hunk formats
348
+ if isinstance(hunk, dict):
349
+ ours = hunk.get("ours", "")
350
+ theirs = hunk.get("theirs", "")
351
+ else:
352
+ ours = getattr(hunk, "ours", "")
353
+ theirs = getattr(hunk, "theirs", "")
354
+
355
+ prompt += f"CONFLICT {i + 1}:\n"
356
+ prompt += f"<<<<<<< HEAD\n{ours}\n=======\n{theirs}\n>>>>>>> INCOMING\n\n"
357
+
358
+ prompt += "Provide the resolved code for each conflict hunk, separated by '---HUNK SEPARATOR---'."
359
+
360
+ try:
361
+ # Use default provider for now, could be configurable via tiered strategy params
362
+ provider = self._llm_service.get_default_provider()
363
+ response = await provider.generate_text(prompt)
364
+
365
+ if response:
366
+ # Simple parsing assumption - in real app would be more robust
367
+ resolved_hunks = response.split("---HUNK SEPARATOR---")
368
+ resolutions.append(
369
+ {
370
+ "file": file_path,
371
+ "content": response, # Storing full response for now as simple implementation
372
+ "hunks_resolved": len(resolved_hunks),
373
+ }
374
+ )
375
+ else:
376
+ return {"success": False, "resolutions": []}
377
+ except Exception as e:
378
+ logger.error(f"LLM resolution failed for {file_path}: {e}")
379
+ return {"success": False, "resolutions": []}
380
+
381
+ return {"success": True, "resolutions": resolutions}
382
+
383
+ async def _resolve_full_file(
384
+ self,
385
+ conflicts: list[dict[str, Any]],
386
+ ) -> dict[str, Any]:
387
+ """Resolve conflicts by sending full file content to LLM.
388
+
389
+ Args:
390
+ conflicts: List of conflict dicts
391
+
392
+ Returns:
393
+ Dict with 'success' bool and 'resolutions' list
394
+ """
395
+ if not self._llm_service:
396
+ logger.warning("No LLM service available for resolution")
397
+ return {"success": False, "resolutions": []}
398
+
399
+ resolutions = []
400
+ for conflict in conflicts:
401
+ file_path = conflict.get("file", "unknown")
402
+
403
+ try:
404
+ # In a real scenario, we'd read the file content with markers here
405
+ # But typically the file on disk already has markers if git merge failed
406
+ content_with_markers = Path(file_path).read_text()
407
+
408
+ prompt = f"Resolve all merge conflicts in the following file {file_path}. Return the FULL resolved file content.\n\n"
409
+ prompt += content_with_markers
410
+
411
+ # Use default provider
412
+ provider = self._llm_service.get_default_provider()
413
+ response = await provider.generate_text(prompt)
414
+
415
+ if response:
416
+ resolutions.append({"file": file_path, "content": response})
417
+ else:
418
+ return {"success": False, "resolutions": []}
419
+ except Exception as e:
420
+ logger.error(f"Full file resolution failed for {file_path}: {e}")
421
+ return {"success": False, "resolutions": []}
422
+
423
+ return {"success": True, "resolutions": resolutions}
424
+
425
+ async def _resolve_file_conflict(
426
+ self,
427
+ conflict: dict[str, Any],
428
+ ) -> dict[str, Any]:
429
+ """Resolve a single file's conflicts.
430
+
431
+ Args:
432
+ conflict: Conflict dict for one file
433
+
434
+ Returns:
435
+ Dict with 'success' bool
436
+ """
437
+ # Try conflict-only first
438
+ result = await self._resolve_conflicts_only([conflict])
439
+ if result["success"]:
440
+ return {"success": True}
441
+
442
+ # Escalate to full-file
443
+ result = await self._resolve_full_file([conflict])
444
+ return result
445
+
446
+ async def resolve_conflicts_parallel(
447
+ self,
448
+ worktree_path: str,
449
+ conflicts: list[dict[str, Any]],
450
+ ) -> tuple[list[str], list[dict[str, Any]]]:
451
+ """Resolve multiple file conflicts in parallel.
452
+
453
+ Args:
454
+ worktree_path: Path to git worktree
455
+ conflicts: List of conflicts to resolve
456
+
457
+ Returns:
458
+ Tuple of (resolved_files, unresolved_conflicts)
459
+ """
460
+ semaphore = asyncio.Semaphore(self.max_parallel_files)
461
+
462
+ async def resolve_with_limit(conflict: dict[str, Any]) -> dict[str, Any]:
463
+ async with semaphore:
464
+ result = await self._resolve_file_conflict(conflict)
465
+ return {"conflict": conflict, "result": result}
466
+
467
+ tasks = [resolve_with_limit(c) for c in conflicts]
468
+ results = await asyncio.gather(*tasks, return_exceptions=True)
469
+
470
+ resolved_files: list[str] = []
471
+ unresolved: list[dict[str, Any]] = []
472
+
473
+ for r in results:
474
+ if isinstance(r, BaseException):
475
+ logger.error(f"Error resolving conflict: {r}")
476
+ continue
477
+
478
+ # r is now dict[str, Any] after the isinstance check
479
+ result_dict: dict[str, Any] = r
480
+ if result_dict["result"].get("success"):
481
+ resolved_files.append(result_dict["conflict"].get("file", ""))
482
+ else:
483
+ unresolved.append(result_dict["conflict"])
484
+
485
+ return resolved_files, unresolved