@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,425 @@
1
+ /**
2
+ * SQLite-backed mail storage for inter-agent messaging.
3
+ *
4
+ * Provides low-level CRUD operations on the messages table.
5
+ * Uses bun:sqlite for zero-dependency, synchronous database access.
6
+ * The higher-level mail client (L2) wraps this store.
7
+ */
8
+
9
+ import { Database } from "bun:sqlite";
10
+ import { MailError } from "../errors.ts";
11
+ import type { MailMessage, MailMessageType } from "../types.ts";
12
+ import { MAIL_MESSAGE_TYPES } from "../types.ts";
13
+
14
+ export interface MailStore {
15
+ insert(
16
+ message: Omit<MailMessage, "read" | "createdAt" | "payload"> & { payload?: string | null },
17
+ ): MailMessage;
18
+ getUnread(agentName: string): MailMessage[];
19
+ getAll(filters?: {
20
+ from?: string;
21
+ to?: string;
22
+ unread?: boolean;
23
+ type?: MailMessageType;
24
+ limit?: number;
25
+ }): MailMessage[];
26
+ getById(id: string): MailMessage | null;
27
+ getByThread(threadId: string): MailMessage[];
28
+ markRead(id: string): void;
29
+ /** Delete a single message by id. Returns true if a row was deleted. */
30
+ deleteById(id: string): boolean;
31
+ /** Delete messages matching the given criteria. Returns the number of messages deleted. */
32
+ purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number;
33
+ close(): void;
34
+ }
35
+
36
+ /** Row shape as stored in SQLite (snake_case columns, integer boolean). */
37
+ interface MessageRow {
38
+ id: string;
39
+ from_agent: string;
40
+ to_agent: string;
41
+ subject: string;
42
+ body: string;
43
+ type: string;
44
+ priority: string;
45
+ thread_id: string | null;
46
+ payload: string | null;
47
+ read: number;
48
+ created_at: string;
49
+ }
50
+
51
+ /** Build the CHECK constraint for message types from the runtime constant. */
52
+ const TYPE_CHECK = `CHECK(type IN (${MAIL_MESSAGE_TYPES.map((t) => `'${t}'`).join(",")}))`;
53
+
54
+ const CREATE_TABLE = `
55
+ CREATE TABLE IF NOT EXISTS messages (
56
+ id TEXT PRIMARY KEY,
57
+ from_agent TEXT NOT NULL,
58
+ to_agent TEXT NOT NULL,
59
+ subject TEXT NOT NULL,
60
+ body TEXT NOT NULL,
61
+ type TEXT NOT NULL DEFAULT 'status' ${TYPE_CHECK},
62
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
63
+ thread_id TEXT,
64
+ payload TEXT,
65
+ read INTEGER NOT NULL DEFAULT 0,
66
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
67
+ )`;
68
+
69
+ /**
70
+ * Migrate an existing messages table to the current schema.
71
+ *
72
+ * Handles two migration paths:
73
+ * 1. Tables without CHECK constraints → recreate with constraints
74
+ * 2. Tables without payload column → add payload column
75
+ * 3. Tables with old CHECK constraints (missing protocol types) → recreate with new types
76
+ *
77
+ * SQLite does not support ALTER TABLE ADD CONSTRAINT, so constraint changes
78
+ * require recreating the table.
79
+ */
80
+ function migrateSchema(db: Database): void {
81
+ const row = db
82
+ .prepare<{ sql: string }, []>(
83
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name='messages'",
84
+ )
85
+ .get();
86
+ if (!row) {
87
+ // Table doesn't exist yet; CREATE TABLE IF NOT EXISTS will handle it
88
+ return;
89
+ }
90
+
91
+ const hasCheckConstraints = row.sql.includes("CHECK");
92
+ const hasPayloadColumn = row.sql.includes("payload");
93
+ const hasProtocolTypes = row.sql.includes("worker_done");
94
+ const hasDecisionGate = row.sql.includes("decision_gate");
95
+ const hasWorkerDied = row.sql.includes("worker_died");
96
+
97
+ // If schema is fully up to date, nothing to do
98
+ if (
99
+ hasCheckConstraints &&
100
+ hasPayloadColumn &&
101
+ hasProtocolTypes &&
102
+ hasDecisionGate &&
103
+ hasWorkerDied
104
+ ) {
105
+ return;
106
+ }
107
+
108
+ // If only missing the payload column (has correct CHECK constraints), use ALTER TABLE
109
+ if (hasCheckConstraints && hasProtocolTypes && hasWorkerDied && !hasPayloadColumn) {
110
+ db.exec("ALTER TABLE messages ADD COLUMN payload TEXT");
111
+ return;
112
+ }
113
+
114
+ // Need to recreate the table (missing CHECK constraints or needs type update)
115
+ const validTypes = MAIL_MESSAGE_TYPES.map((t) => `'${t}'`).join(",");
116
+ db.exec("BEGIN TRANSACTION");
117
+ try {
118
+ db.exec("ALTER TABLE messages RENAME TO messages_old");
119
+ db.exec(`
120
+ CREATE TABLE messages (
121
+ id TEXT PRIMARY KEY,
122
+ from_agent TEXT NOT NULL,
123
+ to_agent TEXT NOT NULL,
124
+ subject TEXT NOT NULL,
125
+ body TEXT NOT NULL,
126
+ type TEXT NOT NULL DEFAULT 'status' CHECK(type IN (${validTypes})),
127
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
128
+ thread_id TEXT,
129
+ payload TEXT,
130
+ read INTEGER NOT NULL DEFAULT 0,
131
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
132
+ )`);
133
+ // Copy data, mapping invalid types to 'status'. Old tables may not have payload column.
134
+ const oldHasPayload = row.sql.includes("payload");
135
+ const payloadSelect = oldHasPayload ? "payload" : "NULL";
136
+ db.exec(`
137
+ INSERT INTO messages (id, from_agent, to_agent, subject, body, type, priority, thread_id, payload, read, created_at)
138
+ SELECT id, from_agent, to_agent, subject, body,
139
+ CASE WHEN type IN (${validTypes}) THEN type ELSE 'status' END,
140
+ CASE WHEN priority IN ('low','normal','high','urgent') THEN priority ELSE 'normal' END,
141
+ thread_id, ${payloadSelect}, read, created_at
142
+ FROM messages_old`);
143
+ db.exec("DROP TABLE messages_old");
144
+ db.exec("COMMIT");
145
+ } catch (err) {
146
+ db.exec("ROLLBACK");
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ const CREATE_INDEXES = `
152
+ CREATE INDEX IF NOT EXISTS idx_inbox ON messages(to_agent, read);
153
+ CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id)`;
154
+
155
+ /** Generate a random 12-character alphanumeric ID. */
156
+ function randomId(): string {
157
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
158
+ const bytes = new Uint8Array(12);
159
+ crypto.getRandomValues(bytes);
160
+ let result = "";
161
+ for (let i = 0; i < 12; i++) {
162
+ const byte = bytes[i];
163
+ if (byte !== undefined) {
164
+ result += chars[byte % chars.length];
165
+ }
166
+ }
167
+ return result;
168
+ }
169
+
170
+ /** Convert a database row (snake_case) to a MailMessage object (camelCase). */
171
+ function rowToMessage(row: MessageRow): MailMessage {
172
+ return {
173
+ id: row.id,
174
+ from: row.from_agent,
175
+ to: row.to_agent,
176
+ subject: row.subject,
177
+ body: row.body,
178
+ type: row.type as MailMessage["type"],
179
+ priority: row.priority as MailMessage["priority"],
180
+ threadId: row.thread_id,
181
+ payload: row.payload,
182
+ read: row.read === 1,
183
+ createdAt: row.created_at,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Create a new MailStore backed by a SQLite database at the given path.
189
+ *
190
+ * Initializes the database with WAL mode and a 5-second busy timeout.
191
+ * Creates the messages table and indexes if they do not already exist.
192
+ */
193
+ export function createMailStore(dbPath: string): MailStore {
194
+ const db = new Database(dbPath);
195
+
196
+ // Configure for concurrent access from multiple agent processes.
197
+ // WAL mode allows concurrent readers with one writer.
198
+ // synchronous=NORMAL balances safety and performance in WAL mode.
199
+ // busy_timeout retries for up to 5 seconds on lock contention.
200
+ db.exec("PRAGMA journal_mode = WAL");
201
+ db.exec("PRAGMA synchronous = NORMAL");
202
+ db.exec("PRAGMA busy_timeout = 5000");
203
+
204
+ // Migrate existing tables to current schema (no-op if table is new or already migrated)
205
+ migrateSchema(db);
206
+
207
+ // Create schema (if table doesn't exist yet, creates with CHECK constraints)
208
+ db.exec(CREATE_TABLE);
209
+ db.exec(CREATE_INDEXES);
210
+
211
+ // Prepare statements for all queries
212
+ const insertStmt = db.prepare<
213
+ void,
214
+ {
215
+ $id: string;
216
+ $from_agent: string;
217
+ $to_agent: string;
218
+ $subject: string;
219
+ $body: string;
220
+ $type: string;
221
+ $priority: string;
222
+ $thread_id: string | null;
223
+ $payload: string | null;
224
+ $read: number;
225
+ $created_at: string;
226
+ }
227
+ >(`
228
+ INSERT INTO messages
229
+ (id, from_agent, to_agent, subject, body, type, priority, thread_id, payload, read, created_at)
230
+ VALUES
231
+ ($id, $from_agent, $to_agent, $subject, $body, $type, $priority, $thread_id, $payload, $read, $created_at)
232
+ `);
233
+
234
+ const getByIdStmt = db.prepare<MessageRow, { $id: string }>(`
235
+ SELECT * FROM messages WHERE id = $id
236
+ `);
237
+
238
+ const getUnreadStmt = db.prepare<MessageRow, { $to_agent: string }>(`
239
+ SELECT * FROM messages WHERE to_agent = $to_agent AND read = 0 ORDER BY created_at ASC
240
+ `);
241
+
242
+ const getByThreadStmt = db.prepare<MessageRow, { $thread_id: string }>(`
243
+ SELECT * FROM messages WHERE thread_id = $thread_id ORDER BY created_at ASC
244
+ `);
245
+
246
+ const markReadStmt = db.prepare<void, { $id: string }>(`
247
+ UPDATE messages SET read = 1 WHERE id = $id
248
+ `);
249
+
250
+ const deleteByIdStmt = db.prepare<void, { $id: string }>(`
251
+ DELETE FROM messages WHERE id = $id
252
+ `);
253
+
254
+ // Dynamic filter queries are built at call time since the WHERE clause varies
255
+ function buildFilterQuery(filters?: {
256
+ from?: string;
257
+ to?: string;
258
+ unread?: boolean;
259
+ type?: MailMessageType;
260
+ limit?: number;
261
+ }): MailMessage[] {
262
+ const conditions: string[] = [];
263
+ const params: Record<string, string | number> = {};
264
+
265
+ if (filters?.from !== undefined) {
266
+ conditions.push("from_agent = $from_agent");
267
+ params.$from_agent = filters.from;
268
+ }
269
+ if (filters?.to !== undefined) {
270
+ conditions.push("to_agent = $to_agent");
271
+ params.$to_agent = filters.to;
272
+ }
273
+ if (filters?.unread !== undefined) {
274
+ conditions.push("read = $read");
275
+ params.$read = filters.unread ? 0 : 1;
276
+ }
277
+ if (filters?.type !== undefined) {
278
+ conditions.push("type = $type");
279
+ params.$type = filters.type;
280
+ }
281
+
282
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
283
+ const limitClause = filters?.limit !== undefined ? ` LIMIT $limit` : "";
284
+ if (filters?.limit !== undefined) {
285
+ params.$limit = filters.limit;
286
+ }
287
+ const query = `SELECT * FROM messages ${whereClause} ORDER BY created_at DESC${limitClause}`;
288
+ const stmt = db.prepare<MessageRow, Record<string, string | number>>(query);
289
+ const rows = stmt.all(params);
290
+ return rows.map(rowToMessage);
291
+ }
292
+
293
+ return {
294
+ insert(
295
+ message: Omit<MailMessage, "read" | "createdAt" | "payload"> & {
296
+ payload?: string | null;
297
+ },
298
+ ): MailMessage {
299
+ const id = message.id || `msg-${randomId()}`;
300
+ const createdAt = new Date().toISOString();
301
+ const payload = message.payload ?? null;
302
+
303
+ try {
304
+ insertStmt.run({
305
+ $id: id,
306
+ $from_agent: message.from,
307
+ $to_agent: message.to,
308
+ $subject: message.subject,
309
+ $body: message.body,
310
+ $type: message.type,
311
+ $priority: message.priority,
312
+ $thread_id: message.threadId,
313
+ $payload: payload,
314
+ $read: 0,
315
+ $created_at: createdAt,
316
+ });
317
+ } catch (err) {
318
+ throw new MailError(`Failed to insert message: ${id}`, {
319
+ messageId: id,
320
+ cause: err instanceof Error ? err : undefined,
321
+ });
322
+ }
323
+
324
+ return {
325
+ ...message,
326
+ id,
327
+ payload,
328
+ read: false,
329
+ createdAt,
330
+ };
331
+ },
332
+
333
+ getUnread(agentName: string): MailMessage[] {
334
+ const rows = getUnreadStmt.all({ $to_agent: agentName });
335
+ return rows.map(rowToMessage);
336
+ },
337
+
338
+ getAll(filters?: {
339
+ from?: string;
340
+ to?: string;
341
+ unread?: boolean;
342
+ type?: MailMessageType;
343
+ limit?: number;
344
+ }): MailMessage[] {
345
+ return buildFilterQuery(filters);
346
+ },
347
+
348
+ getById(id: string): MailMessage | null {
349
+ const row = getByIdStmt.get({ $id: id });
350
+ return row ? rowToMessage(row) : null;
351
+ },
352
+
353
+ getByThread(threadId: string): MailMessage[] {
354
+ const rows = getByThreadStmt.all({ $thread_id: threadId });
355
+ return rows.map(rowToMessage);
356
+ },
357
+
358
+ markRead(id: string): void {
359
+ markReadStmt.run({ $id: id });
360
+ },
361
+
362
+ deleteById(id: string): boolean {
363
+ try {
364
+ const result = deleteByIdStmt.run({ $id: id });
365
+ return result.changes > 0;
366
+ } catch (err) {
367
+ throw new MailError(`Failed to delete message: ${id}`, {
368
+ messageId: id,
369
+ cause: err instanceof Error ? err : undefined,
370
+ });
371
+ }
372
+ },
373
+
374
+ purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number {
375
+ // Count matching rows before deletion so we can report accurate numbers
376
+ if (options.all) {
377
+ const countRow = db
378
+ .prepare<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM messages")
379
+ .get();
380
+ const count = countRow?.cnt ?? 0;
381
+ db.prepare("DELETE FROM messages").run();
382
+ return count;
383
+ }
384
+
385
+ const conditions: string[] = [];
386
+ const params: Record<string, string> = {};
387
+
388
+ if (options.olderThanMs !== undefined) {
389
+ const cutoff = new Date(Date.now() - options.olderThanMs).toISOString();
390
+ conditions.push("created_at < $cutoff");
391
+ params.$cutoff = cutoff;
392
+ }
393
+
394
+ if (options.agent !== undefined) {
395
+ conditions.push("(from_agent = $agent OR to_agent = $agent)");
396
+ params.$agent = options.agent;
397
+ }
398
+
399
+ if (conditions.length === 0) {
400
+ return 0;
401
+ }
402
+
403
+ const whereClause = conditions.join(" AND ");
404
+ const countQuery = `SELECT COUNT(*) as cnt FROM messages WHERE ${whereClause}`;
405
+ const countRow = db.prepare<{ cnt: number }, Record<string, string>>(countQuery).get(params);
406
+ const count = countRow?.cnt ?? 0;
407
+
408
+ const deleteQuery = `DELETE FROM messages WHERE ${whereClause}`;
409
+ db.prepare<void, Record<string, string>>(deleteQuery).run(params);
410
+
411
+ return count;
412
+ },
413
+
414
+ close(): void {
415
+ // Checkpoint WAL to ensure all written data is visible to other processes
416
+ // that may open the database after this connection closes.
417
+ try {
418
+ db.exec("PRAGMA wal_checkpoint(PASSIVE)");
419
+ } catch {
420
+ // Best effort — checkpoint failure is non-fatal
421
+ }
422
+ db.close();
423
+ },
424
+ };
425
+ }
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { MergeError } from "../errors.ts";
7
+ import { acquireMergeLock, mergeLockPath, sanitizeBranchForFilename } from "./lock.ts";
8
+
9
+ describe("sanitizeBranchForFilename", () => {
10
+ test("replaces forward slashes with dashes", () => {
11
+ expect(sanitizeBranchForFilename("feature/foo")).toBe("feature-foo");
12
+ expect(sanitizeBranchForFilename("a/b/c")).toBe("a-b-c");
13
+ });
14
+
15
+ test("replaces backslashes and colons", () => {
16
+ expect(sanitizeBranchForFilename("feature\\bar")).toBe("feature-bar");
17
+ expect(sanitizeBranchForFilename("ns:branch")).toBe("ns-branch");
18
+ });
19
+
20
+ test("leaves simple branch names alone", () => {
21
+ expect(sanitizeBranchForFilename("main")).toBe("main");
22
+ expect(sanitizeBranchForFilename("develop_2")).toBe("develop_2");
23
+ });
24
+ });
25
+
26
+ describe("mergeLockPath", () => {
27
+ test("composes path under .agentplate/ with sanitized branch", () => {
28
+ expect(mergeLockPath("/tmp/.agentplate", "feature/x")).toBe(
29
+ "/tmp/.agentplate/merge-feature-x.lock",
30
+ );
31
+ });
32
+ });
33
+
34
+ describe("acquireMergeLock", () => {
35
+ let agentplateDir: string;
36
+
37
+ beforeEach(async () => {
38
+ agentplateDir = await mkdtemp(join(tmpdir(), "ap-merge-lock-"));
39
+ await mkdir(agentplateDir, { recursive: true });
40
+ });
41
+
42
+ afterEach(async () => {
43
+ await rm(agentplateDir, { recursive: true, force: true });
44
+ });
45
+
46
+ test("creates a lock file and returns a handle that removes it on release", () => {
47
+ const handle = acquireMergeLock(agentplateDir, "main");
48
+ expect(existsSync(handle.path)).toBe(true);
49
+
50
+ const payload = JSON.parse(readFileSync(handle.path, "utf8"));
51
+ expect(payload.pid).toBe(process.pid);
52
+ expect(payload.targetBranch).toBe("main");
53
+ expect(typeof payload.acquiredAt).toBe("string");
54
+
55
+ handle.release();
56
+ expect(existsSync(handle.path)).toBe(false);
57
+ });
58
+
59
+ test("release() is idempotent", () => {
60
+ const handle = acquireMergeLock(agentplateDir, "main");
61
+ handle.release();
62
+ handle.release(); // should not throw
63
+ expect(existsSync(handle.path)).toBe(false);
64
+ });
65
+
66
+ test("throws MergeError when lock is held by a live process", () => {
67
+ // Use this test process's own PID — it is guaranteed live.
68
+ const path = mergeLockPath(agentplateDir, "main");
69
+ writeFileSync(
70
+ path,
71
+ JSON.stringify({
72
+ pid: process.pid,
73
+ acquiredAt: new Date().toISOString(),
74
+ targetBranch: "main",
75
+ }),
76
+ );
77
+
78
+ try {
79
+ acquireMergeLock(agentplateDir, "main");
80
+ expect(true).toBe(false); // should not reach
81
+ } catch (err: unknown) {
82
+ expect(err).toBeInstanceOf(MergeError);
83
+ const msg = (err as MergeError).message;
84
+ expect(msg).toContain("Another ap merge is already running");
85
+ expect(msg).toContain(`pid ${process.pid}`);
86
+ expect(msg).toContain("main");
87
+ }
88
+
89
+ // Lock file is still on disk — we did not steal it.
90
+ expect(existsSync(path)).toBe(true);
91
+ });
92
+
93
+ test("steals a stale lock whose PID is not alive", () => {
94
+ const path = mergeLockPath(agentplateDir, "main");
95
+ // PID 2147483647 is INT_MAX — extremely unlikely to be in use.
96
+ writeFileSync(
97
+ path,
98
+ JSON.stringify({
99
+ pid: 2147483647,
100
+ acquiredAt: new Date(Date.now() - 60_000).toISOString(),
101
+ targetBranch: "main",
102
+ }),
103
+ );
104
+
105
+ const handle = acquireMergeLock(agentplateDir, "main");
106
+ const payload = JSON.parse(readFileSync(handle.path, "utf8"));
107
+ expect(payload.pid).toBe(process.pid);
108
+ handle.release();
109
+ });
110
+
111
+ test("steals an unparseable lock file", () => {
112
+ const path = mergeLockPath(agentplateDir, "main");
113
+ writeFileSync(path, "not json");
114
+
115
+ const handle = acquireMergeLock(agentplateDir, "main");
116
+ const payload = JSON.parse(readFileSync(handle.path, "utf8"));
117
+ expect(payload.pid).toBe(process.pid);
118
+ handle.release();
119
+ });
120
+
121
+ test("locks on different target branches are independent", () => {
122
+ const a = acquireMergeLock(agentplateDir, "main");
123
+ const b = acquireMergeLock(agentplateDir, "develop");
124
+ expect(existsSync(a.path)).toBe(true);
125
+ expect(existsSync(b.path)).toBe(true);
126
+ expect(a.path).not.toBe(b.path);
127
+ a.release();
128
+ b.release();
129
+ });
130
+
131
+ test("error message includes path so operator can manually clear", () => {
132
+ const path = mergeLockPath(agentplateDir, "main");
133
+ writeFileSync(
134
+ path,
135
+ JSON.stringify({
136
+ pid: process.pid,
137
+ acquiredAt: new Date().toISOString(),
138
+ targetBranch: "main",
139
+ }),
140
+ );
141
+
142
+ try {
143
+ acquireMergeLock(agentplateDir, "main");
144
+ expect(true).toBe(false);
145
+ } catch (err: unknown) {
146
+ expect((err as MergeError).message).toContain(path);
147
+ }
148
+ });
149
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Sentinel-file lock to prevent concurrent `ap merge` runs against the same
3
+ * canonical (target) branch.
4
+ *
5
+ * Two parallel merges into the same canonical branch can produce a misleading
6
+ * transient view: one merge runs the git operations while the second observes
7
+ * conflict markers mid-merge and reports a false failure. See sprout issue
8
+ * agentplate-9610 for the original incident.
9
+ *
10
+ * The lock is a single JSON file at `.agentplate/merge-{sanitized-target}.lock`
11
+ * created atomically with `writeFileSync(..., { flag: "wx" })`. If the file
12
+ * already exists, the holder PID is checked: live → fail fast, dead → take
13
+ * over. Released on exit via the returned handle.
14
+ */
15
+
16
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { MergeError } from "../errors.ts";
19
+ import { isProcessAlive } from "../worktree/tmux.ts";
20
+
21
+ export interface MergeLockHandle {
22
+ /** Path to the lock file on disk (useful for diagnostics / tests). */
23
+ readonly path: string;
24
+ /** Release the lock. Idempotent — safe to call multiple times. */
25
+ release(): void;
26
+ }
27
+
28
+ interface LockPayload {
29
+ pid: number;
30
+ acquiredAt: string;
31
+ targetBranch: string;
32
+ }
33
+
34
+ /**
35
+ * Sanitize a branch name for use in a filename.
36
+ * Replaces "/", "\\", and ":" with "-" so `feature/foo` becomes `feature-foo`.
37
+ */
38
+ export function sanitizeBranchForFilename(branch: string): string {
39
+ return branch.replace(/[/\\:]/g, "-");
40
+ }
41
+
42
+ /** Compute the lock file path for a given target branch. */
43
+ export function mergeLockPath(agentplateDir: string, targetBranch: string): string {
44
+ return join(agentplateDir, `merge-${sanitizeBranchForFilename(targetBranch)}.lock`);
45
+ }
46
+
47
+ /**
48
+ * Acquire the merge lock for a given target branch. Throws `MergeError` if
49
+ * another live `ap merge` is already running against this target. Stale locks
50
+ * (PID no longer alive) are taken over automatically.
51
+ *
52
+ * The caller MUST call `release()` on the returned handle when done.
53
+ */
54
+ export function acquireMergeLock(agentplateDir: string, targetBranch: string): MergeLockHandle {
55
+ const path = mergeLockPath(agentplateDir, targetBranch);
56
+ const payload: LockPayload = {
57
+ pid: process.pid,
58
+ acquiredAt: new Date().toISOString(),
59
+ targetBranch,
60
+ };
61
+ const serialized = JSON.stringify(payload);
62
+
63
+ const tryCreate = (): boolean => {
64
+ try {
65
+ writeFileSync(path, serialized, { flag: "wx" });
66
+ return true;
67
+ } catch (err: unknown) {
68
+ const code = (err as NodeJS.ErrnoException).code;
69
+ if (code === "EEXIST") return false;
70
+ throw err;
71
+ }
72
+ };
73
+
74
+ if (tryCreate()) {
75
+ return makeHandle(path);
76
+ }
77
+
78
+ // Lock file exists. Inspect the holder before failing.
79
+ const existing = readLockPayload(path);
80
+ const holderPid = existing?.pid;
81
+ const holderAlive = typeof holderPid === "number" && isProcessAlive(holderPid);
82
+
83
+ if (holderAlive) {
84
+ const since = existing?.acquiredAt ?? "unknown time";
85
+ throw new MergeError(
86
+ `Another ap merge is already running for "${targetBranch}" (pid ${holderPid}, acquired ${since}). Wait for it to finish, or remove ${path} if you are sure it is stale.`,
87
+ { branchName: targetBranch },
88
+ );
89
+ }
90
+
91
+ // Stale or unparseable lock — remove and retry once. If a third process
92
+ // won the race in between, surface that as a clear retry-soon error.
93
+ try {
94
+ unlinkSync(path);
95
+ } catch {
96
+ // File may have just been removed by another cleanup — fine.
97
+ }
98
+ if (tryCreate()) {
99
+ return makeHandle(path);
100
+ }
101
+
102
+ throw new MergeError(
103
+ `Another ap merge raced to acquire the lock for "${targetBranch}". Retry shortly.`,
104
+ { branchName: targetBranch },
105
+ );
106
+ }
107
+
108
+ function readLockPayload(path: string): LockPayload | null {
109
+ try {
110
+ const content = readFileSync(path, "utf8");
111
+ const parsed = JSON.parse(content) as unknown;
112
+ if (
113
+ parsed !== null &&
114
+ typeof parsed === "object" &&
115
+ "pid" in parsed &&
116
+ typeof (parsed as { pid: unknown }).pid === "number"
117
+ ) {
118
+ return parsed as LockPayload;
119
+ }
120
+ return null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ function makeHandle(path: string): MergeLockHandle {
127
+ let released = false;
128
+ return {
129
+ path,
130
+ release(): void {
131
+ if (released) return;
132
+ released = true;
133
+ try {
134
+ unlinkSync(path);
135
+ } catch {
136
+ // File may already be gone — not an error.
137
+ }
138
+ },
139
+ };
140
+ }