@ag-eco/agentplate-cli 0.13.0

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 (455) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +462 -0
  3. package/agents/ap-co-creation.md +90 -0
  4. package/agents/builder.md +144 -0
  5. package/agents/coordinator.md +377 -0
  6. package/agents/lead.md +435 -0
  7. package/agents/merger.md +164 -0
  8. package/agents/monitor.md +214 -0
  9. package/agents/orchestrator.md +239 -0
  10. package/agents/reviewer.md +140 -0
  11. package/agents/scout.md +125 -0
  12. package/agents/supervisor.md +427 -0
  13. package/package.json +66 -0
  14. package/src/agents/capabilities.test.ts +85 -0
  15. package/src/agents/capabilities.ts +125 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +101 -0
  18. package/src/agents/copilot-hooks-deployer.test.ts +162 -0
  19. package/src/agents/copilot-hooks-deployer.ts +93 -0
  20. package/src/agents/guard-rules.test.ts +372 -0
  21. package/src/agents/guard-rules.ts +97 -0
  22. package/src/agents/headless-mail-injector.test.ts +709 -0
  23. package/src/agents/headless-mail-injector.ts +377 -0
  24. package/src/agents/headless-prompt.test.ts +102 -0
  25. package/src/agents/headless-prompt.ts +68 -0
  26. package/src/agents/hooks-deployer.test.ts +3119 -0
  27. package/src/agents/hooks-deployer.ts +804 -0
  28. package/src/agents/identity.test.ts +604 -0
  29. package/src/agents/identity.ts +384 -0
  30. package/src/agents/lifecycle.test.ts +196 -0
  31. package/src/agents/lifecycle.ts +183 -0
  32. package/src/agents/mail-poll-detect.test.ts +153 -0
  33. package/src/agents/mail-poll-detect.ts +73 -0
  34. package/src/agents/manifest.test.ts +1026 -0
  35. package/src/agents/manifest.ts +376 -0
  36. package/src/agents/overlay.test.ts +1058 -0
  37. package/src/agents/overlay.ts +490 -0
  38. package/src/agents/scope-detect.test.ts +190 -0
  39. package/src/agents/scope-detect.ts +146 -0
  40. package/src/agents/turn-lock.test.ts +181 -0
  41. package/src/agents/turn-lock.ts +235 -0
  42. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  43. package/src/agents/turn-runner-dispatch.ts +105 -0
  44. package/src/agents/turn-runner.test.ts +2312 -0
  45. package/src/agents/turn-runner.ts +1383 -0
  46. package/src/beads/client.test.ts +217 -0
  47. package/src/beads/client.ts +230 -0
  48. package/src/beads/molecules.test.ts +338 -0
  49. package/src/beads/molecules.ts +198 -0
  50. package/src/commands/agents.test.ts +328 -0
  51. package/src/commands/agents.ts +299 -0
  52. package/src/commands/clean.test.ts +797 -0
  53. package/src/commands/clean.ts +791 -0
  54. package/src/commands/completions.test.ts +348 -0
  55. package/src/commands/completions.ts +981 -0
  56. package/src/commands/coordinator.test.ts +2975 -0
  57. package/src/commands/coordinator.ts +1841 -0
  58. package/src/commands/costs.test.ts +1183 -0
  59. package/src/commands/costs.ts +599 -0
  60. package/src/commands/dashboard.test.ts +954 -0
  61. package/src/commands/dashboard.ts +1212 -0
  62. package/src/commands/discover.test.ts +288 -0
  63. package/src/commands/discover.ts +202 -0
  64. package/src/commands/doctor.test.ts +303 -0
  65. package/src/commands/doctor.ts +311 -0
  66. package/src/commands/ecosystem.test.ts +226 -0
  67. package/src/commands/ecosystem.ts +248 -0
  68. package/src/commands/errors.test.ts +654 -0
  69. package/src/commands/errors.ts +197 -0
  70. package/src/commands/feed.test.ts +709 -0
  71. package/src/commands/feed.ts +260 -0
  72. package/src/commands/group.test.ts +475 -0
  73. package/src/commands/group.ts +546 -0
  74. package/src/commands/hooks.test.ts +458 -0
  75. package/src/commands/hooks.ts +263 -0
  76. package/src/commands/init.test.ts +1011 -0
  77. package/src/commands/init.ts +967 -0
  78. package/src/commands/inspect.test.ts +1239 -0
  79. package/src/commands/inspect.ts +648 -0
  80. package/src/commands/log.test.ts +1913 -0
  81. package/src/commands/log.ts +958 -0
  82. package/src/commands/logs.test.ts +801 -0
  83. package/src/commands/logs.ts +483 -0
  84. package/src/commands/mail.test.ts +1501 -0
  85. package/src/commands/mail.ts +848 -0
  86. package/src/commands/merge.test.ts +864 -0
  87. package/src/commands/merge.ts +381 -0
  88. package/src/commands/metrics.test.ts +458 -0
  89. package/src/commands/metrics.ts +129 -0
  90. package/src/commands/monitor.test.ts +191 -0
  91. package/src/commands/monitor.ts +409 -0
  92. package/src/commands/nudge.test.ts +579 -0
  93. package/src/commands/nudge.ts +646 -0
  94. package/src/commands/orchestrator.ts +42 -0
  95. package/src/commands/prime.test.ts +612 -0
  96. package/src/commands/prime.ts +359 -0
  97. package/src/commands/replay.test.ts +757 -0
  98. package/src/commands/replay.ts +231 -0
  99. package/src/commands/run.test.ts +469 -0
  100. package/src/commands/run.ts +353 -0
  101. package/src/commands/serve/agent-actions.test.ts +210 -0
  102. package/src/commands/serve/agent-actions.ts +192 -0
  103. package/src/commands/serve/build.test.ts +202 -0
  104. package/src/commands/serve/build.ts +206 -0
  105. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  106. package/src/commands/serve/coordinator-actions.ts +410 -0
  107. package/src/commands/serve/dev.test.ts +168 -0
  108. package/src/commands/serve/dev.ts +117 -0
  109. package/src/commands/serve/mail-actions.test.ts +312 -0
  110. package/src/commands/serve/mail-actions.ts +167 -0
  111. package/src/commands/serve/rest.test.ts +1680 -0
  112. package/src/commands/serve/rest.ts +1130 -0
  113. package/src/commands/serve/static.ts +51 -0
  114. package/src/commands/serve/ws.test.ts +361 -0
  115. package/src/commands/serve/ws.ts +332 -0
  116. package/src/commands/serve.test.ts +459 -0
  117. package/src/commands/serve.ts +654 -0
  118. package/src/commands/sling.test.ts +1583 -0
  119. package/src/commands/sling.ts +1351 -0
  120. package/src/commands/spec.test.ts +179 -0
  121. package/src/commands/spec.ts +105 -0
  122. package/src/commands/status.test.ts +614 -0
  123. package/src/commands/status.ts +403 -0
  124. package/src/commands/stop.test.ts +964 -0
  125. package/src/commands/stop.ts +319 -0
  126. package/src/commands/supervisor.test.ts +185 -0
  127. package/src/commands/supervisor.ts +537 -0
  128. package/src/commands/trace.test.ts +762 -0
  129. package/src/commands/trace.ts +205 -0
  130. package/src/commands/update.test.ts +466 -0
  131. package/src/commands/update.ts +263 -0
  132. package/src/commands/upgrade.test.ts +48 -0
  133. package/src/commands/upgrade.ts +240 -0
  134. package/src/commands/watch.test.ts +257 -0
  135. package/src/commands/watch.ts +308 -0
  136. package/src/commands/worktree.test.ts +1297 -0
  137. package/src/commands/worktree.ts +451 -0
  138. package/src/config.test.ts +1535 -0
  139. package/src/config.ts +1064 -0
  140. package/src/doctor/agents.test.ts +523 -0
  141. package/src/doctor/agents.ts +399 -0
  142. package/src/doctor/config-check.test.ts +191 -0
  143. package/src/doctor/config-check.ts +183 -0
  144. package/src/doctor/consistency.test.ts +807 -0
  145. package/src/doctor/consistency.ts +347 -0
  146. package/src/doctor/databases.test.ts +350 -0
  147. package/src/doctor/databases.ts +243 -0
  148. package/src/doctor/dependencies.test.ts +296 -0
  149. package/src/doctor/dependencies.ts +272 -0
  150. package/src/doctor/ecosystem.test.ts +308 -0
  151. package/src/doctor/ecosystem.ts +156 -0
  152. package/src/doctor/logs.test.ts +253 -0
  153. package/src/doctor/logs.ts +295 -0
  154. package/src/doctor/merge-queue.test.ts +315 -0
  155. package/src/doctor/merge-queue.ts +167 -0
  156. package/src/doctor/providers.test.ts +409 -0
  157. package/src/doctor/providers.ts +250 -0
  158. package/src/doctor/serve.test.ts +95 -0
  159. package/src/doctor/serve.ts +86 -0
  160. package/src/doctor/structure.test.ts +423 -0
  161. package/src/doctor/structure.ts +285 -0
  162. package/src/doctor/types.ts +43 -0
  163. package/src/doctor/version.test.ts +241 -0
  164. package/src/doctor/version.ts +132 -0
  165. package/src/doctor/watchdog.test.ts +167 -0
  166. package/src/doctor/watchdog.ts +214 -0
  167. package/src/e2e/init-sling-lifecycle.test.ts +283 -0
  168. package/src/errors.test.ts +350 -0
  169. package/src/errors.ts +217 -0
  170. package/src/events/store.test.ts +660 -0
  171. package/src/events/store.ts +369 -0
  172. package/src/events/tailer.test.ts +719 -0
  173. package/src/events/tailer.ts +332 -0
  174. package/src/events/tool-filter.test.ts +330 -0
  175. package/src/events/tool-filter.ts +126 -0
  176. package/src/index.ts +533 -0
  177. package/src/insights/analyzer.test.ts +466 -0
  178. package/src/insights/analyzer.ts +203 -0
  179. package/src/insights/quality-gates.test.ts +141 -0
  180. package/src/insights/quality-gates.ts +156 -0
  181. package/src/json.test.ts +72 -0
  182. package/src/json.ts +53 -0
  183. package/src/loam/client.test.ts +752 -0
  184. package/src/loam/client.ts +664 -0
  185. package/src/logging/color.test.ts +252 -0
  186. package/src/logging/color.ts +105 -0
  187. package/src/logging/format.test.ts +110 -0
  188. package/src/logging/format.ts +255 -0
  189. package/src/logging/logger.test.ts +814 -0
  190. package/src/logging/logger.ts +266 -0
  191. package/src/logging/reporter.test.ts +259 -0
  192. package/src/logging/reporter.ts +110 -0
  193. package/src/logging/sanitizer.test.ts +190 -0
  194. package/src/logging/sanitizer.ts +57 -0
  195. package/src/logging/theme.ts +140 -0
  196. package/src/mail/broadcast.test.ts +204 -0
  197. package/src/mail/broadcast.ts +92 -0
  198. package/src/mail/client.test.ts +774 -0
  199. package/src/mail/client.ts +236 -0
  200. package/src/mail/store.test.ts +898 -0
  201. package/src/mail/store.ts +425 -0
  202. package/src/merge/lock.test.ts +149 -0
  203. package/src/merge/lock.ts +140 -0
  204. package/src/merge/predict.test.ts +387 -0
  205. package/src/merge/predict.ts +249 -0
  206. package/src/merge/queue.test.ts +426 -0
  207. package/src/merge/queue.ts +246 -0
  208. package/src/merge/resolver.test.ts +1993 -0
  209. package/src/merge/resolver.ts +926 -0
  210. package/src/metrics/pricing.test.ts +258 -0
  211. package/src/metrics/pricing.ts +135 -0
  212. package/src/metrics/store.test.ts +978 -0
  213. package/src/metrics/store.ts +501 -0
  214. package/src/metrics/summary.test.ts +398 -0
  215. package/src/metrics/summary.ts +178 -0
  216. package/src/metrics/transcript.test.ts +483 -0
  217. package/src/metrics/transcript.ts +114 -0
  218. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  219. package/src/runtimes/aider.test.ts +124 -0
  220. package/src/runtimes/aider.ts +147 -0
  221. package/src/runtimes/amp.test.ts +164 -0
  222. package/src/runtimes/amp.ts +154 -0
  223. package/src/runtimes/claude.test.ts +1474 -0
  224. package/src/runtimes/claude.ts +579 -0
  225. package/src/runtimes/codex.test.ts +805 -0
  226. package/src/runtimes/codex.ts +273 -0
  227. package/src/runtimes/connections.test.ts +214 -0
  228. package/src/runtimes/connections.ts +103 -0
  229. package/src/runtimes/copilot.test.ts +707 -0
  230. package/src/runtimes/copilot.ts +316 -0
  231. package/src/runtimes/cursor.test.ts +497 -0
  232. package/src/runtimes/cursor.ts +205 -0
  233. package/src/runtimes/gemini.test.ts +537 -0
  234. package/src/runtimes/gemini.ts +243 -0
  235. package/src/runtimes/goose.test.ts +133 -0
  236. package/src/runtimes/goose.ts +157 -0
  237. package/src/runtimes/headless-connection.test.ts +264 -0
  238. package/src/runtimes/headless-connection.ts +158 -0
  239. package/src/runtimes/opencode.test.ts +325 -0
  240. package/src/runtimes/opencode.ts +188 -0
  241. package/src/runtimes/pi-guards.test.ts +486 -0
  242. package/src/runtimes/pi-guards.ts +367 -0
  243. package/src/runtimes/pi.test.ts +789 -0
  244. package/src/runtimes/pi.ts +305 -0
  245. package/src/runtimes/registry.test.ts +196 -0
  246. package/src/runtimes/registry.ts +99 -0
  247. package/src/runtimes/sapling.test.ts +1267 -0
  248. package/src/runtimes/sapling.ts +710 -0
  249. package/src/runtimes/types.ts +266 -0
  250. package/src/schema-consistency.test.ts +246 -0
  251. package/src/sessions/compat.test.ts +281 -0
  252. package/src/sessions/compat.ts +105 -0
  253. package/src/sessions/store.test.ts +1748 -0
  254. package/src/sessions/store.ts +858 -0
  255. package/src/test-helpers.test.ts +124 -0
  256. package/src/test-helpers.ts +145 -0
  257. package/src/test-setup.test.ts +31 -0
  258. package/src/test-setup.ts +28 -0
  259. package/src/tools/loam/api.ts +368 -0
  260. package/src/tools/loam/cli.ts +278 -0
  261. package/src/tools/loam/commands/add.ts +52 -0
  262. package/src/tools/loam/commands/archive.ts +214 -0
  263. package/src/tools/loam/commands/audit.ts +276 -0
  264. package/src/tools/loam/commands/compact.ts +1062 -0
  265. package/src/tools/loam/commands/completions.ts +79 -0
  266. package/src/tools/loam/commands/config.ts +381 -0
  267. package/src/tools/loam/commands/delete-domain.ts +121 -0
  268. package/src/tools/loam/commands/delete.ts +316 -0
  269. package/src/tools/loam/commands/diff.ts +200 -0
  270. package/src/tools/loam/commands/doctor.ts +1113 -0
  271. package/src/tools/loam/commands/edit.ts +226 -0
  272. package/src/tools/loam/commands/init.ts +31 -0
  273. package/src/tools/loam/commands/learn.ts +179 -0
  274. package/src/tools/loam/commands/move.ts +323 -0
  275. package/src/tools/loam/commands/onboard.ts +374 -0
  276. package/src/tools/loam/commands/outcome.ts +185 -0
  277. package/src/tools/loam/commands/prime.ts +688 -0
  278. package/src/tools/loam/commands/prune.ts +614 -0
  279. package/src/tools/loam/commands/query.ts +218 -0
  280. package/src/tools/loam/commands/rank.ts +180 -0
  281. package/src/tools/loam/commands/ready.ts +189 -0
  282. package/src/tools/loam/commands/record.ts +1210 -0
  283. package/src/tools/loam/commands/restore.ts +166 -0
  284. package/src/tools/loam/commands/search.ts +327 -0
  285. package/src/tools/loam/commands/setup.ts +887 -0
  286. package/src/tools/loam/commands/status.ts +103 -0
  287. package/src/tools/loam/commands/sync.ts +298 -0
  288. package/src/tools/loam/commands/update.ts +19 -0
  289. package/src/tools/loam/commands/upgrade.ts +93 -0
  290. package/src/tools/loam/commands/validate.ts +190 -0
  291. package/src/tools/loam/index.ts +62 -0
  292. package/src/tools/loam/log.ts +127 -0
  293. package/src/tools/loam/registry/builtins.ts +409 -0
  294. package/src/tools/loam/registry/custom.ts +431 -0
  295. package/src/tools/loam/registry/init.ts +55 -0
  296. package/src/tools/loam/registry/template.ts +40 -0
  297. package/src/tools/loam/registry/type-registry.ts +113 -0
  298. package/src/tools/loam/schemas/config-schema.ts +489 -0
  299. package/src/tools/loam/schemas/config.ts +245 -0
  300. package/src/tools/loam/schemas/index.ts +18 -0
  301. package/src/tools/loam/schemas/record-schema.ts +191 -0
  302. package/src/tools/loam/schemas/record.ts +115 -0
  303. package/src/tools/loam/utils/active-work.ts +205 -0
  304. package/src/tools/loam/utils/anchor-validity.ts +80 -0
  305. package/src/tools/loam/utils/archive.ts +146 -0
  306. package/src/tools/loam/utils/audit.ts +667 -0
  307. package/src/tools/loam/utils/bm25.ts +238 -0
  308. package/src/tools/loam/utils/budget.ts +142 -0
  309. package/src/tools/loam/utils/config.ts +344 -0
  310. package/src/tools/loam/utils/dir-anchors.ts +62 -0
  311. package/src/tools/loam/utils/domain-rules.ts +114 -0
  312. package/src/tools/loam/utils/expertise.ts +393 -0
  313. package/src/tools/loam/utils/format-helpers.ts +96 -0
  314. package/src/tools/loam/utils/format.ts +1234 -0
  315. package/src/tools/loam/utils/git-context.ts +50 -0
  316. package/src/tools/loam/utils/git.ts +183 -0
  317. package/src/tools/loam/utils/hooks.ts +299 -0
  318. package/src/tools/loam/utils/index.ts +52 -0
  319. package/src/tools/loam/utils/json-output.ts +13 -0
  320. package/src/tools/loam/utils/lock.ts +76 -0
  321. package/src/tools/loam/utils/markers.ts +48 -0
  322. package/src/tools/loam/utils/numeric-flags.ts +20 -0
  323. package/src/tools/loam/utils/palette.ts +44 -0
  324. package/src/tools/loam/utils/prime-ranking.ts +135 -0
  325. package/src/tools/loam/utils/recipe-discovery.ts +195 -0
  326. package/src/tools/loam/utils/runtime-flags.ts +28 -0
  327. package/src/tools/loam/utils/scoring.ts +94 -0
  328. package/src/tools/loam/utils/version.ts +116 -0
  329. package/src/tools/sprout/commands/block.ts +64 -0
  330. package/src/tools/sprout/commands/blocked.ts +86 -0
  331. package/src/tools/sprout/commands/close.ts +129 -0
  332. package/src/tools/sprout/commands/completions.ts +198 -0
  333. package/src/tools/sprout/commands/config.ts +238 -0
  334. package/src/tools/sprout/commands/create.ts +164 -0
  335. package/src/tools/sprout/commands/dep.ts +148 -0
  336. package/src/tools/sprout/commands/doctor.ts +979 -0
  337. package/src/tools/sprout/commands/init.ts +83 -0
  338. package/src/tools/sprout/commands/label.ts +178 -0
  339. package/src/tools/sprout/commands/list.ts +210 -0
  340. package/src/tools/sprout/commands/migrate.ts +133 -0
  341. package/src/tools/sprout/commands/onboard.ts +207 -0
  342. package/src/tools/sprout/commands/plan-show.ts +278 -0
  343. package/src/tools/sprout/commands/plan.ts +2526 -0
  344. package/src/tools/sprout/commands/prime.ts +399 -0
  345. package/src/tools/sprout/commands/ready.ts +245 -0
  346. package/src/tools/sprout/commands/search.ts +221 -0
  347. package/src/tools/sprout/commands/show.ts +277 -0
  348. package/src/tools/sprout/commands/stats.ts +146 -0
  349. package/src/tools/sprout/commands/sync.ts +134 -0
  350. package/src/tools/sprout/commands/tpl.ts +364 -0
  351. package/src/tools/sprout/commands/unblock.ts +115 -0
  352. package/src/tools/sprout/commands/update.ts +257 -0
  353. package/src/tools/sprout/commands/upgrade.ts +91 -0
  354. package/src/tools/sprout/config-schema.ts +152 -0
  355. package/src/tools/sprout/config.ts +355 -0
  356. package/src/tools/sprout/filter.ts +107 -0
  357. package/src/tools/sprout/format.ts +43 -0
  358. package/src/tools/sprout/id.ts +22 -0
  359. package/src/tools/sprout/index.ts +204 -0
  360. package/src/tools/sprout/log.ts +76 -0
  361. package/src/tools/sprout/markers.ts +22 -0
  362. package/src/tools/sprout/output.ts +121 -0
  363. package/src/tools/sprout/plan-backref.ts +93 -0
  364. package/src/tools/sprout/plan-context.ts +81 -0
  365. package/src/tools/sprout/plan-domain.ts +139 -0
  366. package/src/tools/sprout/plan-lifecycle.ts +65 -0
  367. package/src/tools/sprout/plan-loam.ts +207 -0
  368. package/src/tools/sprout/plan-schema.ts +209 -0
  369. package/src/tools/sprout/sort.ts +31 -0
  370. package/src/tools/sprout/store.ts +172 -0
  371. package/src/tools/sprout/types.ts +118 -0
  372. package/src/tools/sprout/validation.ts +119 -0
  373. package/src/tools/sprout/version.ts +1 -0
  374. package/src/tools/sprout/yaml.ts +387 -0
  375. package/src/tools/trellis/commands/archive.ts +87 -0
  376. package/src/tools/trellis/commands/completions.ts +610 -0
  377. package/src/tools/trellis/commands/config.ts +382 -0
  378. package/src/tools/trellis/commands/create.ts +252 -0
  379. package/src/tools/trellis/commands/diff.ts +150 -0
  380. package/src/tools/trellis/commands/doctor.ts +771 -0
  381. package/src/tools/trellis/commands/emit.ts +365 -0
  382. package/src/tools/trellis/commands/history.ts +83 -0
  383. package/src/tools/trellis/commands/import.ts +198 -0
  384. package/src/tools/trellis/commands/init.ts +81 -0
  385. package/src/tools/trellis/commands/list.ts +103 -0
  386. package/src/tools/trellis/commands/onboard.ts +156 -0
  387. package/src/tools/trellis/commands/pin.ts +172 -0
  388. package/src/tools/trellis/commands/prime.ts +193 -0
  389. package/src/tools/trellis/commands/render.ts +122 -0
  390. package/src/tools/trellis/commands/schema.ts +353 -0
  391. package/src/tools/trellis/commands/show.ts +115 -0
  392. package/src/tools/trellis/commands/stats.ts +65 -0
  393. package/src/tools/trellis/commands/sync.ts +112 -0
  394. package/src/tools/trellis/commands/tree.ts +123 -0
  395. package/src/tools/trellis/commands/update.ts +330 -0
  396. package/src/tools/trellis/commands/upgrade.ts +95 -0
  397. package/src/tools/trellis/commands/validate.ts +166 -0
  398. package/src/tools/trellis/config-schema.ts +81 -0
  399. package/src/tools/trellis/config.ts +108 -0
  400. package/src/tools/trellis/frontmatter.ts +348 -0
  401. package/src/tools/trellis/id.ts +24 -0
  402. package/src/tools/trellis/index.ts +209 -0
  403. package/src/tools/trellis/markers.ts +28 -0
  404. package/src/tools/trellis/output.ts +84 -0
  405. package/src/tools/trellis/render.ts +212 -0
  406. package/src/tools/trellis/store.ts +144 -0
  407. package/src/tools/trellis/types.ts +82 -0
  408. package/src/tools/trellis/validate.ts +199 -0
  409. package/src/tools/trellis/yaml.ts +309 -0
  410. package/src/tracker/beads.test.ts +454 -0
  411. package/src/tracker/beads.ts +56 -0
  412. package/src/tracker/factory.test.ts +90 -0
  413. package/src/tracker/factory.ts +65 -0
  414. package/src/tracker/sprout.test.ts +461 -0
  415. package/src/tracker/sprout.ts +182 -0
  416. package/src/tracker/types.ts +52 -0
  417. package/src/trellis/client.test.ts +107 -0
  418. package/src/trellis/client.ts +179 -0
  419. package/src/types.ts +970 -0
  420. package/src/utils/bin.test.ts +10 -0
  421. package/src/utils/bin.ts +37 -0
  422. package/src/utils/browser.test.ts +49 -0
  423. package/src/utils/browser.ts +48 -0
  424. package/src/utils/fs.test.ts +119 -0
  425. package/src/utils/fs.ts +62 -0
  426. package/src/utils/pid.test.ts +152 -0
  427. package/src/utils/pid.ts +130 -0
  428. package/src/utils/process-scan.test.ts +53 -0
  429. package/src/utils/process-scan.ts +76 -0
  430. package/src/utils/time.test.ts +43 -0
  431. package/src/utils/time.ts +37 -0
  432. package/src/utils/version.test.ts +33 -0
  433. package/src/utils/version.ts +70 -0
  434. package/src/version.ts +5 -0
  435. package/src/watchdog/daemon.test.ts +3721 -0
  436. package/src/watchdog/daemon.ts +1257 -0
  437. package/src/watchdog/health.test.ts +830 -0
  438. package/src/watchdog/health.ts +434 -0
  439. package/src/watchdog/triage.test.ts +205 -0
  440. package/src/watchdog/triage.ts +205 -0
  441. package/src/worktree/manager.test.ts +720 -0
  442. package/src/worktree/manager.ts +405 -0
  443. package/src/worktree/process.test.ts +172 -0
  444. package/src/worktree/process.ts +131 -0
  445. package/src/worktree/tmux.test.ts +1616 -0
  446. package/src/worktree/tmux.ts +721 -0
  447. package/templates/CLAUDE.md.tmpl +100 -0
  448. package/templates/copilot-hooks.json.tmpl +13 -0
  449. package/templates/hooks.json.tmpl +109 -0
  450. package/templates/overlay.md.tmpl +88 -0
  451. package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
  452. package/ui/dist/chunk-8s31f05k.css +1 -0
  453. package/ui/dist/chunk-vm5rz679.js +300 -0
  454. package/ui/dist/favicon-nzb39vza.svg +4 -0
  455. package/ui/dist/index.html +17 -0
@@ -0,0 +1,1993 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { join } from "node:path";
12
+ import { MergeError } from "../errors.ts";
13
+ import type { LoamClient } from "../loam/client.ts";
14
+ import {
15
+ cleanupTempDir,
16
+ commitFile,
17
+ createTempGitRepo,
18
+ getDefaultBranch,
19
+ runGitInDir,
20
+ } from "../test-helpers.ts";
21
+ import type { MergeEntry, ParsedConflictPattern } from "../types.ts";
22
+ import {
23
+ buildConflictHistory,
24
+ createMergeResolver,
25
+ hasContentfulCanonical,
26
+ looksLikeProse,
27
+ parseConflictPatterns,
28
+ resolveConflictsUnion,
29
+ } from "./resolver.ts";
30
+
31
+ /**
32
+ * Helper to create a mock Bun.spawn return value for claude CLI mocking.
33
+ *
34
+ * The resolver reads stdout/stderr via `new Response(proc.stdout).text()`
35
+ * and `new Response(proc.stderr).text()`, so we need ReadableStreams.
36
+ */
37
+ function mockSpawnResult(
38
+ stdout: string,
39
+ stderr: string,
40
+ exitCode: number,
41
+ ): {
42
+ stdout: ReadableStream<Uint8Array>;
43
+ stderr: ReadableStream<Uint8Array>;
44
+ exited: Promise<number>;
45
+ pid: number;
46
+ } {
47
+ return {
48
+ stdout: new Response(stdout).body as ReadableStream<Uint8Array>,
49
+ stderr: new Response(stderr).body as ReadableStream<Uint8Array>,
50
+ exited: Promise.resolve(exitCode),
51
+ pid: 12345,
52
+ };
53
+ }
54
+
55
+ function makeTestEntry(overrides?: Partial<MergeEntry>): MergeEntry {
56
+ return {
57
+ branchName: overrides?.branchName ?? "feature-branch",
58
+ taskId: overrides?.taskId ?? "bead-123",
59
+ agentName: overrides?.agentName ?? "test-agent",
60
+ filesModified: overrides?.filesModified ?? ["src/test.ts"],
61
+ enqueuedAt: overrides?.enqueuedAt ?? new Date().toISOString(),
62
+ status: overrides?.status ?? "pending",
63
+ resolvedTier: overrides?.resolvedTier ?? null,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Set up a clean merge scenario: feature branch adds a new file with no conflict.
69
+ */
70
+ async function setupCleanMerge(dir: string, baseBranch: string): Promise<void> {
71
+ await commitFile(dir, "src/main-file.ts", "main content\n");
72
+ await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
73
+ await commitFile(dir, "src/feature-file.ts", "feature content\n");
74
+ await runGitInDir(dir, ["checkout", baseBranch]);
75
+ }
76
+
77
+ /**
78
+ * Set up a real content conflict: create a file, branch, modify on both
79
+ * branches. Both sides must diverge from the common ancestor to produce
80
+ * conflict markers.
81
+ */
82
+ async function setupContentConflict(dir: string, baseBranch: string): Promise<void> {
83
+ await commitFile(dir, "src/test.ts", "original content\n");
84
+ await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
85
+ await commitFile(dir, "src/test.ts", "feature content\n");
86
+ await runGitInDir(dir, ["checkout", baseBranch]);
87
+ await commitFile(dir, "src/test.ts", "main modified content\n");
88
+ }
89
+
90
+ /**
91
+ * Set up a conflict where the canonical (HEAD) side is empty in the conflict marker.
92
+ * Main deletes a shared line; feature replaces it. Git produces a conflict with an
93
+ * empty HEAD side, so hasContentfulCanonical returns false and auto-resolve can safely
94
+ * keep the incoming content.
95
+ */
96
+ async function setupEmptyCanonicalConflict(dir: string, baseBranch: string): Promise<void> {
97
+ await commitFile(dir, "src/test.ts", "line1\nshared line\nline3\n");
98
+ await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
99
+ await commitFile(dir, "src/test.ts", "line1\nnew content\nline3\n");
100
+ await runGitInDir(dir, ["checkout", baseBranch]);
101
+ await commitFile(dir, "src/test.ts", "line1\nline3\n"); // main deletes "shared line"
102
+ }
103
+
104
+ /**
105
+ * Create a delete/modify conflict: file is deleted on main but modified on
106
+ * the feature branch. This produces a conflict with NO conflict markers in
107
+ * the working copy, causing Tier 2 auto-resolve to fail (resolveConflictsKeepIncoming
108
+ * returns null). This naturally escalates to Tier 3 or 4.
109
+ */
110
+ async function setupDeleteModifyConflict(
111
+ dir: string,
112
+ baseBranch: string,
113
+ branchName = "feature-branch",
114
+ ): Promise<void> {
115
+ await commitFile(dir, "src/test.ts", "original content\n");
116
+ await runGitInDir(dir, ["checkout", "-b", branchName]);
117
+ await commitFile(dir, "src/test.ts", "modified by agent\n");
118
+ await runGitInDir(dir, ["checkout", baseBranch]);
119
+ await runGitInDir(dir, ["rm", "src/test.ts"]);
120
+ await runGitInDir(dir, ["commit", "-m", "delete src/test.ts"]);
121
+ }
122
+
123
+ /**
124
+ * Set up a scenario where Tier 2 auto-resolve fails but Tier 4 reimagine can
125
+ * succeed. We create a delete/modify conflict on one file (causes Tier 2 to fail)
126
+ * and set entry.filesModified to a different file that exists on both branches
127
+ * (so git show works for both in reimagine).
128
+ */
129
+ async function setupReimagineScenario(dir: string, baseBranch: string): Promise<void> {
130
+ await commitFile(dir, "src/conflict-file.ts", "original content\n");
131
+ await commitFile(dir, "src/reimagine-target.ts", "main version of target\n");
132
+ await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
133
+ await commitFile(dir, "src/conflict-file.ts", "modified by agent\n");
134
+ await commitFile(dir, "src/reimagine-target.ts", "feature version of target\n");
135
+ await runGitInDir(dir, ["checkout", baseBranch]);
136
+ await runGitInDir(dir, ["rm", "src/conflict-file.ts"]);
137
+ await runGitInDir(dir, ["commit", "-m", "delete conflict file"]);
138
+ }
139
+
140
+ /**
141
+ * Create a mock LoamClient for testing.
142
+ * Optionally override the record method to track calls or simulate failures.
143
+ */
144
+ function createMockLoamClient(
145
+ recordImpl?: (domain: string, options: unknown) => Promise<void>,
146
+ ): LoamClient {
147
+ return {
148
+ async prime() {
149
+ return "";
150
+ },
151
+ async status() {
152
+ return { domains: [] };
153
+ },
154
+ async record(domain: string, options: unknown) {
155
+ if (recordImpl) {
156
+ return recordImpl(domain, options);
157
+ }
158
+ },
159
+ async query() {
160
+ return "";
161
+ },
162
+ async search() {
163
+ return "";
164
+ },
165
+ async diff() {
166
+ return {
167
+ success: true,
168
+ command: "diff",
169
+ since: "HEAD",
170
+ domains: [],
171
+ message: "",
172
+ };
173
+ },
174
+ async learn() {
175
+ return {
176
+ success: true,
177
+ command: "learn",
178
+ changedFiles: [],
179
+ suggestedDomains: [],
180
+ unmatchedFiles: [],
181
+ };
182
+ },
183
+ async prune() {
184
+ return {
185
+ success: true,
186
+ command: "prune",
187
+ dryRun: false,
188
+ totalPruned: 0,
189
+ results: [],
190
+ };
191
+ },
192
+ async doctor() {
193
+ return {
194
+ success: true,
195
+ command: "doctor",
196
+ checks: [],
197
+ summary: {
198
+ pass: 0,
199
+ warn: 0,
200
+ fail: 0,
201
+ totalIssues: 0,
202
+ fixableIssues: 0,
203
+ },
204
+ };
205
+ },
206
+ async ready() {
207
+ return {
208
+ success: true,
209
+ command: "ready",
210
+ count: 0,
211
+ entries: [],
212
+ };
213
+ },
214
+ async compact() {
215
+ return {
216
+ success: true,
217
+ command: "compact",
218
+ action: "analyze",
219
+ };
220
+ },
221
+ async appendOutcome() {
222
+ // No-op stub: resolver tests don't exercise outcome appending
223
+ },
224
+ };
225
+ }
226
+
227
+ describe("createMergeResolver", () => {
228
+ describe("Tier 1: Clean merge", () => {
229
+ test("returns success with correct result shape and file content", async () => {
230
+ const repoDir = await createTempGitRepo();
231
+ try {
232
+ const defaultBranch = await getDefaultBranch(repoDir);
233
+ await setupCleanMerge(repoDir, defaultBranch);
234
+
235
+ const entry = makeTestEntry({
236
+ branchName: "feature-branch",
237
+ filesModified: ["src/feature-file.ts"],
238
+ });
239
+
240
+ const resolver = createMergeResolver({
241
+ aiResolveEnabled: false,
242
+ reimagineEnabled: false,
243
+ });
244
+
245
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
246
+
247
+ expect(result.success).toBe(true);
248
+ expect(result.tier).toBe("clean-merge");
249
+ expect(result.entry.status).toBe("merged");
250
+ expect(result.entry.resolvedTier).toBe("clean-merge");
251
+ expect(result.conflictFiles).toEqual([]);
252
+ expect(result.errorMessage).toBeNull();
253
+
254
+ // After merge, the feature file should exist on main
255
+ const file = Bun.file(join(repoDir, "src/feature-file.ts"));
256
+ const content = await file.text();
257
+ expect(content).toBe("feature content\n");
258
+ } finally {
259
+ await cleanupTempDir(repoDir);
260
+ }
261
+ });
262
+ });
263
+
264
+ describe("Tier 1: Checkout failure", () => {
265
+ // Both tests only attempt checkout of nonexistent branches -- no repo mutation.
266
+ let repoDir: string;
267
+
268
+ beforeAll(async () => {
269
+ repoDir = await createTempGitRepo();
270
+ });
271
+
272
+ afterAll(async () => {
273
+ await cleanupTempDir(repoDir);
274
+ });
275
+
276
+ test("throws MergeError if checkout fails", async () => {
277
+ const entry = makeTestEntry();
278
+
279
+ const resolver = createMergeResolver({
280
+ aiResolveEnabled: false,
281
+ reimagineEnabled: false,
282
+ });
283
+
284
+ await expect(resolver.resolve(entry, "nonexistent-branch", repoDir)).rejects.toThrow(
285
+ MergeError,
286
+ );
287
+ });
288
+
289
+ test("MergeError from checkout failure includes branch name", async () => {
290
+ const entry = makeTestEntry();
291
+
292
+ const resolver = createMergeResolver({
293
+ aiResolveEnabled: false,
294
+ reimagineEnabled: false,
295
+ });
296
+
297
+ try {
298
+ await resolver.resolve(entry, "develop", repoDir);
299
+ expect(true).toBe(false);
300
+ } catch (err: unknown) {
301
+ expect(err).toBeInstanceOf(MergeError);
302
+ const mergeErr = err as MergeError;
303
+ expect(mergeErr.message).toContain("develop");
304
+ }
305
+ });
306
+ });
307
+
308
+ describe("Dirty working tree pre-check", () => {
309
+ test("stashes unstaged changes and proceeds with clean merge", async () => {
310
+ const repoDir = await createTempGitRepo();
311
+ try {
312
+ const defaultBranch = await getDefaultBranch(repoDir);
313
+ // Create a tracked file and then leave it modified (unstaged)
314
+ await commitFile(repoDir, "src/main.ts", "original content\n");
315
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
316
+ await commitFile(repoDir, "src/feature.ts", "feature content\n");
317
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
318
+ // Modify a tracked file without staging — should be stashed, not rejected
319
+ await Bun.write(`${repoDir}/src/main.ts`, "modified content\n");
320
+
321
+ const entry = makeTestEntry({
322
+ branchName: "feature-branch",
323
+ filesModified: ["src/feature.ts"],
324
+ });
325
+
326
+ const resolver = createMergeResolver({
327
+ aiResolveEnabled: false,
328
+ reimagineEnabled: false,
329
+ });
330
+
331
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
332
+ expect(result.success).toBe(true);
333
+ expect(result.tier).toBe("clean-merge");
334
+ } finally {
335
+ await cleanupTempDir(repoDir);
336
+ }
337
+ });
338
+
339
+ test("dirty non-state files are stashed, merge succeeds, files restored after", async () => {
340
+ const repoDir = await createTempGitRepo();
341
+ try {
342
+ const defaultBranch = await getDefaultBranch(repoDir);
343
+ await commitFile(repoDir, "src/main.ts", "original content\n");
344
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
345
+ await commitFile(repoDir, "src/feature.ts", "feature content\n");
346
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
347
+ // Dirty file unrelated to the merge
348
+ await Bun.write(`${repoDir}/src/main.ts`, "in-progress work\n");
349
+
350
+ const entry = makeTestEntry({
351
+ branchName: "feature-branch",
352
+ filesModified: ["src/feature.ts"],
353
+ });
354
+
355
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
356
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
357
+
358
+ expect(result.success).toBe(true);
359
+ expect(result.tier).toBe("clean-merge");
360
+
361
+ // After merge, the stashed modification should be restored
362
+ const mainContent = await Bun.file(`${repoDir}/src/main.ts`).text();
363
+ expect(mainContent).toBe("in-progress work\n");
364
+ } finally {
365
+ await cleanupTempDir(repoDir);
366
+ }
367
+ });
368
+
369
+ test("stash pop happens after failed merge too", async () => {
370
+ const repoDir = await createTempGitRepo();
371
+ try {
372
+ const defaultBranch = await getDefaultBranch(repoDir);
373
+ // Set up a conflict that cannot be resolved (delete/modify)
374
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
375
+ // Also leave an unrelated file dirty
376
+ await Bun.write(`${repoDir}/src/other.ts`, "work in progress\n");
377
+ await runGitInDir(repoDir, ["add", "src/other.ts"]);
378
+ await runGitInDir(repoDir, ["commit", "-m", "add other.ts"]);
379
+ await Bun.write(`${repoDir}/src/other.ts`, "modified but not committed\n");
380
+
381
+ const entry = makeTestEntry({
382
+ branchName: "feature-branch",
383
+ filesModified: ["src/test.ts"],
384
+ });
385
+
386
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
387
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
388
+
389
+ expect(result.success).toBe(false);
390
+
391
+ // After failed merge, the stashed modification should be restored
392
+ const otherContent = await Bun.file(`${repoDir}/src/other.ts`).text();
393
+ expect(otherContent).toBe("modified but not committed\n");
394
+ } finally {
395
+ await cleanupTempDir(repoDir);
396
+ }
397
+ });
398
+
399
+ test("staged but uncommitted changes are stashed and merge proceeds", async () => {
400
+ const repoDir = await createTempGitRepo();
401
+ try {
402
+ const defaultBranch = await getDefaultBranch(repoDir);
403
+ await commitFile(repoDir, "src/main.ts", "original content\n");
404
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
405
+ await commitFile(repoDir, "src/feature.ts", "feature content\n");
406
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
407
+ // Modify and stage (but don't commit) — should be stashed, not rejected
408
+ await Bun.write(`${repoDir}/src/main.ts`, "staged but not committed\n");
409
+ await runGitInDir(repoDir, ["add", "src/main.ts"]);
410
+
411
+ const entry = makeTestEntry({
412
+ branchName: "feature-branch",
413
+ filesModified: ["src/feature.ts"],
414
+ });
415
+
416
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
417
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
418
+
419
+ expect(result.success).toBe(true);
420
+ expect(result.tier).toBe("clean-merge");
421
+ } finally {
422
+ await cleanupTempDir(repoDir);
423
+ }
424
+ });
425
+
426
+ test("clean working tree proceeds normally to Tier 1", async () => {
427
+ const repoDir = await createTempGitRepo();
428
+ try {
429
+ const defaultBranch = await getDefaultBranch(repoDir);
430
+ await setupCleanMerge(repoDir, defaultBranch);
431
+
432
+ const entry = makeTestEntry({
433
+ branchName: "feature-branch",
434
+ filesModified: ["src/feature-file.ts"],
435
+ });
436
+
437
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
438
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
439
+
440
+ expect(result.success).toBe(true);
441
+ expect(result.tier).toBe("clean-merge");
442
+ } finally {
443
+ await cleanupTempDir(repoDir);
444
+ }
445
+ });
446
+ });
447
+
448
+ describe("Tier 1 fail -> Tier 2: Auto-resolve", () => {
449
+ test("auto-resolves conflicts when canonical side is empty (keeps incoming)", async () => {
450
+ const repoDir = await createTempGitRepo();
451
+ try {
452
+ const defaultBranch = await getDefaultBranch(repoDir);
453
+ // Use empty-canonical setup: main deletes a line, feature replaces it.
454
+ // The conflict marker has an empty HEAD side, so auto-resolve is safe.
455
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
456
+
457
+ const entry = makeTestEntry({
458
+ branchName: "feature-branch",
459
+ filesModified: ["src/test.ts"],
460
+ });
461
+
462
+ const resolver = createMergeResolver({
463
+ aiResolveEnabled: false,
464
+ reimagineEnabled: false,
465
+ });
466
+
467
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
468
+
469
+ expect(result.success).toBe(true);
470
+ expect(result.tier).toBe("auto-resolve");
471
+ expect(result.entry.status).toBe("merged");
472
+ expect(result.entry.resolvedTier).toBe("auto-resolve");
473
+ expect(result.warnings).toEqual([]);
474
+
475
+ // The resolved file should contain the incoming (feature branch) content
476
+ const file = Bun.file(join(repoDir, "src/test.ts"));
477
+ const content = await file.text();
478
+ expect(content).toContain("new content");
479
+ } finally {
480
+ await cleanupTempDir(repoDir);
481
+ }
482
+ });
483
+ });
484
+
485
+ describe("Tier 3: AI-resolve", () => {
486
+ // After the first test (aiResolve=false), the resolver aborts the merge and
487
+ // leaves the repo clean. The second test can retry the merge on the same repo.
488
+ let repoDir: string;
489
+ let defaultBranch: string;
490
+
491
+ beforeAll(async () => {
492
+ repoDir = await createTempGitRepo();
493
+ defaultBranch = await getDefaultBranch(repoDir);
494
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
495
+ });
496
+
497
+ afterAll(async () => {
498
+ await cleanupTempDir(repoDir);
499
+ });
500
+
501
+ // This test MUST run first -- it fails to merge and aborts, leaving repo clean
502
+ test("is skipped when aiResolveEnabled is false", async () => {
503
+ const entry = makeTestEntry({
504
+ branchName: "feature-branch",
505
+ filesModified: ["src/test.ts"],
506
+ });
507
+
508
+ const resolver = createMergeResolver({
509
+ aiResolveEnabled: false,
510
+ reimagineEnabled: false,
511
+ });
512
+
513
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
514
+
515
+ expect(result.success).toBe(false);
516
+ expect(result.entry.status).toBe("failed");
517
+ });
518
+
519
+ // This test runs second -- repo is clean from the abort, same conflict is available
520
+ test("invokes claude when aiResolveEnabled is true and tier 2 fails", async () => {
521
+ // Selective spy: mock only claude, let git commands through.
522
+ const originalSpawn = Bun.spawn;
523
+ let claudeCalled = false;
524
+
525
+ const selectiveMock = (...args: unknown[]): unknown => {
526
+ const cmd = args[0] as string[];
527
+ if (cmd?.[0] === "claude") {
528
+ claudeCalled = true;
529
+ return mockSpawnResult("resolved content from AI\n", "", 0);
530
+ }
531
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
532
+ };
533
+
534
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
535
+
536
+ try {
537
+ const entry = makeTestEntry({
538
+ branchName: "feature-branch",
539
+ filesModified: ["src/test.ts"],
540
+ });
541
+
542
+ const resolver = createMergeResolver({
543
+ aiResolveEnabled: true,
544
+ reimagineEnabled: false,
545
+ });
546
+
547
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
548
+
549
+ expect(claudeCalled).toBe(true);
550
+ expect(result.success).toBe(true);
551
+ expect(result.tier).toBe("ai-resolve");
552
+ expect(result.entry.status).toBe("merged");
553
+ expect(result.entry.resolvedTier).toBe("ai-resolve");
554
+ } finally {
555
+ spawnSpy.mockRestore();
556
+ }
557
+ });
558
+ });
559
+
560
+ describe("Tier 4: Re-imagine", () => {
561
+ // After the first test (reimagine=false), the resolver aborts the merge and
562
+ // leaves the repo clean. The second test can retry the merge on the same repo.
563
+ let repoDir: string;
564
+ let defaultBranch: string;
565
+
566
+ beforeAll(async () => {
567
+ repoDir = await createTempGitRepo();
568
+ defaultBranch = await getDefaultBranch(repoDir);
569
+ await setupReimagineScenario(repoDir, defaultBranch);
570
+ });
571
+
572
+ afterAll(async () => {
573
+ await cleanupTempDir(repoDir);
574
+ });
575
+
576
+ // This test MUST run first -- it fails to merge and aborts, leaving repo clean
577
+ test("is skipped when reimagineEnabled is false", async () => {
578
+ const entry = makeTestEntry({
579
+ branchName: "feature-branch",
580
+ filesModified: ["src/reimagine-target.ts"],
581
+ });
582
+
583
+ const resolver = createMergeResolver({
584
+ aiResolveEnabled: false,
585
+ reimagineEnabled: false,
586
+ });
587
+
588
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
589
+
590
+ expect(result.success).toBe(false);
591
+ expect(result.entry.status).toBe("failed");
592
+ });
593
+
594
+ // This test runs second -- repo is clean from the abort, same conflict is available
595
+ test("aborts merge and reimplements when reimagineEnabled is true", async () => {
596
+ // Selective spy: mock only claude, let git commands through.
597
+ const originalSpawn = Bun.spawn;
598
+ let claudeCalled = false;
599
+
600
+ const selectiveMock = (...args: unknown[]): unknown => {
601
+ const cmd = args[0] as string[];
602
+ if (cmd?.[0] === "claude") {
603
+ claudeCalled = true;
604
+ return mockSpawnResult("reimagined content\n", "", 0);
605
+ }
606
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
607
+ };
608
+
609
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
610
+
611
+ try {
612
+ const entry = makeTestEntry({
613
+ branchName: "feature-branch",
614
+ filesModified: ["src/reimagine-target.ts"],
615
+ });
616
+
617
+ const resolver = createMergeResolver({
618
+ aiResolveEnabled: false,
619
+ reimagineEnabled: true,
620
+ });
621
+
622
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
623
+
624
+ expect(claudeCalled).toBe(true);
625
+ expect(result.success).toBe(true);
626
+ expect(result.tier).toBe("reimagine");
627
+ expect(result.entry.status).toBe("merged");
628
+ expect(result.entry.resolvedTier).toBe("reimagine");
629
+
630
+ // Verify the reimagined content was written
631
+ const file = Bun.file(join(repoDir, "src/reimagine-target.ts"));
632
+ const content = await file.text();
633
+ expect(content).toBe("reimagined content\n");
634
+ } finally {
635
+ spawnSpy.mockRestore();
636
+ }
637
+ });
638
+ });
639
+
640
+ describe("All tiers fail", () => {
641
+ test("returns failed status and repo is clean when all tiers fail", async () => {
642
+ const repoDir = await createTempGitRepo();
643
+ try {
644
+ const defaultBranch = await getDefaultBranch(repoDir);
645
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
646
+
647
+ const entry = makeTestEntry({
648
+ branchName: "feature-branch",
649
+ filesModified: ["src/test.ts"],
650
+ });
651
+
652
+ const resolver = createMergeResolver({
653
+ aiResolveEnabled: false,
654
+ reimagineEnabled: false,
655
+ });
656
+
657
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
658
+
659
+ expect(result.success).toBe(false);
660
+ expect(result.entry.status).toBe("failed");
661
+ expect(result.entry.resolvedTier).toBeNull();
662
+ expect(result.errorMessage).not.toBeNull();
663
+ expect(result.errorMessage).toContain("failed");
664
+
665
+ // Verify the repo is in a clean state (merge was aborted)
666
+ const status = await runGitInDir(repoDir, ["status", "--porcelain"]);
667
+ expect(status.trim()).toBe("");
668
+ } finally {
669
+ await cleanupTempDir(repoDir);
670
+ }
671
+ });
672
+ });
673
+
674
+ describe("result shape", () => {
675
+ let repoDir: string;
676
+ let defaultBranch: string;
677
+
678
+ beforeEach(async () => {
679
+ repoDir = await createTempGitRepo();
680
+ defaultBranch = await getDefaultBranch(repoDir);
681
+ });
682
+
683
+ afterEach(async () => {
684
+ await cleanupTempDir(repoDir);
685
+ });
686
+
687
+ test("successful result has correct MergeResult shape", async () => {
688
+ await setupCleanMerge(repoDir, defaultBranch);
689
+
690
+ const resolver = createMergeResolver({
691
+ aiResolveEnabled: false,
692
+ reimagineEnabled: false,
693
+ });
694
+
695
+ const result = await resolver.resolve(
696
+ makeTestEntry({
697
+ branchName: "feature-branch",
698
+ filesModified: ["src/feature-file.ts"],
699
+ }),
700
+ defaultBranch,
701
+ repoDir,
702
+ );
703
+
704
+ expect(result).toHaveProperty("entry");
705
+ expect(result).toHaveProperty("success");
706
+ expect(result).toHaveProperty("tier");
707
+ expect(result).toHaveProperty("conflictFiles");
708
+ expect(result).toHaveProperty("errorMessage");
709
+ expect(result).toHaveProperty("warnings");
710
+ });
711
+
712
+ test("failed result preserves original entry fields", async () => {
713
+ await setupDeleteModifyConflict(repoDir, defaultBranch, "agentplate/my-agent/bead-xyz");
714
+
715
+ const entry = makeTestEntry({
716
+ branchName: "agentplate/my-agent/bead-xyz",
717
+ taskId: "bead-xyz",
718
+ agentName: "my-agent",
719
+ filesModified: ["src/test.ts"],
720
+ });
721
+
722
+ const resolver = createMergeResolver({
723
+ aiResolveEnabled: false,
724
+ reimagineEnabled: false,
725
+ });
726
+
727
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
728
+
729
+ expect(result.entry.branchName).toBe("agentplate/my-agent/bead-xyz");
730
+ expect(result.entry.taskId).toBe("bead-xyz");
731
+ expect(result.entry.agentName).toBe("my-agent");
732
+ });
733
+ });
734
+
735
+ describe("checkout skip when already on canonical branch", () => {
736
+ test("succeeds when already on canonical branch (skips checkout)", async () => {
737
+ const repoDir = await createTempGitRepo();
738
+ try {
739
+ const defaultBranch = await getDefaultBranch(repoDir);
740
+ await setupCleanMerge(repoDir, defaultBranch);
741
+
742
+ // Verify we're on the default branch
743
+ const branch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
744
+ expect(branch.trim()).toBe(defaultBranch);
745
+
746
+ const entry = makeTestEntry({
747
+ branchName: "feature-branch",
748
+ filesModified: ["src/feature-file.ts"],
749
+ });
750
+
751
+ const resolver = createMergeResolver({
752
+ aiResolveEnabled: false,
753
+ reimagineEnabled: false,
754
+ });
755
+
756
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
757
+ expect(result.success).toBe(true);
758
+ expect(result.tier).toBe("clean-merge");
759
+ } finally {
760
+ await cleanupTempDir(repoDir);
761
+ }
762
+ });
763
+
764
+ test("checks out canonical when on a different branch", async () => {
765
+ const repoDir = await createTempGitRepo();
766
+ try {
767
+ const defaultBranch = await getDefaultBranch(repoDir);
768
+ await setupCleanMerge(repoDir, defaultBranch);
769
+
770
+ // Switch to a different branch
771
+ await runGitInDir(repoDir, ["checkout", "-b", "some-other-branch"]);
772
+ const branch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
773
+ expect(branch.trim()).toBe("some-other-branch");
774
+
775
+ const entry = makeTestEntry({
776
+ branchName: "feature-branch",
777
+ filesModified: ["src/feature-file.ts"],
778
+ });
779
+
780
+ const resolver = createMergeResolver({
781
+ aiResolveEnabled: false,
782
+ reimagineEnabled: false,
783
+ });
784
+
785
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
786
+ expect(result.success).toBe(true);
787
+ expect(result.tier).toBe("clean-merge");
788
+ } finally {
789
+ await cleanupTempDir(repoDir);
790
+ }
791
+ });
792
+ });
793
+
794
+ describe("looksLikeProse", () => {
795
+ test("detects conversational prose", () => {
796
+ expect(looksLikeProse("I need permission to edit the file")).toBe(true);
797
+ expect(looksLikeProse("Here's the resolved content:")).toBe(true);
798
+ expect(looksLikeProse("Here is the file")).toBe(true);
799
+ expect(looksLikeProse("The conflict can be resolved by")).toBe(true);
800
+ expect(looksLikeProse("Let me resolve this for you")).toBe(true);
801
+ expect(looksLikeProse("Sure, here's the resolved file")).toBe(true);
802
+ expect(looksLikeProse("I cannot access the file")).toBe(true);
803
+ expect(looksLikeProse("I don't have access")).toBe(true);
804
+ expect(looksLikeProse("To resolve this, we need to")).toBe(true);
805
+ expect(looksLikeProse("Looking at the conflict")).toBe(true);
806
+ expect(looksLikeProse("Based on both versions")).toBe(true);
807
+ });
808
+
809
+ test("detects markdown fencing", () => {
810
+ expect(looksLikeProse("```typescript\nconst x = 1;\n```")).toBe(true);
811
+ expect(looksLikeProse("```\nsome code\n```")).toBe(true);
812
+ });
813
+
814
+ test("detects empty output", () => {
815
+ expect(looksLikeProse("")).toBe(true);
816
+ expect(looksLikeProse(" ")).toBe(true);
817
+ });
818
+
819
+ test("accepts valid code", () => {
820
+ expect(looksLikeProse("const x = 1;")).toBe(false);
821
+ expect(looksLikeProse("import { foo } from 'bar';")).toBe(false);
822
+ expect(looksLikeProse("export function resolve() {}")).toBe(false);
823
+ expect(looksLikeProse("function hello() {\n return 'world';\n}")).toBe(false);
824
+ expect(looksLikeProse("// comment\nconst a = 1;")).toBe(false);
825
+ });
826
+ });
827
+
828
+ describe("hasContentfulCanonical", () => {
829
+ test("returns true when canonical side has content", () => {
830
+ const content = [
831
+ "<<<<<<< HEAD\n",
832
+ "canonical content\n",
833
+ "=======\n",
834
+ "incoming content\n",
835
+ ">>>>>>> feature-branch\n",
836
+ ].join("");
837
+ expect(hasContentfulCanonical(content)).toBe(true);
838
+ });
839
+
840
+ test("returns false when canonical side is empty", () => {
841
+ const content = [
842
+ "<<<<<<< HEAD\n",
843
+ "=======\n",
844
+ "incoming content\n",
845
+ ">>>>>>> feature-branch\n",
846
+ ].join("");
847
+ expect(hasContentfulCanonical(content)).toBe(false);
848
+ });
849
+
850
+ test("returns false when canonical is whitespace only", () => {
851
+ const content = [
852
+ "<<<<<<< HEAD\n",
853
+ " \n",
854
+ "\t\n",
855
+ "=======\n",
856
+ "incoming content\n",
857
+ ">>>>>>> feature-branch\n",
858
+ ].join("");
859
+ expect(hasContentfulCanonical(content)).toBe(false);
860
+ });
861
+
862
+ test("returns false when no conflict markers", () => {
863
+ expect(hasContentfulCanonical("no conflicts here\n")).toBe(false);
864
+ expect(hasContentfulCanonical("")).toBe(false);
865
+ });
866
+
867
+ test("returns true if ANY block has canonical content (multiple blocks)", () => {
868
+ const block1 = "<<<<<<< HEAD\n=======\nonly incoming\n>>>>>>> branch\n";
869
+ const block2 = "<<<<<<< HEAD\ncanonical content\n=======\nincoming\n>>>>>>> branch\n";
870
+ const content = `${block1}middle\n${block2}`;
871
+ expect(hasContentfulCanonical(content)).toBe(true);
872
+ });
873
+ });
874
+
875
+ describe("auto-resolve: content protection", () => {
876
+ test("auto-resolve skips files with contentful canonical, result includes warning", async () => {
877
+ const repoDir = await createTempGitRepo();
878
+ try {
879
+ const defaultBranch = await getDefaultBranch(repoDir);
880
+ // setupContentConflict: both canonical and incoming have content
881
+ await setupContentConflict(repoDir, defaultBranch);
882
+
883
+ const entry = makeTestEntry({
884
+ branchName: "feature-branch",
885
+ filesModified: ["src/test.ts"],
886
+ });
887
+
888
+ // AI and reimagine disabled — should FAIL because auto-resolve correctly refuses
889
+ const resolver = createMergeResolver({
890
+ aiResolveEnabled: false,
891
+ reimagineEnabled: false,
892
+ });
893
+
894
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
895
+
896
+ expect(result.success).toBe(false);
897
+ expect(result.warnings.length).toBeGreaterThan(0);
898
+ expect(result.warnings[0]).toContain("src/test.ts");
899
+ } finally {
900
+ await cleanupTempDir(repoDir);
901
+ }
902
+ });
903
+ });
904
+
905
+ describe("untracked files: no silent commit", () => {
906
+ test("untracked overlapping files are deleted, not committed", async () => {
907
+ const repoDir = await createTempGitRepo();
908
+ try {
909
+ const defaultBranch = await getDefaultBranch(repoDir);
910
+ await setupCleanMerge(repoDir, defaultBranch);
911
+
912
+ // Place an untracked file at the path the feature branch will bring in
913
+ await Bun.write(`${repoDir}/src/feature-file.ts`, "local untracked content\n");
914
+
915
+ const entry = makeTestEntry({
916
+ branchName: "feature-branch",
917
+ filesModified: ["src/feature-file.ts"],
918
+ });
919
+
920
+ const resolver = createMergeResolver({
921
+ aiResolveEnabled: false,
922
+ reimagineEnabled: false,
923
+ });
924
+
925
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
926
+
927
+ expect(result.success).toBe(true);
928
+ expect(result.warnings.some((w) => w.includes("src/feature-file.ts"))).toBe(true);
929
+
930
+ // Verify git log does NOT contain the "commit untracked files before merge" commit
931
+ const log = await runGitInDir(repoDir, ["log", "--oneline"]);
932
+ expect(log).not.toContain("commit untracked files before merge");
933
+ } finally {
934
+ await cleanupTempDir(repoDir);
935
+ }
936
+ });
937
+ });
938
+
939
+ describe("Tier 3: AI-resolve prose rejection", () => {
940
+ test("rejects prose output and falls through to failure", async () => {
941
+ const repoDir = await createTempGitRepo();
942
+ try {
943
+ const defaultBranch = await getDefaultBranch(repoDir);
944
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
945
+
946
+ const originalSpawn = Bun.spawn;
947
+ const selectiveMock = (...args: unknown[]): unknown => {
948
+ const cmd = args[0] as string[];
949
+ if (cmd?.[0] === "claude") {
950
+ // Return prose instead of code
951
+ return mockSpawnResult(
952
+ "I need permission to edit the file. Here's the resolved content:\n```\nresolved\n```",
953
+ "",
954
+ 0,
955
+ );
956
+ }
957
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
958
+ };
959
+
960
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
961
+
962
+ try {
963
+ const entry = makeTestEntry({
964
+ branchName: "feature-branch",
965
+ filesModified: ["src/test.ts"],
966
+ });
967
+
968
+ const resolver = createMergeResolver({
969
+ aiResolveEnabled: true,
970
+ reimagineEnabled: false,
971
+ });
972
+
973
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
974
+
975
+ // Should fail because prose was rejected
976
+ expect(result.success).toBe(false);
977
+ } finally {
978
+ spawnSpy.mockRestore();
979
+ }
980
+ } finally {
981
+ await cleanupTempDir(repoDir);
982
+ }
983
+ });
984
+ });
985
+
986
+ describe("Conflict pattern recording", () => {
987
+ test("no recording when loamClient is not provided (backward compatible)", async () => {
988
+ const repoDir = await createTempGitRepo();
989
+ try {
990
+ const defaultBranch = await getDefaultBranch(repoDir);
991
+ // Use empty-canonical setup so auto-resolve completes successfully
992
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
993
+
994
+ const entry = makeTestEntry({
995
+ branchName: "feature-branch",
996
+ filesModified: ["src/test.ts"],
997
+ });
998
+
999
+ // No loamClient passed — should work as before
1000
+ const resolver = createMergeResolver({
1001
+ aiResolveEnabled: false,
1002
+ reimagineEnabled: false,
1003
+ });
1004
+
1005
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1006
+
1007
+ expect(result.success).toBe(true);
1008
+ expect(result.tier).toBe("auto-resolve");
1009
+ } finally {
1010
+ await cleanupTempDir(repoDir);
1011
+ }
1012
+ });
1013
+
1014
+ test("records pattern on tier 2 auto-resolve success", async () => {
1015
+ const repoDir = await createTempGitRepo();
1016
+ try {
1017
+ const defaultBranch = await getDefaultBranch(repoDir);
1018
+ // Use empty-canonical setup so auto-resolve completes successfully
1019
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
1020
+
1021
+ const entry = makeTestEntry({
1022
+ branchName: "feature-branch",
1023
+ taskId: "bead-abc-123",
1024
+ agentName: "test-builder",
1025
+ filesModified: ["src/test.ts"],
1026
+ });
1027
+
1028
+ // Create a mock LoamClient with a spy on record
1029
+ const recordCalls: Array<{
1030
+ domain: string;
1031
+ options: {
1032
+ type: string;
1033
+ description?: string;
1034
+ tags?: string[];
1035
+ evidenceBead?: string;
1036
+ };
1037
+ }> = [];
1038
+
1039
+ const mockLoamClient = createMockLoamClient(async (domain, options) => {
1040
+ recordCalls.push({
1041
+ domain,
1042
+ options: options as {
1043
+ type: string;
1044
+ description?: string;
1045
+ tags?: string[];
1046
+ evidenceBead?: string;
1047
+ },
1048
+ });
1049
+ });
1050
+
1051
+ const resolver = createMergeResolver({
1052
+ aiResolveEnabled: false,
1053
+ reimagineEnabled: false,
1054
+ loamClient: mockLoamClient,
1055
+ });
1056
+
1057
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1058
+
1059
+ expect(result.success).toBe(true);
1060
+ expect(result.tier).toBe("auto-resolve");
1061
+
1062
+ // Verify record was called
1063
+ expect(recordCalls.length).toBe(1);
1064
+ const call = recordCalls[0];
1065
+ expect(call?.domain).toBe("architecture");
1066
+ expect(call?.options.type).toBe("pattern");
1067
+ expect(call?.options.tags).toContain("merge-conflict");
1068
+ expect(call?.options.evidenceBead).toBe("bead-abc-123");
1069
+
1070
+ // Verify description contains key details
1071
+ const desc = call?.options.description ?? "";
1072
+ expect(desc).toContain("resolved");
1073
+ expect(desc).toContain("auto-resolve");
1074
+ expect(desc).toContain("feature-branch");
1075
+ expect(desc).toContain("test-builder");
1076
+ expect(desc).toContain("src/test.ts");
1077
+ } finally {
1078
+ await cleanupTempDir(repoDir);
1079
+ }
1080
+ });
1081
+
1082
+ test("records pattern on total failure", async () => {
1083
+ const repoDir = await createTempGitRepo();
1084
+ try {
1085
+ const defaultBranch = await getDefaultBranch(repoDir);
1086
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
1087
+
1088
+ const entry = makeTestEntry({
1089
+ branchName: "feature-branch",
1090
+ taskId: "bead-fail-456",
1091
+ agentName: "test-agent",
1092
+ filesModified: ["src/test.ts"],
1093
+ });
1094
+
1095
+ const recordCalls: Array<{
1096
+ domain: string;
1097
+ options: {
1098
+ type: string;
1099
+ description?: string;
1100
+ tags?: string[];
1101
+ evidenceBead?: string;
1102
+ };
1103
+ }> = [];
1104
+
1105
+ const mockLoamClient = createMockLoamClient(async (domain, options) => {
1106
+ recordCalls.push({
1107
+ domain,
1108
+ options: options as {
1109
+ type: string;
1110
+ description?: string;
1111
+ tags?: string[];
1112
+ evidenceBead?: string;
1113
+ },
1114
+ });
1115
+ });
1116
+
1117
+ // AI and reimagine disabled — will fail at tier 2
1118
+ const resolver = createMergeResolver({
1119
+ aiResolveEnabled: false,
1120
+ reimagineEnabled: false,
1121
+ loamClient: mockLoamClient,
1122
+ });
1123
+
1124
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1125
+
1126
+ expect(result.success).toBe(false);
1127
+
1128
+ // Verify record was called for failure
1129
+ expect(recordCalls.length).toBe(1);
1130
+ const call = recordCalls[0];
1131
+ expect(call?.domain).toBe("architecture");
1132
+ expect(call?.options.type).toBe("pattern");
1133
+ expect(call?.options.evidenceBead).toBe("bead-fail-456");
1134
+
1135
+ // Verify description contains "failed" not "resolved"
1136
+ const desc = call?.options.description ?? "";
1137
+ expect(desc).toContain("failed");
1138
+ expect(desc).not.toContain("resolved");
1139
+ expect(desc).toContain("auto-resolve"); // last attempted tier
1140
+ } finally {
1141
+ await cleanupTempDir(repoDir);
1142
+ }
1143
+ });
1144
+
1145
+ test("recording failure does not affect merge result (fire-and-forget)", async () => {
1146
+ const repoDir = await createTempGitRepo();
1147
+ try {
1148
+ const defaultBranch = await getDefaultBranch(repoDir);
1149
+ // Use empty-canonical setup so auto-resolve completes successfully
1150
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
1151
+
1152
+ const entry = makeTestEntry({
1153
+ branchName: "feature-branch",
1154
+ filesModified: ["src/test.ts"],
1155
+ });
1156
+
1157
+ // Mock loamClient whose record rejects
1158
+ const mockLoamClient = createMockLoamClient(async () => {
1159
+ throw new Error("Loam recording failed!");
1160
+ });
1161
+
1162
+ const resolver = createMergeResolver({
1163
+ aiResolveEnabled: false,
1164
+ reimagineEnabled: false,
1165
+ loamClient: mockLoamClient,
1166
+ });
1167
+
1168
+ // Should still succeed despite recording failure (fire-and-forget)
1169
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1170
+
1171
+ expect(result.success).toBe(true);
1172
+ expect(result.tier).toBe("auto-resolve");
1173
+ } finally {
1174
+ await cleanupTempDir(repoDir);
1175
+ }
1176
+ });
1177
+
1178
+ test("records pattern on tier 3 ai-resolve success", async () => {
1179
+ const repoDir = await createTempGitRepo();
1180
+ try {
1181
+ const defaultBranch = await getDefaultBranch(repoDir);
1182
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
1183
+
1184
+ const entry = makeTestEntry({
1185
+ branchName: "feature-branch",
1186
+ taskId: "bead-ai-789",
1187
+ filesModified: ["src/test.ts"],
1188
+ });
1189
+
1190
+ const recordCalls: Array<{
1191
+ domain: string;
1192
+ options: {
1193
+ type: string;
1194
+ description?: string;
1195
+ tags?: string[];
1196
+ evidenceBead?: string;
1197
+ };
1198
+ }> = [];
1199
+
1200
+ const mockLoamClient = createMockLoamClient(async (domain, options) => {
1201
+ recordCalls.push({
1202
+ domain,
1203
+ options: options as {
1204
+ type: string;
1205
+ description?: string;
1206
+ tags?: string[];
1207
+ evidenceBead?: string;
1208
+ },
1209
+ });
1210
+ });
1211
+
1212
+ // Mock claude to succeed
1213
+ const originalSpawn = Bun.spawn;
1214
+ const selectiveMock = (...args: unknown[]): unknown => {
1215
+ const cmd = args[0] as string[];
1216
+ if (cmd?.[0] === "claude") {
1217
+ return mockSpawnResult("resolved content from AI\n", "", 0);
1218
+ }
1219
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
1220
+ };
1221
+
1222
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
1223
+
1224
+ try {
1225
+ const resolver = createMergeResolver({
1226
+ aiResolveEnabled: true,
1227
+ reimagineEnabled: false,
1228
+ loamClient: mockLoamClient,
1229
+ });
1230
+
1231
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1232
+
1233
+ expect(result.success).toBe(true);
1234
+ expect(result.tier).toBe("ai-resolve");
1235
+
1236
+ // Verify record was called
1237
+ expect(recordCalls.length).toBe(1);
1238
+ const call = recordCalls[0];
1239
+ expect(call?.domain).toBe("architecture");
1240
+ expect(call?.options.evidenceBead).toBe("bead-ai-789");
1241
+
1242
+ const desc = call?.options.description ?? "";
1243
+ expect(desc).toContain("resolved");
1244
+ expect(desc).toContain("ai-resolve");
1245
+ } finally {
1246
+ spawnSpy.mockRestore();
1247
+ }
1248
+ } finally {
1249
+ await cleanupTempDir(repoDir);
1250
+ }
1251
+ });
1252
+
1253
+ test("records pattern on tier 4 reimagine success", async () => {
1254
+ const repoDir = await createTempGitRepo();
1255
+ try {
1256
+ const defaultBranch = await getDefaultBranch(repoDir);
1257
+ await setupReimagineScenario(repoDir, defaultBranch);
1258
+
1259
+ const entry = makeTestEntry({
1260
+ branchName: "feature-branch",
1261
+ taskId: "bead-reimagine-xyz",
1262
+ filesModified: ["src/reimagine-target.ts"],
1263
+ });
1264
+
1265
+ const recordCalls: Array<{
1266
+ domain: string;
1267
+ options: {
1268
+ type: string;
1269
+ description?: string;
1270
+ tags?: string[];
1271
+ evidenceBead?: string;
1272
+ };
1273
+ }> = [];
1274
+
1275
+ const mockLoamClient = createMockLoamClient(async (domain, options) => {
1276
+ recordCalls.push({
1277
+ domain,
1278
+ options: options as {
1279
+ type: string;
1280
+ description?: string;
1281
+ tags?: string[];
1282
+ evidenceBead?: string;
1283
+ },
1284
+ });
1285
+ });
1286
+
1287
+ // Mock claude to succeed
1288
+ const originalSpawn = Bun.spawn;
1289
+ const selectiveMock = (...args: unknown[]): unknown => {
1290
+ const cmd = args[0] as string[];
1291
+ if (cmd?.[0] === "claude") {
1292
+ return mockSpawnResult("reimagined content\n", "", 0);
1293
+ }
1294
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
1295
+ };
1296
+
1297
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
1298
+
1299
+ try {
1300
+ const resolver = createMergeResolver({
1301
+ aiResolveEnabled: false,
1302
+ reimagineEnabled: true,
1303
+ loamClient: mockLoamClient,
1304
+ });
1305
+
1306
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1307
+
1308
+ expect(result.success).toBe(true);
1309
+ expect(result.tier).toBe("reimagine");
1310
+
1311
+ // Verify record was called
1312
+ expect(recordCalls.length).toBe(1);
1313
+ const call = recordCalls[0];
1314
+ expect(call?.domain).toBe("architecture");
1315
+ expect(call?.options.evidenceBead).toBe("bead-reimagine-xyz");
1316
+
1317
+ const desc = call?.options.description ?? "";
1318
+ expect(desc).toContain("resolved");
1319
+ expect(desc).toContain("reimagine");
1320
+ } finally {
1321
+ spawnSpy.mockRestore();
1322
+ }
1323
+ } finally {
1324
+ await cleanupTempDir(repoDir);
1325
+ }
1326
+ });
1327
+
1328
+ test("no recording on tier 1 clean merge (no conflict)", async () => {
1329
+ const repoDir = await createTempGitRepo();
1330
+ try {
1331
+ const defaultBranch = await getDefaultBranch(repoDir);
1332
+ await setupCleanMerge(repoDir, defaultBranch);
1333
+
1334
+ const entry = makeTestEntry({
1335
+ branchName: "feature-branch",
1336
+ filesModified: ["src/feature-file.ts"],
1337
+ });
1338
+
1339
+ const recordCalls: Array<unknown> = [];
1340
+
1341
+ const mockLoamClient = createMockLoamClient(async (domain, options) => {
1342
+ recordCalls.push({ domain, options });
1343
+ });
1344
+
1345
+ const resolver = createMergeResolver({
1346
+ aiResolveEnabled: false,
1347
+ reimagineEnabled: false,
1348
+ loamClient: mockLoamClient,
1349
+ });
1350
+
1351
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1352
+
1353
+ expect(result.success).toBe(true);
1354
+ expect(result.tier).toBe("clean-merge");
1355
+
1356
+ // No recording on clean merge (no conflict occurred)
1357
+ expect(recordCalls.length).toBe(0);
1358
+ } finally {
1359
+ await cleanupTempDir(repoDir);
1360
+ }
1361
+ });
1362
+ });
1363
+
1364
+ describe("parseConflictPatterns", () => {
1365
+ test("parses successful resolution pattern", () => {
1366
+ const input =
1367
+ "Merge conflict resolved at tier auto-resolve. Branch: feature-branch. Agent: test-builder. Conflicting files: src/test.ts.";
1368
+ const patterns = parseConflictPatterns(input);
1369
+ expect(patterns.length).toBe(1);
1370
+ expect(patterns[0]?.tier).toBe("auto-resolve");
1371
+ expect(patterns[0]?.success).toBe(true);
1372
+ expect(patterns[0]?.files).toEqual(["src/test.ts"]);
1373
+ expect(patterns[0]?.agent).toBe("test-builder");
1374
+ expect(patterns[0]?.branch).toBe("feature-branch");
1375
+ });
1376
+
1377
+ test("parses failed resolution pattern", () => {
1378
+ const input =
1379
+ "Merge conflict failed at tier ai-resolve. Branch: other-branch. Agent: my-agent. Conflicting files: src/foo.ts, src/bar.ts.";
1380
+ const patterns = parseConflictPatterns(input);
1381
+ expect(patterns.length).toBe(1);
1382
+ expect(patterns[0]?.tier).toBe("ai-resolve");
1383
+ expect(patterns[0]?.success).toBe(false);
1384
+ expect(patterns[0]?.files).toEqual(["src/foo.ts", "src/bar.ts"]);
1385
+ });
1386
+
1387
+ test("parses multiple patterns from search output", () => {
1388
+ const input = [
1389
+ "Some loam header text",
1390
+ "Merge conflict resolved at tier auto-resolve. Branch: b1. Agent: a1. Conflicting files: src/a.ts.",
1391
+ "Other text in between",
1392
+ "Merge conflict failed at tier reimagine. Branch: b2. Agent: a2. Conflicting files: src/b.ts, src/c.ts.",
1393
+ ].join("\n");
1394
+ const patterns = parseConflictPatterns(input);
1395
+ expect(patterns.length).toBe(2);
1396
+ });
1397
+
1398
+ test("returns empty array for no matches", () => {
1399
+ expect(parseConflictPatterns("")).toEqual([]);
1400
+ expect(parseConflictPatterns("no patterns here")).toEqual([]);
1401
+ });
1402
+ });
1403
+
1404
+ describe("buildConflictHistory", () => {
1405
+ test("returns empty history when no patterns match entry files", () => {
1406
+ const patterns: ParsedConflictPattern[] = [
1407
+ {
1408
+ tier: "auto-resolve",
1409
+ success: true,
1410
+ files: ["unrelated.ts"],
1411
+ agent: "a",
1412
+ branch: "b",
1413
+ },
1414
+ ];
1415
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1416
+ expect(history.skipTiers).toEqual([]);
1417
+ expect(history.pastResolutions).toEqual([]);
1418
+ expect(history.predictedConflictFiles).toEqual([]);
1419
+ });
1420
+
1421
+ test("builds skip tier list when tier fails >= 2 times with no successes", () => {
1422
+ const patterns: ParsedConflictPattern[] = [
1423
+ {
1424
+ tier: "ai-resolve",
1425
+ success: false,
1426
+ files: ["src/test.ts"],
1427
+ agent: "a",
1428
+ branch: "b1",
1429
+ },
1430
+ {
1431
+ tier: "ai-resolve",
1432
+ success: false,
1433
+ files: ["src/test.ts"],
1434
+ agent: "a",
1435
+ branch: "b2",
1436
+ },
1437
+ ];
1438
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1439
+ expect(history.skipTiers).toContain("ai-resolve");
1440
+ });
1441
+
1442
+ test("does not skip tier if it has any successes", () => {
1443
+ const patterns: ParsedConflictPattern[] = [
1444
+ {
1445
+ tier: "ai-resolve",
1446
+ success: false,
1447
+ files: ["src/test.ts"],
1448
+ agent: "a",
1449
+ branch: "b1",
1450
+ },
1451
+ {
1452
+ tier: "ai-resolve",
1453
+ success: false,
1454
+ files: ["src/test.ts"],
1455
+ agent: "a",
1456
+ branch: "b2",
1457
+ },
1458
+ {
1459
+ tier: "ai-resolve",
1460
+ success: true,
1461
+ files: ["src/test.ts"],
1462
+ agent: "a",
1463
+ branch: "b3",
1464
+ },
1465
+ ];
1466
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1467
+ expect(history.skipTiers).not.toContain("ai-resolve");
1468
+ });
1469
+
1470
+ test("does not skip tier with only 1 failure", () => {
1471
+ const patterns: ParsedConflictPattern[] = [
1472
+ {
1473
+ tier: "reimagine",
1474
+ success: false,
1475
+ files: ["src/test.ts"],
1476
+ agent: "a",
1477
+ branch: "b1",
1478
+ },
1479
+ ];
1480
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1481
+ expect(history.skipTiers).not.toContain("reimagine");
1482
+ });
1483
+
1484
+ test("collects past successful resolutions", () => {
1485
+ const patterns: ParsedConflictPattern[] = [
1486
+ {
1487
+ tier: "auto-resolve",
1488
+ success: true,
1489
+ files: ["src/test.ts"],
1490
+ agent: "a",
1491
+ branch: "b1",
1492
+ },
1493
+ {
1494
+ tier: "ai-resolve",
1495
+ success: true,
1496
+ files: ["src/test.ts", "src/other.ts"],
1497
+ agent: "b",
1498
+ branch: "b2",
1499
+ },
1500
+ ];
1501
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1502
+ expect(history.pastResolutions.length).toBe(2);
1503
+ expect(history.pastResolutions[0]).toContain("auto-resolve");
1504
+ expect(history.pastResolutions[1]).toContain("ai-resolve");
1505
+ });
1506
+
1507
+ test("predicts conflict files from historical patterns", () => {
1508
+ const patterns: ParsedConflictPattern[] = [
1509
+ {
1510
+ tier: "auto-resolve",
1511
+ success: true,
1512
+ files: ["src/test.ts", "src/utils.ts"],
1513
+ agent: "a",
1514
+ branch: "b1",
1515
+ },
1516
+ ];
1517
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1518
+ expect(history.predictedConflictFiles).toContain("src/test.ts");
1519
+ expect(history.predictedConflictFiles).toContain("src/utils.ts");
1520
+ });
1521
+
1522
+ test("returns empty history for empty patterns array", () => {
1523
+ const history = buildConflictHistory([], ["src/test.ts"]);
1524
+ expect(history.skipTiers).toEqual([]);
1525
+ expect(history.pastResolutions).toEqual([]);
1526
+ expect(history.predictedConflictFiles).toEqual([]);
1527
+ });
1528
+ });
1529
+
1530
+ describe("Conflict history tier skipping", () => {
1531
+ test("skips auto-resolve tier when history says it always fails for these files", async () => {
1532
+ const repoDir = await createTempGitRepo();
1533
+ try {
1534
+ const defaultBranch = await getDefaultBranch(repoDir);
1535
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
1536
+
1537
+ const entry = makeTestEntry({
1538
+ branchName: "feature-branch",
1539
+ filesModified: ["src/test.ts"],
1540
+ });
1541
+
1542
+ // Mock loamClient that returns history showing auto-resolve always fails
1543
+ const mockLoamClient = createMockLoamClient();
1544
+ mockLoamClient.search = async () => {
1545
+ return [
1546
+ "Merge conflict failed at tier auto-resolve. Branch: b1. Agent: a1. Conflicting files: src/test.ts.",
1547
+ "Merge conflict failed at tier auto-resolve. Branch: b2. Agent: a2. Conflicting files: src/test.ts.",
1548
+ ].join("\n");
1549
+ };
1550
+
1551
+ // AI and reimagine disabled, auto-resolve should be skipped -> fails immediately
1552
+ const resolver = createMergeResolver({
1553
+ aiResolveEnabled: false,
1554
+ reimagineEnabled: false,
1555
+ loamClient: mockLoamClient,
1556
+ });
1557
+
1558
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1559
+
1560
+ // Should fail, and the last tier should NOT be auto-resolve (it was skipped)
1561
+ expect(result.success).toBe(false);
1562
+ } finally {
1563
+ await cleanupTempDir(repoDir);
1564
+ }
1565
+ });
1566
+ });
1567
+
1568
+ describe("resolveConflictsUnion", () => {
1569
+ test("returns null when no conflict markers are present", () => {
1570
+ expect(resolveConflictsUnion("no conflicts here\n")).toBeNull();
1571
+ expect(resolveConflictsUnion("")).toBeNull();
1572
+ });
1573
+
1574
+ test("keeps both canonical and incoming content for a single conflict", () => {
1575
+ const content = [
1576
+ "<<<<<<< HEAD\n",
1577
+ '{"id":"a"}\n',
1578
+ '{"id":"c"}\n',
1579
+ "=======\n",
1580
+ '{"id":"a"}\n',
1581
+ '{"id":"b"}\n',
1582
+ ">>>>>>> feature-branch\n",
1583
+ ].join("");
1584
+ const result = resolveConflictsUnion(content);
1585
+ expect(result).not.toBeNull();
1586
+ expect(result).toContain('{"id":"a"}\n{"id":"c"}\n');
1587
+ expect(result).toContain('{"id":"a"}\n{"id":"b"}\n');
1588
+ // No conflict markers remain
1589
+ expect(result).not.toContain("<<<<<<<");
1590
+ expect(result).not.toContain("=======");
1591
+ expect(result).not.toContain(">>>>>>>");
1592
+ });
1593
+
1594
+ test("resolves multiple conflict blocks with union strategy", () => {
1595
+ const block = (canonical: string, incoming: string): string =>
1596
+ `<<<<<<< HEAD\n${canonical}\n=======\n${incoming}\n>>>>>>> branch\n`;
1597
+ const content = `${block("line-a\n", "line-b\n")}middle\n${block("line-c\n", "line-d\n")}`;
1598
+ const result = resolveConflictsUnion(content);
1599
+ expect(result).not.toBeNull();
1600
+ expect(result).toContain("line-a\n");
1601
+ expect(result).toContain("line-b\n");
1602
+ expect(result).toContain("middle\n");
1603
+ expect(result).toContain("line-c\n");
1604
+ expect(result).toContain("line-d\n");
1605
+ expect(result).not.toContain("<<<<<<<");
1606
+ });
1607
+ });
1608
+
1609
+ describe("merge=union gitattribute support", () => {
1610
+ test("union strategy preserves lines from both sides (git handles cleanly or via auto-resolve)", async () => {
1611
+ const repoDir = await createTempGitRepo();
1612
+ try {
1613
+ const defaultBranch = await getDefaultBranch(repoDir);
1614
+
1615
+ // Set up .gitattributes with merge=union for *.jsonl files so that
1616
+ // both git's built-in union driver AND agentplate's Tier 2 union path
1617
+ // are configured to keep all lines from both sides.
1618
+ await commitFile(repoDir, ".gitattributes", "*.jsonl merge=union\n");
1619
+ // Common ancestor: one line
1620
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n');
1621
+
1622
+ // Feature branch: adds line b
1623
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
1624
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"b"}\n');
1625
+
1626
+ // Back to main: adds line c (diverges from ancestor)
1627
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
1628
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"c"}\n');
1629
+
1630
+ const entry = makeTestEntry({
1631
+ branchName: "feature-branch",
1632
+ filesModified: ["data.jsonl"],
1633
+ });
1634
+
1635
+ const resolver = createMergeResolver({
1636
+ aiResolveEnabled: false,
1637
+ reimagineEnabled: false,
1638
+ });
1639
+
1640
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1641
+
1642
+ // With merge=union, git either resolves cleanly (Tier 1) or
1643
+ // agentplate's union path handles it (Tier 2). Either way, success
1644
+ // and both sides' content must be preserved.
1645
+ expect(result.success).toBe(true);
1646
+ expect(result.entry.status).toBe("merged");
1647
+
1648
+ // Both sides' lines must be present — no lines dropped
1649
+ const file = Bun.file(join(repoDir, "data.jsonl"));
1650
+ const content = await file.text();
1651
+ expect(content).toContain('{"id":"a"}');
1652
+ expect(content).toContain('{"id":"b"}');
1653
+ expect(content).toContain('{"id":"c"}');
1654
+ } finally {
1655
+ await cleanupTempDir(repoDir);
1656
+ }
1657
+ });
1658
+
1659
+ test("Tier 2 union auto-resolve keeps both sides when git produces conflict markers", async () => {
1660
+ // This test verifies the Tier 2 code path: when git produces conflict
1661
+ // markers for a file that has merge=union set in .gitattributes,
1662
+ // agentplate resolves it by keeping both canonical and incoming content.
1663
+ // We produce conflict markers by doing a content conflict on a file whose
1664
+ // attribute is set to merge=union AFTER the conflict state exists, then
1665
+ // run only the auto-resolve path via a standalone resolver call.
1666
+ //
1667
+ // To force conflict markers despite merge=union: we DON'T commit
1668
+ // .gitattributes before the merge, so git uses the default driver and
1669
+ // produces conflict markers. Then we write .gitattributes to the working
1670
+ // tree (not committed) so git check-attr sees it and our code detects union.
1671
+ const repoDir = await createTempGitRepo();
1672
+ try {
1673
+ const defaultBranch = await getDefaultBranch(repoDir);
1674
+
1675
+ // Common ancestor
1676
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n');
1677
+
1678
+ // Feature branch: adds line b (also appends to same position as main)
1679
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
1680
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"b"}\n');
1681
+
1682
+ // Back to main: adds line c — diverges from ancestor at same position
1683
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
1684
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"c"}\n');
1685
+
1686
+ // Now WRITE .gitattributes to working tree (not committed).
1687
+ // git check-attr reads from the working tree during Tier 2.
1688
+ await Bun.write(`${repoDir}/.gitattributes`, "*.jsonl merge=union\n");
1689
+
1690
+ // Attempt merge — git uses default driver (no committed .gitattributes)
1691
+ // so it WILL produce conflict markers if branches diverge at same position.
1692
+ // (If git resolves cleanly, the test still passes because content is preserved.)
1693
+ const entry = makeTestEntry({
1694
+ branchName: "feature-branch",
1695
+ filesModified: ["data.jsonl"],
1696
+ });
1697
+
1698
+ const resolver = createMergeResolver({
1699
+ aiResolveEnabled: false,
1700
+ reimagineEnabled: false,
1701
+ });
1702
+
1703
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1704
+
1705
+ expect(result.success).toBe(true);
1706
+ expect(result.entry.status).toBe("merged");
1707
+
1708
+ const file = Bun.file(join(repoDir, "data.jsonl"));
1709
+ const content = await file.text();
1710
+ expect(content).toContain('{"id":"a"}');
1711
+ expect(content).toContain('{"id":"b"}');
1712
+ expect(content).toContain('{"id":"c"}');
1713
+ } finally {
1714
+ await cleanupTempDir(repoDir);
1715
+ }
1716
+ });
1717
+ });
1718
+
1719
+ describe("queryConflictHistory uses sortByScore", () => {
1720
+ test("passes sortByScore: true to loam search when querying conflict history", async () => {
1721
+ const repoDir = await createTempGitRepo();
1722
+ try {
1723
+ const defaultBranch = await getDefaultBranch(repoDir);
1724
+ await setupContentConflict(repoDir, defaultBranch);
1725
+
1726
+ const entry = makeTestEntry({
1727
+ branchName: "feature-branch",
1728
+ filesModified: ["src/test.ts"],
1729
+ });
1730
+
1731
+ // Capture search call options
1732
+ let capturedSearchOptions: unknown;
1733
+ const mockLoamClient = createMockLoamClient();
1734
+ mockLoamClient.search = async (_query, options) => {
1735
+ capturedSearchOptions = options;
1736
+ return "";
1737
+ };
1738
+
1739
+ const resolver = createMergeResolver({
1740
+ aiResolveEnabled: false,
1741
+ reimagineEnabled: false,
1742
+ loamClient: mockLoamClient,
1743
+ });
1744
+
1745
+ await resolver.resolve(entry, defaultBranch, repoDir);
1746
+
1747
+ // Verify sortByScore was passed to search
1748
+ expect(capturedSearchOptions).toMatchObject({ sortByScore: true });
1749
+ } finally {
1750
+ await cleanupTempDir(repoDir);
1751
+ }
1752
+ });
1753
+ });
1754
+
1755
+ describe("AI-resolve with history context", () => {
1756
+ test("includes historical context in AI prompt when available", async () => {
1757
+ const repoDir = await createTempGitRepo();
1758
+ try {
1759
+ const defaultBranch = await getDefaultBranch(repoDir);
1760
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
1761
+
1762
+ const entry = makeTestEntry({
1763
+ branchName: "feature-branch",
1764
+ filesModified: ["src/test.ts"],
1765
+ });
1766
+
1767
+ // Mock loamClient that returns successful resolution history
1768
+ const mockLoamClient = createMockLoamClient();
1769
+ mockLoamClient.search = async () => {
1770
+ return "Merge conflict resolved at tier ai-resolve. Branch: old-branch. Agent: old-agent. Conflicting files: src/test.ts.";
1771
+ };
1772
+
1773
+ // Capture the prompt sent to claude
1774
+ let capturedPrompt = "";
1775
+ const originalSpawn = Bun.spawn;
1776
+ const selectiveMock = (...args: unknown[]): unknown => {
1777
+ const cmd = args[0] as string[];
1778
+ if (cmd?.[0] === "claude") {
1779
+ capturedPrompt = cmd[3] ?? "";
1780
+ return mockSpawnResult("resolved content\n", "", 0);
1781
+ }
1782
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
1783
+ };
1784
+
1785
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
1786
+
1787
+ try {
1788
+ const resolver = createMergeResolver({
1789
+ aiResolveEnabled: true,
1790
+ reimagineEnabled: false,
1791
+ loamClient: mockLoamClient,
1792
+ });
1793
+
1794
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1795
+
1796
+ expect(result.success).toBe(true);
1797
+ expect(result.tier).toBe("ai-resolve");
1798
+ // Verify historical context was included in the prompt
1799
+ expect(capturedPrompt).toContain("Historical context");
1800
+ expect(capturedPrompt).toContain("ai-resolve");
1801
+ } finally {
1802
+ spawnSpy.mockRestore();
1803
+ }
1804
+ } finally {
1805
+ await cleanupTempDir(repoDir);
1806
+ }
1807
+ });
1808
+ });
1809
+
1810
+ describe("onMergeSuccess callback", () => {
1811
+ test("callback is invoked on successful clean merge", async () => {
1812
+ const repoDir = await createTempGitRepo();
1813
+ try {
1814
+ const defaultBranch = await getDefaultBranch(repoDir);
1815
+ await setupCleanMerge(repoDir, defaultBranch);
1816
+
1817
+ const entry = makeTestEntry({
1818
+ branchName: "feature-branch",
1819
+ filesModified: ["src/feature-file.ts"],
1820
+ });
1821
+
1822
+ let callbackCalled = false;
1823
+ const resolver = createMergeResolver({
1824
+ aiResolveEnabled: false,
1825
+ reimagineEnabled: false,
1826
+ onMergeSuccess: async (mergedEntry) => {
1827
+ callbackCalled = true;
1828
+ expect(mergedEntry.status).toBe("merged");
1829
+ expect(mergedEntry.resolvedTier).toBe("clean-merge");
1830
+ },
1831
+ });
1832
+
1833
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1834
+ expect(result.success).toBe(true);
1835
+ expect(callbackCalled).toBe(true);
1836
+ } finally {
1837
+ await cleanupTempDir(repoDir);
1838
+ }
1839
+ });
1840
+
1841
+ test("callback is invoked on auto-resolve success", async () => {
1842
+ const repoDir = await createTempGitRepo();
1843
+ try {
1844
+ const defaultBranch = await getDefaultBranch(repoDir);
1845
+ // Use empty-canonical setup so auto-resolve completes successfully
1846
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
1847
+
1848
+ const entry = makeTestEntry({
1849
+ branchName: "feature-branch",
1850
+ filesModified: ["src/test.ts"],
1851
+ });
1852
+
1853
+ let callbackCalled = false;
1854
+ const resolver = createMergeResolver({
1855
+ aiResolveEnabled: false,
1856
+ reimagineEnabled: false,
1857
+ onMergeSuccess: async (mergedEntry) => {
1858
+ callbackCalled = true;
1859
+ expect(mergedEntry.resolvedTier).toBe("auto-resolve");
1860
+ },
1861
+ });
1862
+
1863
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1864
+ expect(result.success).toBe(true);
1865
+ expect(callbackCalled).toBe(true);
1866
+ } finally {
1867
+ await cleanupTempDir(repoDir);
1868
+ }
1869
+ });
1870
+
1871
+ test("callback is NOT invoked on merge failure", async () => {
1872
+ const repoDir = await createTempGitRepo();
1873
+ try {
1874
+ const defaultBranch = await getDefaultBranch(repoDir);
1875
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
1876
+
1877
+ const entry = makeTestEntry({
1878
+ branchName: "feature-branch",
1879
+ filesModified: ["src/test.ts"],
1880
+ });
1881
+
1882
+ let callbackCalled = false;
1883
+ const resolver = createMergeResolver({
1884
+ aiResolveEnabled: false,
1885
+ reimagineEnabled: false,
1886
+ onMergeSuccess: async () => {
1887
+ callbackCalled = true;
1888
+ },
1889
+ });
1890
+
1891
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1892
+ expect(result.success).toBe(false);
1893
+ expect(callbackCalled).toBe(false);
1894
+ } finally {
1895
+ await cleanupTempDir(repoDir);
1896
+ }
1897
+ });
1898
+
1899
+ test("callback errors are swallowed and merge result is unaffected", async () => {
1900
+ const repoDir = await createTempGitRepo();
1901
+ try {
1902
+ const defaultBranch = await getDefaultBranch(repoDir);
1903
+ await setupCleanMerge(repoDir, defaultBranch);
1904
+
1905
+ const entry = makeTestEntry({
1906
+ branchName: "feature-branch",
1907
+ filesModified: ["src/feature-file.ts"],
1908
+ });
1909
+
1910
+ const resolver = createMergeResolver({
1911
+ aiResolveEnabled: false,
1912
+ reimagineEnabled: false,
1913
+ onMergeSuccess: async () => {
1914
+ throw new Error("callback failure");
1915
+ },
1916
+ });
1917
+
1918
+ // Should succeed despite the callback throwing
1919
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1920
+ expect(result.success).toBe(true);
1921
+ expect(result.tier).toBe("clean-merge");
1922
+ } finally {
1923
+ await cleanupTempDir(repoDir);
1924
+ }
1925
+ });
1926
+ });
1927
+
1928
+ describe("Untracked files blocking merge", () => {
1929
+ test("untracked files in merge path are auto-committed and merge succeeds", async () => {
1930
+ const repoDir = await createTempGitRepo();
1931
+ try {
1932
+ const defaultBranch = await getDefaultBranch(repoDir);
1933
+
1934
+ // Feature branch adds src/new-feature.ts
1935
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
1936
+ await commitFile(repoDir, "src/new-feature.ts", "feature content\n");
1937
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
1938
+
1939
+ // Create the same file as untracked in the working directory —
1940
+ // without our fix, git merge would fail: "untracked file would be overwritten"
1941
+ await Bun.write(`${repoDir}/src/new-feature.ts`, "feature content\n");
1942
+
1943
+ const entry = makeTestEntry({
1944
+ branchName: "feature-branch",
1945
+ filesModified: ["src/new-feature.ts"],
1946
+ });
1947
+
1948
+ const resolver = createMergeResolver({
1949
+ aiResolveEnabled: false,
1950
+ reimagineEnabled: false,
1951
+ });
1952
+
1953
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1954
+ expect(result.success).toBe(true);
1955
+ expect(result.tier).toBe("clean-merge");
1956
+ } finally {
1957
+ await cleanupTempDir(repoDir);
1958
+ }
1959
+ });
1960
+
1961
+ test("untracked files NOT in entry.filesModified are left alone", async () => {
1962
+ const repoDir = await createTempGitRepo();
1963
+ try {
1964
+ const defaultBranch = await getDefaultBranch(repoDir);
1965
+ await setupCleanMerge(repoDir, defaultBranch);
1966
+
1967
+ // Untracked file NOT in filesModified — should not be touched
1968
+ await Bun.write(`${repoDir}/src/scratch.ts`, "scratch work\n");
1969
+
1970
+ const entry = makeTestEntry({
1971
+ branchName: "feature-branch",
1972
+ filesModified: ["src/feature-file.ts"],
1973
+ });
1974
+
1975
+ const resolver = createMergeResolver({
1976
+ aiResolveEnabled: false,
1977
+ reimagineEnabled: false,
1978
+ });
1979
+
1980
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1981
+ expect(result.success).toBe(true);
1982
+
1983
+ // Untracked file should still exist and be untracked
1984
+ const scratchContent = await Bun.file(`${repoDir}/src/scratch.ts`).text();
1985
+ expect(scratchContent).toBe("scratch work\n");
1986
+ const status = await runGitInDir(repoDir, ["status", "--porcelain"]);
1987
+ expect(status).toContain("src/scratch.ts");
1988
+ } finally {
1989
+ await cleanupTempDir(repoDir);
1990
+ }
1991
+ });
1992
+ });
1993
+ });