@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,964 @@
1
+ /**
2
+ * Tests for agentplate stop command.
3
+ *
4
+ * Uses real temp directories and real git repos for file I/O and config loading.
5
+ * Tmux and worktree operations are injected via the StopDeps DI interface instead of
6
+ * mock.module() to avoid the process-global mock leak issue
7
+ * (see loam record mx-56558b).
8
+ *
9
+ * WHY DI instead of mock.module: mock.module() in bun:test is process-global
10
+ * and leaks across test files. The DI approach (same pattern as coordinator.ts)
11
+ * ensures mocks are scoped to each test invocation.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+ import { mkdir, realpath } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { AgentError, ValidationError } from "../errors.ts";
18
+ import { createMailClient } from "../mail/client.ts";
19
+ import { createMailStore } from "../mail/store.ts";
20
+ import { openSessionStore } from "../sessions/compat.ts";
21
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
22
+ import type { AgentSession, MergeReadyPayload } from "../types.ts";
23
+ import { type StopDeps, stopCommand } from "./stop.ts";
24
+
25
+ // --- Fake Git (for branch deletion) ---
26
+
27
+ interface GitCallTracker {
28
+ deleteBranch: Array<{ repoRoot: string; branch: string }>;
29
+ }
30
+
31
+ function makeFakeGit(shouldSucceed = true): {
32
+ git: NonNullable<StopDeps["_git"]>;
33
+ calls: GitCallTracker;
34
+ } {
35
+ const calls: GitCallTracker = { deleteBranch: [] };
36
+ const git: NonNullable<StopDeps["_git"]> = {
37
+ deleteBranch: async (repoRoot: string, branch: string): Promise<boolean> => {
38
+ calls.deleteBranch.push({ repoRoot, branch });
39
+ return shouldSucceed;
40
+ },
41
+ };
42
+ return { git, calls };
43
+ }
44
+
45
+ // --- Fake Process (for headless agents) ---
46
+
47
+ /** Track calls to fake process for assertions. */
48
+ interface ProcessCallTracker {
49
+ isAlive: Array<{ pid: number; result: boolean }>;
50
+ killTree: Array<{ pid: number }>;
51
+ }
52
+
53
+ /** Build a fake process DI object with configurable PID liveness. */
54
+ function makeFakeProcess(pidAliveMap: Record<number, boolean> = {}): {
55
+ proc: NonNullable<StopDeps["_process"]>;
56
+ calls: ProcessCallTracker;
57
+ } {
58
+ const calls: ProcessCallTracker = {
59
+ isAlive: [],
60
+ killTree: [],
61
+ };
62
+
63
+ const proc: NonNullable<StopDeps["_process"]> = {
64
+ isAlive: (pid: number): boolean => {
65
+ const alive = pidAliveMap[pid] ?? false;
66
+ calls.isAlive.push({ pid, result: alive });
67
+ return alive;
68
+ },
69
+ killTree: async (pid: number): Promise<void> => {
70
+ calls.killTree.push({ pid });
71
+ },
72
+ };
73
+
74
+ return { proc, calls };
75
+ }
76
+
77
+ // --- Fake Tmux ---
78
+
79
+ /** Track calls to fake tmux for assertions. */
80
+ interface TmuxCallTracker {
81
+ isSessionAlive: Array<{ name: string; result: boolean }>;
82
+ killSession: Array<{ name: string }>;
83
+ }
84
+
85
+ /** Build a fake tmux DI object with configurable session liveness. */
86
+ function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
87
+ tmux: NonNullable<StopDeps["_tmux"]>;
88
+ calls: TmuxCallTracker;
89
+ } {
90
+ const calls: TmuxCallTracker = {
91
+ isSessionAlive: [],
92
+ killSession: [],
93
+ };
94
+
95
+ const tmux: NonNullable<StopDeps["_tmux"]> = {
96
+ isSessionAlive: async (name: string): Promise<boolean> => {
97
+ const alive = sessionAliveMap[name] ?? false;
98
+ calls.isSessionAlive.push({ name, result: alive });
99
+ return alive;
100
+ },
101
+ killSession: async (name: string): Promise<void> => {
102
+ calls.killSession.push({ name });
103
+ },
104
+ };
105
+
106
+ return { tmux, calls };
107
+ }
108
+
109
+ // --- Fake Worktree ---
110
+
111
+ /** Track calls to fake worktree for assertions. */
112
+ interface WorktreeCallTracker {
113
+ remove: Array<{
114
+ repoRoot: string;
115
+ path: string;
116
+ options?: { force?: boolean; forceBranch?: boolean };
117
+ }>;
118
+ }
119
+
120
+ /** Build a fake worktree DI object with configurable success/failure. */
121
+ function makeFakeWorktree(shouldFail = false): {
122
+ worktree: NonNullable<StopDeps["_worktree"]>;
123
+ calls: WorktreeCallTracker;
124
+ } {
125
+ const calls: WorktreeCallTracker = { remove: [] };
126
+
127
+ const worktree: NonNullable<StopDeps["_worktree"]> = {
128
+ remove: async (
129
+ repoRoot: string,
130
+ path: string,
131
+ options?: { force?: boolean; forceBranch?: boolean },
132
+ ): Promise<void> => {
133
+ calls.remove.push({ repoRoot, path, options });
134
+ if (shouldFail) {
135
+ throw new Error("worktree removal failed");
136
+ }
137
+ },
138
+ };
139
+
140
+ return { worktree, calls };
141
+ }
142
+
143
+ // --- Test Setup ---
144
+
145
+ let tempDir: string;
146
+ let agentplateDir: string;
147
+ const originalCwd = process.cwd();
148
+
149
+ /** Save sessions to the SessionStore (sessions.db) for test setup. */
150
+ function saveSessionsToDb(sessions: AgentSession[]): void {
151
+ const { store } = openSessionStore(agentplateDir);
152
+ try {
153
+ for (const session of sessions) {
154
+ store.upsert(session);
155
+ }
156
+ } finally {
157
+ store.close();
158
+ }
159
+ }
160
+
161
+ beforeEach(async () => {
162
+ process.chdir(originalCwd);
163
+
164
+ tempDir = await realpath(await createTempGitRepo());
165
+ agentplateDir = join(tempDir, ".agentplate");
166
+ await mkdir(agentplateDir, { recursive: true });
167
+
168
+ // Write a minimal config.yaml so loadConfig succeeds
169
+ await Bun.write(
170
+ join(agentplateDir, "config.yaml"),
171
+ ["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
172
+ "\n",
173
+ ),
174
+ );
175
+
176
+ // Override cwd so stop commands find our temp project
177
+ process.chdir(tempDir);
178
+ });
179
+
180
+ afterEach(async () => {
181
+ process.chdir(originalCwd);
182
+ await cleanupTempDir(tempDir);
183
+ });
184
+
185
+ // --- Helpers ---
186
+
187
+ function makeAgentSession(overrides: Partial<AgentSession> = {}): AgentSession {
188
+ return {
189
+ id: `session-${Date.now()}-my-builder`,
190
+ agentName: "my-builder",
191
+ capability: "builder",
192
+ worktreePath: join(tempDir, ".agentplate", "worktrees", "my-builder"),
193
+ branchName: "agentplate/my-builder/bead-123",
194
+ taskId: "bead-123",
195
+ tmuxSession: "agentplate-test-project-my-builder",
196
+ state: "working",
197
+ pid: 99999,
198
+ parentAgent: null,
199
+ depth: 2,
200
+ runId: null,
201
+ startedAt: new Date().toISOString(),
202
+ lastActivity: new Date().toISOString(),
203
+ escalationLevel: 0,
204
+ stalledSince: null,
205
+ transcriptPath: null,
206
+ ...overrides,
207
+ };
208
+ }
209
+
210
+ /** Capture stdout.write output during a function call. */
211
+ async function captureStdout(fn: () => Promise<void>): Promise<string> {
212
+ const chunks: string[] = [];
213
+ const originalWrite = process.stdout.write;
214
+ process.stdout.write = ((chunk: string) => {
215
+ chunks.push(chunk);
216
+ return true;
217
+ }) as typeof process.stdout.write;
218
+ try {
219
+ await fn();
220
+ } finally {
221
+ process.stdout.write = originalWrite;
222
+ }
223
+ return chunks.join("");
224
+ }
225
+
226
+ /** Capture stderr.write output during a function call. */
227
+ async function captureStderr(fn: () => Promise<void>): Promise<{ stderr: string; stdout: string }> {
228
+ const stderrChunks: string[] = [];
229
+ const stdoutChunks: string[] = [];
230
+ const origStderr = process.stderr.write;
231
+ const origStdout = process.stdout.write;
232
+ process.stderr.write = ((chunk: string) => {
233
+ stderrChunks.push(chunk);
234
+ return true;
235
+ }) as typeof process.stderr.write;
236
+ process.stdout.write = ((chunk: string) => {
237
+ stdoutChunks.push(chunk);
238
+ return true;
239
+ }) as typeof process.stdout.write;
240
+ try {
241
+ await fn();
242
+ } finally {
243
+ process.stderr.write = origStderr;
244
+ process.stdout.write = origStdout;
245
+ }
246
+ return { stderr: stderrChunks.join(""), stdout: stdoutChunks.join("") };
247
+ }
248
+
249
+ /** Build default deps with fake tmux, worktree, and git. */
250
+ function makeDeps(
251
+ sessionAliveMap: Record<string, boolean> = {},
252
+ worktreeConfig?: { shouldFail?: boolean },
253
+ gitConfig?: { shouldSucceed?: boolean },
254
+ ): {
255
+ deps: StopDeps;
256
+ tmuxCalls: TmuxCallTracker;
257
+ worktreeCalls: WorktreeCallTracker;
258
+ gitCalls: GitCallTracker;
259
+ } {
260
+ const { tmux, calls: tmuxCalls } = makeFakeTmux(sessionAliveMap);
261
+ const { worktree, calls: worktreeCalls } = makeFakeWorktree(worktreeConfig?.shouldFail);
262
+ const { git, calls: gitCalls } = makeFakeGit(gitConfig?.shouldSucceed ?? true);
263
+ return {
264
+ deps: { _tmux: tmux, _worktree: worktree, _git: git },
265
+ tmuxCalls,
266
+ worktreeCalls,
267
+ gitCalls,
268
+ };
269
+ }
270
+
271
+ // --- Tests ---
272
+
273
+ describe("stopCommand validation", () => {
274
+ test("throws ValidationError when agent name is empty string", async () => {
275
+ const { deps } = makeDeps();
276
+ await expect(stopCommand("", {}, deps)).rejects.toThrow(ValidationError);
277
+ });
278
+
279
+ test("throws AgentError when agent not found", async () => {
280
+ const { deps } = makeDeps();
281
+ await expect(stopCommand("nonexistent-agent", {}, deps)).rejects.toThrow(AgentError);
282
+ });
283
+
284
+ test("throws AgentError when agent is already completed (without --clean-worktree)", async () => {
285
+ const session = makeAgentSession({ state: "completed" });
286
+ saveSessionsToDb([session]);
287
+
288
+ const { deps } = makeDeps();
289
+ await expect(stopCommand("my-builder", {}, deps)).rejects.toThrow(AgentError);
290
+ await expect(stopCommand("my-builder", {}, deps)).rejects.toThrow(/already completed/);
291
+ await expect(stopCommand("my-builder", {}, deps)).rejects.toThrow(/--clean-worktree/);
292
+ });
293
+
294
+ test("succeeds when agent is zombie (cleanup, no error)", async () => {
295
+ const session = makeAgentSession({ state: "zombie" });
296
+ saveSessionsToDb([session]);
297
+
298
+ const { deps } = makeDeps({ [session.tmuxSession]: false });
299
+ const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
300
+
301
+ expect(output).toContain("Agent stopped");
302
+ expect(output).toContain("Zombie agent cleaned up");
303
+
304
+ const { store } = openSessionStore(agentplateDir);
305
+ const updated = store.getByName("my-builder");
306
+ store.close();
307
+ expect(updated?.state).toBe("completed");
308
+ });
309
+ });
310
+
311
+ describe("stopCommand zombie cleanup", () => {
312
+ test("zombie + --clean-worktree removes worktree", async () => {
313
+ const session = makeAgentSession({ state: "zombie" });
314
+ saveSessionsToDb([session]);
315
+
316
+ const { deps, worktreeCalls } = makeDeps({ [session.tmuxSession]: false });
317
+ const output = await captureStdout(() =>
318
+ stopCommand("my-builder", { cleanWorktree: true }, deps),
319
+ );
320
+
321
+ expect(output).toContain("Agent stopped");
322
+ expect(output).toContain("Zombie agent cleaned up");
323
+ expect(output).toContain(`Worktree removed: ${session.worktreePath}`);
324
+ expect(worktreeCalls.remove).toHaveLength(1);
325
+
326
+ const { store } = openSessionStore(agentplateDir);
327
+ const updated = store.getByName("my-builder");
328
+ store.close();
329
+ expect(updated?.state).toBe("completed");
330
+ });
331
+
332
+ test("zombie + --json includes wasZombie: true", async () => {
333
+ const session = makeAgentSession({ state: "zombie" });
334
+ saveSessionsToDb([session]);
335
+
336
+ const { deps } = makeDeps({ [session.tmuxSession]: false });
337
+ const output = await captureStdout(() => stopCommand("my-builder", { json: true }, deps));
338
+
339
+ const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
340
+ expect(parsed.success).toBe(true);
341
+ expect(parsed.stopped).toBe(true);
342
+ expect(parsed.wasZombie).toBe(true);
343
+ expect(parsed.agentName).toBe("my-builder");
344
+
345
+ const { store } = openSessionStore(agentplateDir);
346
+ const updated = store.getByName("my-builder");
347
+ store.close();
348
+ expect(updated?.state).toBe("completed");
349
+ });
350
+ });
351
+
352
+ describe("stopCommand completed agent cleanup", () => {
353
+ test("completed + --clean-worktree removes worktree and branch", async () => {
354
+ const session = makeAgentSession({ state: "completed" });
355
+ saveSessionsToDb([session]);
356
+
357
+ const { deps, tmuxCalls, worktreeCalls, gitCalls } = makeDeps();
358
+ const output = await captureStdout(() =>
359
+ stopCommand("my-builder", { cleanWorktree: true }, deps),
360
+ );
361
+
362
+ expect(output).toContain("Agent stopped");
363
+ expect(output).toContain("already completed");
364
+ expect(output).toContain(`Worktree removed`);
365
+ expect(output).toContain(`Branch deleted`);
366
+
367
+ // No kill operations
368
+ expect(tmuxCalls.isSessionAlive).toHaveLength(0);
369
+ expect(tmuxCalls.killSession).toHaveLength(0);
370
+
371
+ // Worktree removed
372
+ expect(worktreeCalls.remove).toHaveLength(1);
373
+
374
+ // Branch deleted
375
+ expect(gitCalls.deleteBranch).toHaveLength(1);
376
+ expect(gitCalls.deleteBranch[0]?.branch).toBe(session.branchName);
377
+ });
378
+
379
+ test("completed + --clean-worktree + --json includes wasCompleted: true", async () => {
380
+ const session = makeAgentSession({ state: "completed" });
381
+ saveSessionsToDb([session]);
382
+
383
+ const { deps } = makeDeps();
384
+ const output = await captureStdout(() =>
385
+ stopCommand("my-builder", { cleanWorktree: true, json: true }, deps),
386
+ );
387
+
388
+ const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
389
+ expect(parsed.success).toBe(true);
390
+ expect(parsed.stopped).toBe(true);
391
+ expect(parsed.wasCompleted).toBe(true);
392
+ expect(parsed.tmuxKilled).toBe(false);
393
+ expect(parsed.pidKilled).toBe(false);
394
+ expect(parsed.worktreeRemoved).toBe(true);
395
+ expect(parsed.branchDeleted).toBe(true);
396
+ });
397
+
398
+ test("branch deletion failure is non-fatal for completed agent", async () => {
399
+ const session = makeAgentSession({ state: "completed" });
400
+ saveSessionsToDb([session]);
401
+
402
+ const { deps } = makeDeps({}, {}, { shouldSucceed: false });
403
+ // Should not throw even if branch deletion fails
404
+ const output = await captureStdout(() =>
405
+ stopCommand("my-builder", { cleanWorktree: true }, deps),
406
+ );
407
+ expect(output).toContain("Agent stopped");
408
+ });
409
+ });
410
+
411
+ describe("stopCommand stop behavior", () => {
412
+ test("stops a working agent (kills tmux, marks completed)", async () => {
413
+ const session = makeAgentSession({ state: "working" });
414
+ saveSessionsToDb([session]);
415
+
416
+ const { deps, tmuxCalls } = makeDeps({ [session.tmuxSession]: true });
417
+ const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
418
+
419
+ expect(output).toContain("Agent stopped");
420
+ expect(output).toContain("my-builder");
421
+ expect(output).toContain(`Tmux session killed: ${session.tmuxSession}`);
422
+ expect(tmuxCalls.killSession).toHaveLength(1);
423
+ expect(tmuxCalls.killSession[0]?.name).toBe(session.tmuxSession);
424
+
425
+ // Verify state was updated in DB
426
+ const { store } = openSessionStore(agentplateDir);
427
+ const updated = store.getByName("my-builder");
428
+ store.close();
429
+ expect(updated?.state).toBe("completed");
430
+ });
431
+
432
+ test("stops a booting agent", async () => {
433
+ const session = makeAgentSession({ state: "booting" });
434
+ saveSessionsToDb([session]);
435
+
436
+ const { deps, tmuxCalls } = makeDeps({ [session.tmuxSession]: true });
437
+ await stopCommand("my-builder", {}, deps);
438
+
439
+ expect(tmuxCalls.killSession).toHaveLength(1);
440
+ const { store } = openSessionStore(agentplateDir);
441
+ const updated = store.getByName("my-builder");
442
+ store.close();
443
+ expect(updated?.state).toBe("completed");
444
+ });
445
+
446
+ test("stops a stalled agent", async () => {
447
+ const session = makeAgentSession({ state: "stalled" });
448
+ saveSessionsToDb([session]);
449
+
450
+ const { deps, tmuxCalls } = makeDeps({ [session.tmuxSession]: true });
451
+ await stopCommand("my-builder", {}, deps);
452
+
453
+ expect(tmuxCalls.killSession).toHaveLength(1);
454
+ const { store } = openSessionStore(agentplateDir);
455
+ const updated = store.getByName("my-builder");
456
+ store.close();
457
+ expect(updated?.state).toBe("completed");
458
+ });
459
+
460
+ test("handles already-dead tmux session gracefully (skips kill)", async () => {
461
+ const session = makeAgentSession({ state: "working" });
462
+ saveSessionsToDb([session]);
463
+
464
+ // tmux session is NOT alive
465
+ const { deps, tmuxCalls } = makeDeps({ [session.tmuxSession]: false });
466
+ const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
467
+
468
+ expect(output).toContain("Tmux session was already dead");
469
+ expect(tmuxCalls.killSession).toHaveLength(0);
470
+
471
+ // Session should still be marked completed
472
+ const { store } = openSessionStore(agentplateDir);
473
+ const updated = store.getByName("my-builder");
474
+ store.close();
475
+ expect(updated?.state).toBe("completed");
476
+ });
477
+
478
+ test("stopping a lead writes lead_completed pending-nudge for coordinator", async () => {
479
+ // Regression test for agentplate-49a7:
480
+ // The lead_completed nudge now fires from `ap stop` (real completion signal),
481
+ // not from the per-turn Stop hook, which was spamming the coordinator.
482
+ const session = makeAgentSession({
483
+ agentName: "lead-alpha",
484
+ capability: "lead",
485
+ state: "working",
486
+ tmuxSession: "agentplate-lead-alpha",
487
+ });
488
+ saveSessionsToDb([session]);
489
+
490
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
491
+ await stopCommand("lead-alpha", {}, deps);
492
+
493
+ const markerPath = join(agentplateDir, "pending-nudges", "coordinator.json");
494
+ const markerFile = Bun.file(markerPath);
495
+ expect(await markerFile.exists()).toBe(true);
496
+
497
+ const marker = JSON.parse(await markerFile.text());
498
+ expect(marker.from).toBe("lead-alpha");
499
+ expect(marker.reason).toBe("lead_completed");
500
+ expect(marker.subject).toContain("lead-alpha");
501
+ expect(marker.messageId).toContain("auto-nudge-lead-alpha-");
502
+ expect(marker.createdAt).toBeDefined();
503
+ });
504
+
505
+ test("lead exiting without merge_ready gets 'no merge_ready sent' subject (agentplate-41fe)", async () => {
506
+ const session = makeAgentSession({
507
+ agentName: "lead-beta",
508
+ capability: "lead",
509
+ state: "working",
510
+ tmuxSession: "agentplate-lead-beta",
511
+ });
512
+ saveSessionsToDb([session]);
513
+
514
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
515
+ await stopCommand("lead-beta", {}, deps);
516
+
517
+ const markerPath = join(agentplateDir, "pending-nudges", "coordinator.json");
518
+ const marker = JSON.parse(await Bun.file(markerPath).text());
519
+ expect(marker.subject).toBe(
520
+ "Lead lead-beta exited — no merge_ready sent, needs coordinator follow-up",
521
+ );
522
+ });
523
+
524
+ test("lead with one merge_ready gets branch-specific subject (agentplate-41fe)", async () => {
525
+ const session = makeAgentSession({
526
+ agentName: "lead-gamma",
527
+ capability: "lead",
528
+ state: "working",
529
+ tmuxSession: "agentplate-lead-gamma",
530
+ });
531
+ saveSessionsToDb([session]);
532
+
533
+ // Seed mail.db with a merge_ready message from this lead
534
+ const mailStore = createMailStore(join(agentplateDir, "mail.db"));
535
+ const mailClient = createMailClient(mailStore);
536
+ const payload: MergeReadyPayload = {
537
+ branch: "agentplate/lead-gamma/bead-42",
538
+ taskId: "bead-42",
539
+ agentName: "lead-gamma",
540
+ filesModified: ["src/foo.ts"],
541
+ };
542
+ mailClient.sendProtocol({
543
+ from: "lead-gamma",
544
+ to: "coordinator",
545
+ subject: "merge_ready: bead-42",
546
+ body: "ready",
547
+ type: "merge_ready",
548
+ payload,
549
+ });
550
+ mailClient.close();
551
+
552
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
553
+ await stopCommand("lead-gamma", {}, deps);
554
+
555
+ const markerPath = join(agentplateDir, "pending-nudges", "coordinator.json");
556
+ const marker = JSON.parse(await Bun.file(markerPath).text());
557
+ expect(marker.subject).toBe(
558
+ "Lead lead-gamma sent merge_ready for branch agentplate/lead-gamma/bead-42",
559
+ );
560
+ });
561
+
562
+ test("lead with multiple merge_ready messages lists all unique branches (agentplate-41fe)", async () => {
563
+ const session = makeAgentSession({
564
+ agentName: "lead-delta",
565
+ capability: "lead",
566
+ state: "working",
567
+ tmuxSession: "agentplate-lead-delta",
568
+ });
569
+ saveSessionsToDb([session]);
570
+
571
+ const mailStore = createMailStore(join(agentplateDir, "mail.db"));
572
+ const mailClient = createMailClient(mailStore);
573
+ for (const branch of ["agentplate/worker-a/t1", "agentplate/worker-b/t2"]) {
574
+ mailClient.sendProtocol({
575
+ from: "lead-delta",
576
+ to: "coordinator",
577
+ subject: `merge_ready: ${branch}`,
578
+ body: "ready",
579
+ type: "merge_ready",
580
+ payload: {
581
+ branch,
582
+ taskId: branch.split("/")[2] ?? "unknown",
583
+ agentName: "lead-delta",
584
+ filesModified: [],
585
+ },
586
+ });
587
+ }
588
+ mailClient.close();
589
+
590
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
591
+ await stopCommand("lead-delta", {}, deps);
592
+
593
+ const markerPath = join(agentplateDir, "pending-nudges", "coordinator.json");
594
+ const marker = JSON.parse(await Bun.file(markerPath).text());
595
+ expect(marker.subject).toContain("Lead lead-delta sent 2 merge_ready");
596
+ expect(marker.subject).toContain("agentplate/worker-a/t1");
597
+ expect(marker.subject).toContain("agentplate/worker-b/t2");
598
+ });
599
+
600
+ test("merge_ready messages from other agents do not influence the subject (agentplate-41fe)", async () => {
601
+ const session = makeAgentSession({
602
+ agentName: "lead-eps",
603
+ capability: "lead",
604
+ state: "working",
605
+ tmuxSession: "agentplate-lead-eps",
606
+ });
607
+ saveSessionsToDb([session]);
608
+
609
+ // A *different* lead has merge_ready in the same mail.db — should be ignored
610
+ const mailStore = createMailStore(join(agentplateDir, "mail.db"));
611
+ const mailClient = createMailClient(mailStore);
612
+ mailClient.sendProtocol({
613
+ from: "some-other-lead",
614
+ to: "coordinator",
615
+ subject: "merge_ready: x",
616
+ body: "ready",
617
+ type: "merge_ready",
618
+ payload: {
619
+ branch: "agentplate/other/x",
620
+ taskId: "x",
621
+ agentName: "some-other-lead",
622
+ filesModified: [],
623
+ },
624
+ });
625
+ mailClient.close();
626
+
627
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
628
+ await stopCommand("lead-eps", {}, deps);
629
+
630
+ const markerPath = join(agentplateDir, "pending-nudges", "coordinator.json");
631
+ const marker = JSON.parse(await Bun.file(markerPath).text());
632
+ expect(marker.subject).toBe(
633
+ "Lead lead-eps exited — no merge_ready sent, needs coordinator follow-up",
634
+ );
635
+ });
636
+
637
+ test("lead falls back to historical subject when mail store cannot be opened (agentplate-7291)", async () => {
638
+ const session = makeAgentSession({
639
+ agentName: "lead-zeta",
640
+ capability: "lead",
641
+ state: "working",
642
+ tmuxSession: "agentplate-lead-zeta",
643
+ });
644
+ saveSessionsToDb([session]);
645
+
646
+ // Make mail.db un-openable by creating a directory at that path. SQLite
647
+ // cannot open a directory as a database, so createMailStore() throws and
648
+ // buildLeadCompletedSubject hits its outer-catch fallback.
649
+ const mailDbPath = join(agentplateDir, "mail.db");
650
+ await mkdir(mailDbPath, { recursive: true });
651
+
652
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
653
+ await stopCommand("lead-zeta", {}, deps);
654
+
655
+ const markerPath = join(agentplateDir, "pending-nudges", "coordinator.json");
656
+ const marker = JSON.parse(await Bun.file(markerPath).text());
657
+ expect(marker.subject).toBe(
658
+ "Lead lead-zeta completed — check mail for merge_ready/worker_done",
659
+ );
660
+ });
661
+
662
+ test("lead with malformed merge_ready payload skips that message (agentplate-7291)", async () => {
663
+ const session = makeAgentSession({
664
+ agentName: "lead-eta",
665
+ capability: "lead",
666
+ state: "working",
667
+ tmuxSession: "agentplate-lead-eta",
668
+ });
669
+ saveSessionsToDb([session]);
670
+
671
+ // Insert two merge_ready rows directly via the store: one with a valid
672
+ // MergeReadyPayload, one with a non-JSON payload string. sendProtocol
673
+ // would JSON.stringify any payload, so it cannot produce a malformed
674
+ // row — the low-level store accepts the payload column verbatim. The
675
+ // loop must skip the malformed one (inner catch + continue) and use
676
+ // the valid one, yielding the single-branch subject variant.
677
+ const mailStore = createMailStore(join(agentplateDir, "mail.db"));
678
+ const validPayload: MergeReadyPayload = {
679
+ branch: "agentplate/lead-eta/bead-99",
680
+ taskId: "bead-99",
681
+ agentName: "lead-eta",
682
+ filesModified: ["src/x.ts"],
683
+ };
684
+ mailStore.insert({
685
+ id: "msg-valid",
686
+ from: "lead-eta",
687
+ to: "coordinator",
688
+ subject: "merge_ready: bead-99",
689
+ body: "ready",
690
+ type: "merge_ready",
691
+ priority: "normal",
692
+ threadId: null,
693
+ payload: JSON.stringify(validPayload),
694
+ });
695
+ mailStore.insert({
696
+ id: "msg-malformed",
697
+ from: "lead-eta",
698
+ to: "coordinator",
699
+ subject: "merge_ready: broken",
700
+ body: "ready",
701
+ type: "merge_ready",
702
+ priority: "normal",
703
+ threadId: null,
704
+ payload: "not-json{",
705
+ });
706
+ mailStore.close();
707
+
708
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
709
+ await stopCommand("lead-eta", {}, deps);
710
+
711
+ const markerPath = join(agentplateDir, "pending-nudges", "coordinator.json");
712
+ const marker = JSON.parse(await Bun.file(markerPath).text());
713
+ expect(marker.subject).toBe(
714
+ "Lead lead-eta sent merge_ready for branch agentplate/lead-eta/bead-99",
715
+ );
716
+ });
717
+
718
+ test("stopping a non-lead agent does NOT write lead_completed pending-nudge", async () => {
719
+ const session = makeAgentSession({ state: "working", capability: "builder" });
720
+ saveSessionsToDb([session]);
721
+
722
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
723
+ await stopCommand("my-builder", {}, deps);
724
+
725
+ const markerPath = join(agentplateDir, "pending-nudges", "coordinator.json");
726
+ const markerFile = Bun.file(markerPath);
727
+ expect(await markerFile.exists()).toBe(false);
728
+ });
729
+ });
730
+
731
+ describe("stopCommand --json output", () => {
732
+ test("--json outputs correct JSON shape", async () => {
733
+ const session = makeAgentSession({ state: "working" });
734
+ saveSessionsToDb([session]);
735
+
736
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
737
+ const output = await captureStdout(() => stopCommand("my-builder", { json: true }, deps));
738
+
739
+ const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
740
+ expect(parsed.success).toBe(true);
741
+ expect(parsed.command).toBe("stop");
742
+ expect(parsed.stopped).toBe(true);
743
+ expect(parsed.agentName).toBe("my-builder");
744
+ expect(parsed.sessionId).toBe(session.id);
745
+ expect(parsed.capability).toBe("builder");
746
+ expect(parsed.tmuxKilled).toBe(true);
747
+ expect(parsed.worktreeRemoved).toBe(false);
748
+ expect(parsed.force).toBe(false);
749
+ });
750
+
751
+ test("--force flag is passed through to JSON output", async () => {
752
+ const session = makeAgentSession({ state: "working" });
753
+ saveSessionsToDb([session]);
754
+
755
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
756
+ const output = await captureStdout(() =>
757
+ stopCommand("my-builder", { json: true, force: true }, deps),
758
+ );
759
+
760
+ const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
761
+ expect(parsed.force).toBe(true);
762
+ });
763
+ });
764
+
765
+ describe("stopCommand --clean-worktree", () => {
766
+ test("--clean-worktree removes worktree after stopping", async () => {
767
+ const session = makeAgentSession({ state: "working" });
768
+ saveSessionsToDb([session]);
769
+
770
+ const { deps, worktreeCalls } = makeDeps({ [session.tmuxSession]: true });
771
+ const output = await captureStdout(() =>
772
+ stopCommand("my-builder", { cleanWorktree: true }, deps),
773
+ );
774
+
775
+ expect(output).toContain(`Worktree removed: ${session.worktreePath}`);
776
+ expect(worktreeCalls.remove).toHaveLength(1);
777
+ expect(worktreeCalls.remove[0]?.path).toBe(session.worktreePath);
778
+ });
779
+
780
+ test("--clean-worktree with --force passes force to removeWorktree (forceBranch is always false)", async () => {
781
+ const session = makeAgentSession({ state: "working" });
782
+ saveSessionsToDb([session]);
783
+
784
+ const { deps, worktreeCalls } = makeDeps({ [session.tmuxSession]: true });
785
+ await captureStdout(() =>
786
+ stopCommand("my-builder", { cleanWorktree: true, force: true }, deps),
787
+ );
788
+
789
+ expect(worktreeCalls.remove).toHaveLength(1);
790
+ expect(worktreeCalls.remove[0]?.options?.force).toBe(true);
791
+ // forceBranch is always false because branch deletion is handled separately via git branch -D
792
+ expect(worktreeCalls.remove[0]?.options?.forceBranch).toBe(false);
793
+ });
794
+
795
+ test("--clean-worktree also deletes the branch", async () => {
796
+ const session = makeAgentSession({ state: "working" });
797
+ saveSessionsToDb([session]);
798
+
799
+ const { deps, gitCalls } = makeDeps({ [session.tmuxSession]: true });
800
+ const output = await captureStdout(() =>
801
+ stopCommand("my-builder", { cleanWorktree: true }, deps),
802
+ );
803
+
804
+ expect(gitCalls.deleteBranch).toHaveLength(1);
805
+ expect(gitCalls.deleteBranch[0]?.branch).toBe(session.branchName);
806
+ expect(output).toContain("Branch deleted");
807
+ });
808
+
809
+ test("branch deletion failure is non-fatal (agent still stopped)", async () => {
810
+ const session = makeAgentSession({ state: "working" });
811
+ saveSessionsToDb([session]);
812
+
813
+ const { deps } = makeDeps({ [session.tmuxSession]: true }, {}, { shouldSucceed: false });
814
+ const output = await captureStdout(() =>
815
+ stopCommand("my-builder", { cleanWorktree: true }, deps),
816
+ );
817
+ expect(output).toContain("Agent stopped");
818
+ const { store } = openSessionStore(agentplateDir);
819
+ const updated = store.getByName("my-builder");
820
+ store.close();
821
+ expect(updated?.state).toBe("completed");
822
+ });
823
+
824
+ test("--clean-worktree failure is non-fatal (agent still stopped, warning on stdout)", async () => {
825
+ const session = makeAgentSession({ state: "working" });
826
+ saveSessionsToDb([session]);
827
+
828
+ const { deps } = makeDeps({ [session.tmuxSession]: true }, { shouldFail: true });
829
+ const { stdout } = await captureStderr(() =>
830
+ stopCommand("my-builder", { cleanWorktree: true }, deps),
831
+ );
832
+
833
+ // Agent was still stopped
834
+ expect(stdout).toContain("Agent stopped");
835
+ expect(stdout).toContain("my-builder");
836
+ // Warning written to stdout (via printWarning)
837
+ expect(stdout).toContain("Failed to remove worktree");
838
+
839
+ // Session is marked completed despite worktree failure
840
+ const { store } = openSessionStore(agentplateDir);
841
+ const updated = store.getByName("my-builder");
842
+ store.close();
843
+ expect(updated?.state).toBe("completed");
844
+ });
845
+
846
+ test("--clean-worktree with --json reflects worktreeRemoved=false on failure", async () => {
847
+ const session = makeAgentSession({ state: "working" });
848
+ saveSessionsToDb([session]);
849
+
850
+ const { deps } = makeDeps({ [session.tmuxSession]: true }, { shouldFail: true });
851
+ const { stdout } = await captureStderr(() =>
852
+ stopCommand("my-builder", { cleanWorktree: true, json: true }, deps),
853
+ );
854
+
855
+ const parsed = JSON.parse(stdout.trim()) as Record<string, unknown>;
856
+ expect(parsed.stopped).toBe(true);
857
+ expect(parsed.worktreeRemoved).toBe(false);
858
+ });
859
+ });
860
+
861
+ describe("stopCommand headless agents", () => {
862
+ const HEADLESS_PID = 99999;
863
+
864
+ function makeHeadlessSession(overrides: Partial<AgentSession> = {}): AgentSession {
865
+ return makeAgentSession({
866
+ tmuxSession: "",
867
+ pid: HEADLESS_PID,
868
+ ...overrides,
869
+ });
870
+ }
871
+
872
+ function makeHeadlessDeps(
873
+ pidAliveMap: Record<number, boolean> = {},
874
+ worktreeConfig?: { shouldFail?: boolean },
875
+ ): {
876
+ deps: StopDeps;
877
+ tmuxCalls: TmuxCallTracker;
878
+ procCalls: ProcessCallTracker;
879
+ worktreeCalls: WorktreeCallTracker;
880
+ gitCalls: GitCallTracker;
881
+ } {
882
+ const { tmux, calls: tmuxCalls } = makeFakeTmux({});
883
+ const { proc, calls: procCalls } = makeFakeProcess(pidAliveMap);
884
+ const { worktree, calls: worktreeCalls } = makeFakeWorktree(worktreeConfig?.shouldFail);
885
+ const { git, calls: gitCalls } = makeFakeGit();
886
+ return {
887
+ deps: { _tmux: tmux, _worktree: worktree, _process: proc, _git: git },
888
+ tmuxCalls,
889
+ procCalls,
890
+ worktreeCalls,
891
+ gitCalls,
892
+ };
893
+ }
894
+
895
+ test("stops headless agent by killing process tree (no tmux interaction)", async () => {
896
+ const session = makeHeadlessSession({ state: "working" });
897
+ saveSessionsToDb([session]);
898
+
899
+ const { deps, tmuxCalls, procCalls } = makeHeadlessDeps({ [HEADLESS_PID]: true });
900
+ const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
901
+
902
+ // PID was killed
903
+ expect(procCalls.killTree).toHaveLength(1);
904
+ expect(procCalls.killTree[0]?.pid).toBe(HEADLESS_PID);
905
+ // Tmux was NOT touched
906
+ expect(tmuxCalls.isSessionAlive).toHaveLength(0);
907
+ expect(tmuxCalls.killSession).toHaveLength(0);
908
+
909
+ expect(output).toContain("Agent stopped");
910
+ expect(output).toContain("Process tree killed");
911
+ expect(output).toContain(String(HEADLESS_PID));
912
+
913
+ const { store } = openSessionStore(agentplateDir);
914
+ const updated = store.getByName("my-builder");
915
+ store.close();
916
+ expect(updated?.state).toBe("completed");
917
+ });
918
+
919
+ test("handles headless agent with already-dead PID gracefully", async () => {
920
+ const session = makeHeadlessSession({ state: "working" });
921
+ saveSessionsToDb([session]);
922
+
923
+ // PID is NOT alive
924
+ const { deps, procCalls } = makeHeadlessDeps({ [HEADLESS_PID]: false });
925
+ const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
926
+
927
+ expect(procCalls.killTree).toHaveLength(0);
928
+ expect(output).toContain("Agent stopped");
929
+ expect(output).toContain("Process was already dead");
930
+
931
+ const { store } = openSessionStore(agentplateDir);
932
+ const updated = store.getByName("my-builder");
933
+ store.close();
934
+ expect(updated?.state).toBe("completed");
935
+ });
936
+
937
+ test("--json output includes pidKilled for headless agent", async () => {
938
+ const session = makeHeadlessSession({ state: "working" });
939
+ saveSessionsToDb([session]);
940
+
941
+ const { deps } = makeHeadlessDeps({ [HEADLESS_PID]: true });
942
+ const output = await captureStdout(() => stopCommand("my-builder", { json: true }, deps));
943
+
944
+ const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
945
+ expect(parsed.success).toBe(true);
946
+ expect(parsed.stopped).toBe(true);
947
+ expect(parsed.pidKilled).toBe(true);
948
+ expect(parsed.tmuxKilled).toBe(false);
949
+ expect(parsed.agentName).toBe("my-builder");
950
+ });
951
+
952
+ test("--clean-worktree works for headless agent", async () => {
953
+ const session = makeHeadlessSession({ state: "working" });
954
+ saveSessionsToDb([session]);
955
+
956
+ const { deps, worktreeCalls } = makeHeadlessDeps({ [HEADLESS_PID]: true });
957
+ const output = await captureStdout(() =>
958
+ stopCommand("my-builder", { cleanWorktree: true }, deps),
959
+ );
960
+
961
+ expect(output).toContain(`Worktree removed: ${session.worktreePath}`);
962
+ expect(worktreeCalls.remove).toHaveLength(1);
963
+ });
964
+ });