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,550 @@
1
+ """
2
+ Merge resolution storage module.
3
+
4
+ Stores merge resolutions and conflicts for worktree merge operations.
5
+ """
6
+
7
+ import logging
8
+ import sqlite3
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from enum import Enum
13
+ from typing import Any
14
+
15
+ from gobby.storage.database import DatabaseProtocol
16
+ from gobby.utils.id import generate_prefixed_id
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ConflictStatus(Enum):
22
+ """Status of a merge conflict."""
23
+
24
+ PENDING = "pending"
25
+ RESOLVED = "resolved"
26
+ FAILED = "failed"
27
+ HUMAN_REVIEW = "human_review"
28
+
29
+
30
+ @dataclass
31
+ class MergeResolution:
32
+ """A merge resolution record tracking a merge operation."""
33
+
34
+ id: str
35
+ worktree_id: str
36
+ source_branch: str
37
+ target_branch: str
38
+ status: str
39
+ tier_used: str | None
40
+ created_at: str
41
+ updated_at: str
42
+
43
+ @classmethod
44
+ def from_row(cls, row: sqlite3.Row) -> "MergeResolution":
45
+ """Create a MergeResolution from a database row."""
46
+ return cls(
47
+ id=row["id"],
48
+ worktree_id=row["worktree_id"],
49
+ source_branch=row["source_branch"],
50
+ target_branch=row["target_branch"],
51
+ status=row["status"],
52
+ tier_used=row["tier_used"],
53
+ created_at=row["created_at"],
54
+ updated_at=row["updated_at"],
55
+ )
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ """Convert resolution to dictionary for serialization."""
59
+ return {
60
+ "id": self.id,
61
+ "worktree_id": self.worktree_id,
62
+ "source_branch": self.source_branch,
63
+ "target_branch": self.target_branch,
64
+ "status": self.status,
65
+ "tier_used": self.tier_used,
66
+ "created_at": self.created_at,
67
+ "updated_at": self.updated_at,
68
+ }
69
+
70
+
71
+ @dataclass
72
+ class MergeConflict:
73
+ """A merge conflict record for a specific file."""
74
+
75
+ id: str
76
+ resolution_id: str
77
+ file_path: str
78
+ status: str
79
+ ours_content: str | None
80
+ theirs_content: str | None
81
+ resolved_content: str | None
82
+ created_at: str
83
+ updated_at: str
84
+
85
+ @classmethod
86
+ def from_row(cls, row: sqlite3.Row) -> "MergeConflict":
87
+ """Create a MergeConflict from a database row."""
88
+ return cls(
89
+ id=row["id"],
90
+ resolution_id=row["resolution_id"],
91
+ file_path=row["file_path"],
92
+ status=row["status"],
93
+ ours_content=row["ours_content"],
94
+ theirs_content=row["theirs_content"],
95
+ resolved_content=row["resolved_content"],
96
+ created_at=row["created_at"],
97
+ updated_at=row["updated_at"],
98
+ )
99
+
100
+ def to_dict(self) -> dict[str, Any]:
101
+ """Convert conflict to dictionary for serialization."""
102
+ return {
103
+ "id": self.id,
104
+ "resolution_id": self.resolution_id,
105
+ "file_path": self.file_path,
106
+ "status": self.status,
107
+ "ours_content": self.ours_content,
108
+ "theirs_content": self.theirs_content,
109
+ "resolved_content": self.resolved_content,
110
+ "created_at": self.created_at,
111
+ "updated_at": self.updated_at,
112
+ }
113
+
114
+
115
+ class MergeResolutionManager:
116
+ """Manages merge resolutions and conflicts in local SQLite database."""
117
+
118
+ def __init__(self, db: DatabaseProtocol):
119
+ self.db = db
120
+ self._change_listeners: list[Callable[[], Any]] = []
121
+
122
+ def add_change_listener(self, listener: Callable[[], Any]) -> None:
123
+ """Add a change listener that will be called on create/update/delete."""
124
+ self._change_listeners.append(listener)
125
+
126
+ def _notify_listeners(self) -> None:
127
+ """Notify all change listeners."""
128
+ for listener in self._change_listeners:
129
+ try:
130
+ listener()
131
+ except Exception as e:
132
+ logger.error(f"Error in merge resolution change listener: {e}")
133
+
134
+ # =========================================================================
135
+ # Resolution CRUD
136
+ # =========================================================================
137
+
138
+ def create_resolution(
139
+ self,
140
+ worktree_id: str,
141
+ source_branch: str,
142
+ target_branch: str,
143
+ status: str = "pending",
144
+ tier_used: str | None = None,
145
+ ) -> MergeResolution:
146
+ """Create a new merge resolution.
147
+
148
+ Args:
149
+ worktree_id: ID of the worktree
150
+ source_branch: Branch being merged in
151
+ target_branch: Target branch (e.g., main)
152
+ status: Resolution status (default: pending)
153
+ tier_used: Resolution tier used (if resolved)
154
+
155
+ Returns:
156
+ The created MergeResolution
157
+ """
158
+ now = datetime.now(UTC).isoformat()
159
+ resolution_id = generate_prefixed_id("mr", worktree_id + source_branch)
160
+
161
+ with self.db.transaction() as conn:
162
+ conn.execute(
163
+ """
164
+ INSERT INTO merge_resolutions (
165
+ id, worktree_id, source_branch, target_branch,
166
+ status, tier_used, created_at, updated_at
167
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
168
+ """,
169
+ (
170
+ resolution_id,
171
+ worktree_id,
172
+ source_branch,
173
+ target_branch,
174
+ status,
175
+ tier_used,
176
+ now,
177
+ now,
178
+ ),
179
+ )
180
+
181
+ self._notify_listeners()
182
+ result = self.get_resolution(resolution_id)
183
+ if result is None:
184
+ raise RuntimeError(
185
+ f"Failed to retrieve resolution '{resolution_id}' after successful insert"
186
+ )
187
+ return result
188
+
189
+ def get_resolution(self, resolution_id: str) -> MergeResolution | None:
190
+ """Get a resolution by ID.
191
+
192
+ Args:
193
+ resolution_id: The resolution ID
194
+
195
+ Returns:
196
+ The MergeResolution if found, None otherwise
197
+ """
198
+ row = self.db.fetchone("SELECT * FROM merge_resolutions WHERE id = ?", (resolution_id,))
199
+ if not row:
200
+ return None
201
+ return MergeResolution.from_row(row)
202
+
203
+ def update_resolution(
204
+ self,
205
+ resolution_id: str,
206
+ status: str | None = None,
207
+ tier_used: str | None = None,
208
+ ) -> MergeResolution | None:
209
+ """Update a resolution.
210
+
211
+ Args:
212
+ resolution_id: The resolution ID
213
+ status: New status (optional)
214
+ tier_used: New tier used (optional)
215
+
216
+ Returns:
217
+ The updated MergeResolution if found, None otherwise
218
+ """
219
+ resolution = self.get_resolution(resolution_id)
220
+ if not resolution:
221
+ return None
222
+
223
+ now = datetime.now(UTC).isoformat()
224
+ new_status = status if status is not None else resolution.status
225
+ new_tier = tier_used if tier_used is not None else resolution.tier_used
226
+
227
+ with self.db.transaction() as conn:
228
+ conn.execute(
229
+ """
230
+ UPDATE merge_resolutions
231
+ SET status = ?, tier_used = ?, updated_at = ?
232
+ WHERE id = ?
233
+ """,
234
+ (new_status, new_tier, now, resolution_id),
235
+ )
236
+
237
+ self._notify_listeners()
238
+ return self.get_resolution(resolution_id)
239
+
240
+ def delete_resolution(self, resolution_id: str) -> bool:
241
+ """Delete a resolution by ID.
242
+
243
+ Args:
244
+ resolution_id: The resolution ID to delete
245
+
246
+ Returns:
247
+ True if deleted, False if not found
248
+ """
249
+ with self.db.transaction() as conn:
250
+ cursor = conn.execute("DELETE FROM merge_resolutions WHERE id = ?", (resolution_id,))
251
+ if cursor.rowcount == 0:
252
+ return False
253
+
254
+ self._notify_listeners()
255
+ return True
256
+
257
+ def list_resolutions(
258
+ self,
259
+ worktree_id: str | None = None,
260
+ source_branch: str | None = None,
261
+ target_branch: str | None = None,
262
+ status: str | None = None,
263
+ limit: int = 100,
264
+ offset: int = 0,
265
+ ) -> list[MergeResolution]:
266
+ """List resolutions with optional filters.
267
+
268
+ Args:
269
+ worktree_id: Filter by worktree ID
270
+ source_branch: Filter by source branch
271
+ target_branch: Filter by target branch
272
+ status: Filter by status
273
+ limit: Maximum number of results
274
+ offset: Offset for pagination
275
+
276
+ Returns:
277
+ List of matching MergeResolutions
278
+ """
279
+ query = "SELECT * FROM merge_resolutions WHERE 1=1"
280
+ params: list[Any] = []
281
+
282
+ if worktree_id:
283
+ query += " AND worktree_id = ?"
284
+ params.append(worktree_id)
285
+
286
+ if source_branch:
287
+ query += " AND source_branch = ?"
288
+ params.append(source_branch)
289
+
290
+ if target_branch:
291
+ query += " AND target_branch = ?"
292
+ params.append(target_branch)
293
+
294
+ if status:
295
+ query += " AND status = ?"
296
+ params.append(status)
297
+
298
+ query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
299
+ params.extend([limit, offset])
300
+
301
+ rows = self.db.fetchall(query, tuple(params))
302
+ return [MergeResolution.from_row(row) for row in rows]
303
+
304
+ # =========================================================================
305
+ # Conflict CRUD
306
+ # =========================================================================
307
+
308
+ def create_conflict(
309
+ self,
310
+ resolution_id: str,
311
+ file_path: str,
312
+ ours_content: str | None = None,
313
+ theirs_content: str | None = None,
314
+ status: str = "pending",
315
+ ) -> MergeConflict:
316
+ """Create a new merge conflict.
317
+
318
+ Args:
319
+ resolution_id: ID of the parent resolution
320
+ file_path: Path to the conflicting file
321
+ ours_content: Content from our side
322
+ theirs_content: Content from their side
323
+ status: Conflict status (default: pending)
324
+
325
+ Returns:
326
+ The created MergeConflict
327
+ """
328
+ now = datetime.now(UTC).isoformat()
329
+ conflict_id = generate_prefixed_id("mc", resolution_id + file_path)
330
+
331
+ with self.db.transaction() as conn:
332
+ conn.execute(
333
+ """
334
+ INSERT INTO merge_conflicts (
335
+ id, resolution_id, file_path, status,
336
+ ours_content, theirs_content, created_at, updated_at
337
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
338
+ """,
339
+ (
340
+ conflict_id,
341
+ resolution_id,
342
+ file_path,
343
+ status,
344
+ ours_content,
345
+ theirs_content,
346
+ now,
347
+ now,
348
+ ),
349
+ )
350
+
351
+ self._notify_listeners()
352
+ result = self.get_conflict(conflict_id)
353
+ if result is None:
354
+ raise RuntimeError(
355
+ f"Failed to retrieve conflict '{conflict_id}' after successful insert"
356
+ )
357
+ return result
358
+
359
+ def get_conflict(self, conflict_id: str) -> MergeConflict | None:
360
+ """Get a conflict by ID.
361
+
362
+ Args:
363
+ conflict_id: The conflict ID
364
+
365
+ Returns:
366
+ The MergeConflict if found, None otherwise
367
+ """
368
+ row = self.db.fetchone("SELECT * FROM merge_conflicts WHERE id = ?", (conflict_id,))
369
+ if not row:
370
+ return None
371
+ return MergeConflict.from_row(row)
372
+
373
+ def update_conflict(
374
+ self,
375
+ conflict_id: str,
376
+ status: str | None = None,
377
+ resolved_content: str | None = None,
378
+ ) -> MergeConflict | None:
379
+ """Update a conflict.
380
+
381
+ Args:
382
+ conflict_id: The conflict ID
383
+ status: New status (optional)
384
+ resolved_content: Resolved content (optional)
385
+
386
+ Returns:
387
+ The updated MergeConflict if found, None otherwise
388
+ """
389
+ conflict = self.get_conflict(conflict_id)
390
+ if not conflict:
391
+ return None
392
+
393
+ now = datetime.now(UTC).isoformat()
394
+ new_status = status if status is not None else conflict.status
395
+ new_resolved = (
396
+ resolved_content if resolved_content is not None else conflict.resolved_content
397
+ )
398
+
399
+ with self.db.transaction() as conn:
400
+ conn.execute(
401
+ """
402
+ UPDATE merge_conflicts
403
+ SET status = ?, resolved_content = ?, updated_at = ?
404
+ WHERE id = ?
405
+ """,
406
+ (new_status, new_resolved, now, conflict_id),
407
+ )
408
+
409
+ self._notify_listeners()
410
+ return self.get_conflict(conflict_id)
411
+
412
+ def delete_conflict(self, conflict_id: str) -> bool:
413
+ """Delete a conflict by ID.
414
+
415
+ Args:
416
+ conflict_id: The conflict ID to delete
417
+
418
+ Returns:
419
+ True if deleted, False if not found
420
+ """
421
+ with self.db.transaction() as conn:
422
+ cursor = conn.execute("DELETE FROM merge_conflicts WHERE id = ?", (conflict_id,))
423
+ if cursor.rowcount == 0:
424
+ return False
425
+
426
+ self._notify_listeners()
427
+ return True
428
+
429
+ def list_conflicts(
430
+ self,
431
+ resolution_id: str | None = None,
432
+ file_path: str | None = None,
433
+ status: str | None = None,
434
+ limit: int = 100,
435
+ offset: int = 0,
436
+ ) -> list[MergeConflict]:
437
+ """List conflicts with optional filters.
438
+
439
+ Args:
440
+ resolution_id: Filter by resolution ID
441
+ file_path: Filter by file path
442
+ status: Filter by status
443
+ limit: Maximum number of results
444
+ offset: Offset for pagination
445
+
446
+ Returns:
447
+ List of matching MergeConflicts
448
+ """
449
+ query = "SELECT * FROM merge_conflicts WHERE 1=1"
450
+ params: list[Any] = []
451
+
452
+ if resolution_id:
453
+ query += " AND resolution_id = ?"
454
+ params.append(resolution_id)
455
+
456
+ if file_path:
457
+ query += " AND file_path = ?"
458
+ params.append(file_path)
459
+
460
+ if status:
461
+ query += " AND status = ?"
462
+ params.append(status)
463
+
464
+ query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
465
+ params.extend([limit, offset])
466
+
467
+ rows = self.db.fetchall(query, tuple(params))
468
+ return [MergeConflict.from_row(row) for row in rows]
469
+
470
+ # =========================================================================
471
+ # Helper Methods for CLI
472
+ # =========================================================================
473
+
474
+ def get_active_resolution(self, worktree_id: str | None = None) -> MergeResolution | None:
475
+ """
476
+ Get the current active (pending) merge resolution.
477
+
478
+ Args:
479
+ worktree_id: Optional worktree ID to filter by
480
+
481
+ Returns:
482
+ The most recent pending MergeResolution, or None
483
+ """
484
+ if worktree_id:
485
+ row = self.db.fetchone(
486
+ """
487
+ SELECT * FROM merge_resolutions
488
+ WHERE worktree_id = ? AND status = 'pending'
489
+ ORDER BY created_at DESC
490
+ LIMIT 1
491
+ """,
492
+ (worktree_id,),
493
+ )
494
+ else:
495
+ row = self.db.fetchone(
496
+ """
497
+ SELECT * FROM merge_resolutions
498
+ WHERE status = 'pending'
499
+ ORDER BY created_at DESC
500
+ LIMIT 1
501
+ """
502
+ )
503
+ return MergeResolution.from_row(row) if row else None
504
+
505
+ def get_conflict_by_path(
506
+ self, file_path: str, resolution_id: str | None = None
507
+ ) -> MergeConflict | None:
508
+ """
509
+ Get a conflict by file path.
510
+
511
+ Args:
512
+ file_path: Path to the conflicting file
513
+ resolution_id: Optional resolution ID to filter by
514
+
515
+ Returns:
516
+ The MergeConflict if found, None otherwise
517
+ """
518
+ if resolution_id:
519
+ row = self.db.fetchone(
520
+ """
521
+ SELECT * FROM merge_conflicts
522
+ WHERE file_path = ? AND resolution_id = ?
523
+ """,
524
+ (file_path, resolution_id),
525
+ )
526
+ else:
527
+ # Find any pending conflict with this path
528
+ row = self.db.fetchone(
529
+ """
530
+ SELECT c.* FROM merge_conflicts c
531
+ JOIN merge_resolutions r ON c.resolution_id = r.id
532
+ WHERE c.file_path = ? AND r.status = 'pending'
533
+ ORDER BY c.created_at DESC
534
+ LIMIT 1
535
+ """,
536
+ (file_path,),
537
+ )
538
+ return MergeConflict.from_row(row) if row else None
539
+
540
+ def has_active_resolution_for_worktree(self, worktree_id: str) -> bool:
541
+ """
542
+ Check if a worktree has an active (pending) merge resolution.
543
+
544
+ Args:
545
+ worktree_id: Worktree ID to check
546
+
547
+ Returns:
548
+ True if an active resolution exists
549
+ """
550
+ return self.get_active_resolution(worktree_id) is not None