@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,1501 @@
1
+ /**
2
+ * Tests for the CLI mail command handlers.
3
+ *
4
+ * Tests CLI-level behavior like flag parsing and output formatting.
5
+ * Uses real SQLite databases in temp directories (no mocking).
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdir, mkdtemp, readdir } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { createEventStore } from "../events/store.ts";
13
+ import { stripAnsi } from "../logging/color.ts";
14
+ import { createMailClient } from "../mail/client.ts";
15
+ import { createMailStore } from "../mail/store.ts";
16
+ import { cleanupTempDir } from "../test-helpers.ts";
17
+ import type { StoredEvent } from "../types.ts";
18
+ import { AUTO_NUDGE_TYPES, isDispatchNudge, mailCommand, shouldAutoNudge } from "./mail.ts";
19
+
20
+ describe("mailCommand", () => {
21
+ let tempDir: string;
22
+ let origCwd: string;
23
+ let origWrite: typeof process.stdout.write;
24
+ let origStderrWrite: typeof process.stderr.write;
25
+ let output: string;
26
+ let stderrOutput: string;
27
+
28
+ beforeEach(async () => {
29
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-mail-cmd-test-"));
30
+ await mkdir(join(tempDir, ".agentplate"), { recursive: true });
31
+
32
+ // Seed some messages via the store directly
33
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
34
+ const client = createMailClient(store);
35
+ client.send({
36
+ from: "orchestrator",
37
+ to: "builder-1",
38
+ subject: "Build task",
39
+ body: "Implement feature X",
40
+ });
41
+ client.send({
42
+ from: "orchestrator",
43
+ to: "scout-1",
44
+ subject: "Explore API",
45
+ body: "Investigate endpoints",
46
+ });
47
+ client.close();
48
+
49
+ // Change cwd to temp dir so the command finds .agentplate/mail.db
50
+ origCwd = process.cwd();
51
+ process.chdir(tempDir);
52
+
53
+ // Capture stdout
54
+ output = "";
55
+ origWrite = process.stdout.write;
56
+ process.stdout.write = ((chunk: string) => {
57
+ output += chunk;
58
+ return true;
59
+ }) as typeof process.stdout.write;
60
+
61
+ // Capture stderr
62
+ stderrOutput = "";
63
+ origStderrWrite = process.stderr.write;
64
+ process.stderr.write = ((chunk: string) => {
65
+ stderrOutput += chunk;
66
+ return true;
67
+ }) as typeof process.stderr.write;
68
+ });
69
+
70
+ afterEach(async () => {
71
+ process.stdout.write = origWrite;
72
+ process.stderr.write = origStderrWrite;
73
+ process.chdir(origCwd);
74
+ await cleanupTempDir(tempDir);
75
+ });
76
+
77
+ describe("list", () => {
78
+ test("--unread shows all unread messages globally", async () => {
79
+ await mailCommand(["list", "--unread"]);
80
+ expect(output).toContain("Build task");
81
+ expect(output).toContain("Explore API");
82
+ expect(output).toContain("Total: 2 messages");
83
+ });
84
+
85
+ test("--agent filters by recipient (alias for --to)", async () => {
86
+ await mailCommand(["list", "--agent", "builder-1"]);
87
+ expect(output).toContain("Build task");
88
+ expect(output).not.toContain("Explore API");
89
+ expect(output).toContain("Total: 1 message");
90
+ });
91
+
92
+ test("--agent combined with --unread shows only unread for that agent", async () => {
93
+ // Mark builder-1's message as read
94
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
95
+ const client = createMailClient(store);
96
+ const msgs = client.list({ to: "builder-1" });
97
+ const msgId = msgs[0]?.id;
98
+ expect(msgId).toBeTruthy();
99
+ if (msgId) {
100
+ client.markRead(msgId);
101
+ }
102
+ client.close();
103
+
104
+ await mailCommand(["list", "--agent", "builder-1", "--unread"]);
105
+ expect(output).toContain("No messages found");
106
+ });
107
+
108
+ test("--to takes precedence over --agent when both provided", async () => {
109
+ await mailCommand(["list", "--to", "scout-1", "--agent", "builder-1"]);
110
+ // --to is checked first via getFlag, so it should win
111
+ expect(output).toContain("Explore API");
112
+ expect(output).not.toContain("Build task");
113
+ });
114
+
115
+ test("list without filters shows all messages", async () => {
116
+ await mailCommand(["list"]);
117
+ expect(output).toContain("Build task");
118
+ expect(output).toContain("Explore API");
119
+ expect(output).toContain("Total: 2 messages");
120
+ });
121
+
122
+ test("--type filters by message type", async () => {
123
+ // Add a typed message to the seeded inbox
124
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
125
+ const client = createMailClient(store);
126
+ client.send({
127
+ from: "lead-x",
128
+ to: "coordinator",
129
+ subject: "merge_ready: t1",
130
+ body: "ready to merge",
131
+ type: "merge_ready",
132
+ });
133
+ client.close();
134
+
135
+ await mailCommand(["list", "--type", "merge_ready"]);
136
+ expect(output).toContain("merge_ready: t1");
137
+ expect(output).not.toContain("Build task");
138
+ expect(output).not.toContain("Explore API");
139
+ expect(output).toContain("Total: 1 message");
140
+ });
141
+
142
+ test("--type combined with --from filters by both", async () => {
143
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
144
+ const client = createMailClient(store);
145
+ client.send({
146
+ from: "lead-x",
147
+ to: "coordinator",
148
+ subject: "merge_ready: t1",
149
+ body: "ready",
150
+ type: "merge_ready",
151
+ });
152
+ client.send({
153
+ from: "lead-y",
154
+ to: "coordinator",
155
+ subject: "merge_ready: t2",
156
+ body: "ready",
157
+ type: "merge_ready",
158
+ });
159
+ client.close();
160
+
161
+ await mailCommand(["list", "--from", "lead-x", "--type", "merge_ready"]);
162
+ expect(output).toContain("merge_ready: t1");
163
+ expect(output).not.toContain("merge_ready: t2");
164
+ });
165
+
166
+ test("--type rejects invalid type with ValidationError", async () => {
167
+ await expect(mailCommand(["list", "--type", "bogus"])).rejects.toThrow(/Invalid --type/);
168
+ });
169
+ });
170
+
171
+ describe("reply", () => {
172
+ test("reply to own sent message goes to original recipient", async () => {
173
+ // Get the message ID of the message orchestrator sent to builder-1
174
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
175
+ const client = createMailClient(store);
176
+ const msgs = client.list({ to: "builder-1" });
177
+ const originalId = msgs[0]?.id;
178
+ expect(originalId).toBeTruthy();
179
+ client.close();
180
+
181
+ if (!originalId) return;
182
+
183
+ // Reply as orchestrator (the original sender)
184
+ output = "";
185
+ await mailCommand(["reply", originalId, "--body", "Actually also do Y"]);
186
+
187
+ expect(output).toContain("Reply sent");
188
+
189
+ // Verify the reply went to builder-1, not back to orchestrator
190
+ const store2 = createMailStore(join(tempDir, ".agentplate", "mail.db"));
191
+ const client2 = createMailClient(store2);
192
+ const allMsgs = client2.list();
193
+ const replyMsg = allMsgs.find((m) => m.subject === "Re: Build task");
194
+ expect(replyMsg).toBeDefined();
195
+ expect(replyMsg?.from).toBe("orchestrator");
196
+ expect(replyMsg?.to).toBe("builder-1");
197
+ client2.close();
198
+ });
199
+
200
+ test("reply as recipient goes to original sender", async () => {
201
+ // Get the message ID
202
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
203
+ const client = createMailClient(store);
204
+ const msgs = client.list({ to: "builder-1" });
205
+ const originalId = msgs[0]?.id;
206
+ expect(originalId).toBeTruthy();
207
+ client.close();
208
+
209
+ if (!originalId) return;
210
+
211
+ // Reply as builder-1 (the recipient of the original)
212
+ output = "";
213
+ await mailCommand(["reply", originalId, "--body", "Done", "--agent", "builder-1"]);
214
+
215
+ expect(output).toContain("Reply sent");
216
+
217
+ // Verify the reply went to orchestrator (original sender)
218
+ const store2 = createMailStore(join(tempDir, ".agentplate", "mail.db"));
219
+ const client2 = createMailClient(store2);
220
+ const allMsgs = client2.list();
221
+ const replyMsg = allMsgs.find(
222
+ (m) => m.subject === "Re: Build task" && m.from === "builder-1",
223
+ );
224
+ expect(replyMsg).toBeDefined();
225
+ expect(replyMsg?.from).toBe("builder-1");
226
+ expect(replyMsg?.to).toBe("orchestrator");
227
+ client2.close();
228
+ });
229
+
230
+ test("reply with flags before positional ID extracts correct ID", async () => {
231
+ // Regression test for agentplate-6nq: flags before the positional ID
232
+ // caused the flag VALUE (e.g. 'scout') to be treated as the message ID.
233
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
234
+ const client = createMailClient(store);
235
+ const msgs = client.list({ to: "builder-1" });
236
+ const originalId = msgs[0]?.id;
237
+ expect(originalId).toBeTruthy();
238
+ client.close();
239
+
240
+ if (!originalId) return;
241
+
242
+ // Put --agent and --body flags BEFORE the positional message ID
243
+ output = "";
244
+ await mailCommand(["reply", "--agent", "scout-1", "--body", "Got it", originalId]);
245
+
246
+ expect(output).toContain("Reply sent");
247
+
248
+ // Verify the reply used the correct message ID (not 'scout-1' or 'Got it')
249
+ const store2 = createMailStore(join(tempDir, ".agentplate", "mail.db"));
250
+ const client2 = createMailClient(store2);
251
+ const allMsgs = client2.list();
252
+ const replyMsg = allMsgs.find((m) => m.subject === "Re: Build task" && m.from === "scout-1");
253
+ expect(replyMsg).toBeDefined();
254
+ expect(replyMsg?.body).toBe("Got it");
255
+ client2.close();
256
+ });
257
+ });
258
+
259
+ describe("read", () => {
260
+ test("read with flags before positional ID extracts correct ID", async () => {
261
+ // Regression test for agentplate-6nq: same fragile pattern existed in handleRead.
262
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
263
+ const client = createMailClient(store);
264
+ const msgs = client.list({ to: "builder-1" });
265
+ const originalId = msgs[0]?.id;
266
+ expect(originalId).toBeTruthy();
267
+ client.close();
268
+
269
+ if (!originalId) return;
270
+
271
+ // Although read doesn't currently use --agent, test that any unknown
272
+ // flags followed by values don't get treated as the positional ID
273
+ output = "";
274
+ await mailCommand(["read", originalId]);
275
+
276
+ expect(output).toContain("Marked as read");
277
+ });
278
+
279
+ test("read marks message as read", async () => {
280
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
281
+ const client = createMailClient(store);
282
+ const msgs = client.list({ to: "builder-1" });
283
+ const originalId = msgs[0]?.id;
284
+ expect(originalId).toBeTruthy();
285
+ client.close();
286
+
287
+ if (!originalId) return;
288
+
289
+ output = "";
290
+ await mailCommand(["read", originalId]);
291
+ expect(output).toContain("Marked as read");
292
+
293
+ // Reading again should show already read
294
+ output = "";
295
+ await mailCommand(["read", originalId]);
296
+ expect(output).toContain("already read");
297
+ });
298
+ });
299
+
300
+ describe("auto-nudge (pending nudge markers)", () => {
301
+ test("urgent message writes pending nudge marker instead of tmux keys", async () => {
302
+ await mailCommand([
303
+ "send",
304
+ "--to",
305
+ "builder-1",
306
+ "--subject",
307
+ "Fix NOW",
308
+ "--body",
309
+ "Production is down",
310
+ "--priority",
311
+ "urgent",
312
+ ]);
313
+
314
+ // Verify pending nudge marker was written
315
+ const markerPath = join(tempDir, ".agentplate", "pending-nudges", "builder-1.json");
316
+ const file = Bun.file(markerPath);
317
+ expect(await file.exists()).toBe(true);
318
+
319
+ const marker = JSON.parse(await file.text());
320
+ expect(marker.from).toBe("orchestrator");
321
+ expect(marker.reason).toBe("urgent priority");
322
+ expect(marker.subject).toBe("Fix NOW");
323
+ expect(marker.messageId).toBeTruthy();
324
+ expect(marker.createdAt).toBeTruthy();
325
+
326
+ // Output should mention queued nudge, not direct delivery
327
+ expect(output).toContain("Queued nudge");
328
+ expect(output).toContain("delivered on next prompt");
329
+ });
330
+
331
+ test("high priority message writes pending nudge marker", async () => {
332
+ await mailCommand([
333
+ "send",
334
+ "--to",
335
+ "scout-1",
336
+ "--subject",
337
+ "Important task",
338
+ "--body",
339
+ "Please prioritize",
340
+ "--priority",
341
+ "high",
342
+ ]);
343
+
344
+ const markerPath = join(tempDir, ".agentplate", "pending-nudges", "scout-1.json");
345
+ const file = Bun.file(markerPath);
346
+ expect(await file.exists()).toBe(true);
347
+
348
+ const marker = JSON.parse(await file.text());
349
+ expect(marker.reason).toBe("high priority");
350
+ });
351
+
352
+ test("worker_done type writes pending nudge marker regardless of priority", async () => {
353
+ await mailCommand([
354
+ "send",
355
+ "--to",
356
+ "orchestrator",
357
+ "--subject",
358
+ "Task complete",
359
+ "--body",
360
+ "Builder finished",
361
+ "--type",
362
+ "worker_done",
363
+ "--from",
364
+ "builder-1",
365
+ ]);
366
+
367
+ const markerPath = join(tempDir, ".agentplate", "pending-nudges", "orchestrator.json");
368
+ const file = Bun.file(markerPath);
369
+ expect(await file.exists()).toBe(true);
370
+
371
+ const marker = JSON.parse(await file.text());
372
+ expect(marker.reason).toBe("worker_done");
373
+ expect(marker.from).toBe("builder-1");
374
+ });
375
+
376
+ test("normal priority non-protocol message does NOT write marker", async () => {
377
+ await mailCommand(["send", "--to", "builder-1", "--subject", "FYI", "--body", "Just a note"]);
378
+
379
+ const nudgeDir = join(tempDir, ".agentplate", "pending-nudges");
380
+ try {
381
+ const files = await readdir(nudgeDir);
382
+ // No marker should exist for this normal-priority status message
383
+ expect(files.filter((f) => f === "builder-1.json")).toHaveLength(0);
384
+ } catch {
385
+ // Directory doesn't exist — that's fine, means no markers
386
+ }
387
+ });
388
+
389
+ test("mail check --inject surfaces pending nudge banner", async () => {
390
+ // Send an urgent message to create a pending nudge marker
391
+ await mailCommand([
392
+ "send",
393
+ "--to",
394
+ "builder-1",
395
+ "--subject",
396
+ "Critical fix",
397
+ "--body",
398
+ "Deploy hotfix",
399
+ "--priority",
400
+ "urgent",
401
+ ]);
402
+
403
+ // Now check as builder-1 with --inject
404
+ output = "";
405
+ await mailCommand(["check", "--inject", "--agent", "builder-1"]);
406
+
407
+ // Should contain the priority banner from the pending nudge
408
+ expect(output).toContain("PRIORITY");
409
+ expect(output).toContain("urgent priority");
410
+ expect(output).toContain("Critical fix");
411
+
412
+ // Should also contain the actual message (from mail check)
413
+ expect(output).toContain("Deploy hotfix");
414
+ });
415
+
416
+ test("pending nudge marker is cleared after mail check --inject", async () => {
417
+ // Send urgent message
418
+ await mailCommand([
419
+ "send",
420
+ "--to",
421
+ "builder-1",
422
+ "--subject",
423
+ "Fix it",
424
+ "--body",
425
+ "Broken",
426
+ "--priority",
427
+ "urgent",
428
+ ]);
429
+
430
+ // First check clears the marker
431
+ output = "";
432
+ await mailCommand(["check", "--inject", "--agent", "builder-1"]);
433
+ expect(output).toContain("PRIORITY");
434
+
435
+ // Second check should NOT have the priority banner
436
+ output = "";
437
+ await mailCommand(["check", "--inject", "--agent", "builder-1"]);
438
+ expect(output).not.toContain("PRIORITY");
439
+ });
440
+
441
+ test("json output for auto-nudge send does not include nudge banner", async () => {
442
+ await mailCommand([
443
+ "send",
444
+ "--to",
445
+ "builder-1",
446
+ "--subject",
447
+ "Urgent",
448
+ "--body",
449
+ "Fix",
450
+ "--priority",
451
+ "urgent",
452
+ "--json",
453
+ ]);
454
+
455
+ // JSON output should just have the message ID, not the nudge banner text
456
+ const parsed = JSON.parse(output.trim());
457
+ expect(parsed.id).toBeTruthy();
458
+ expect(output).not.toContain("Queued nudge");
459
+ });
460
+ });
461
+
462
+ describe("mail_sent event recording", () => {
463
+ test("mail send records mail_sent event to events.db", async () => {
464
+ await mailCommand([
465
+ "send",
466
+ "--to",
467
+ "builder-1",
468
+ "--subject",
469
+ "Test event",
470
+ "--body",
471
+ "Check events",
472
+ ]);
473
+
474
+ // Verify event was recorded
475
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
476
+ const store = createEventStore(eventsDbPath);
477
+ try {
478
+ const events: StoredEvent[] = store.getTimeline({
479
+ since: "2000-01-01T00:00:00Z",
480
+ });
481
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
482
+ expect(mailEvent).toBeDefined();
483
+ expect(mailEvent?.level).toBe("info");
484
+ expect(mailEvent?.agentName).toBe("orchestrator");
485
+
486
+ const data = JSON.parse(mailEvent?.data ?? "{}") as Record<string, unknown>;
487
+ expect(data.to).toBe("builder-1");
488
+ expect(data.subject).toBe("Test event");
489
+ expect(data.type).toBe("status");
490
+ expect(data.priority).toBe("normal");
491
+ expect(data.messageId).toBeTruthy();
492
+ } finally {
493
+ store.close();
494
+ }
495
+ });
496
+
497
+ test("mail send with custom --from records correct agentName", async () => {
498
+ await mailCommand([
499
+ "send",
500
+ "--to",
501
+ "orchestrator",
502
+ "--subject",
503
+ "Done",
504
+ "--body",
505
+ "Finished task",
506
+ "--from",
507
+ "builder-1",
508
+ "--type",
509
+ "worker_done",
510
+ ]);
511
+
512
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
513
+ const store = createEventStore(eventsDbPath);
514
+ try {
515
+ const events: StoredEvent[] = store.getTimeline({
516
+ since: "2000-01-01T00:00:00Z",
517
+ });
518
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
519
+ expect(mailEvent).toBeDefined();
520
+ expect(mailEvent?.agentName).toBe("builder-1");
521
+
522
+ const data = JSON.parse(mailEvent?.data ?? "{}") as Record<string, unknown>;
523
+ expect(data.to).toBe("orchestrator");
524
+ expect(data.type).toBe("worker_done");
525
+ } finally {
526
+ store.close();
527
+ }
528
+ });
529
+
530
+ test("mail send includes run_id when current-run.txt exists", async () => {
531
+ const runId = "run-test-mail-456";
532
+ await Bun.write(join(tempDir, ".agentplate", "current-run.txt"), runId);
533
+
534
+ await mailCommand([
535
+ "send",
536
+ "--to",
537
+ "builder-1",
538
+ "--subject",
539
+ "With run ID",
540
+ "--body",
541
+ "Test",
542
+ ]);
543
+
544
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
545
+ const store = createEventStore(eventsDbPath);
546
+ try {
547
+ const events: StoredEvent[] = store.getTimeline({
548
+ since: "2000-01-01T00:00:00Z",
549
+ });
550
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
551
+ expect(mailEvent).toBeDefined();
552
+ expect(mailEvent?.runId).toBe(runId);
553
+ } finally {
554
+ store.close();
555
+ }
556
+ });
557
+
558
+ test("mail send without current-run.txt records null runId", async () => {
559
+ await mailCommand(["send", "--to", "builder-1", "--subject", "No run", "--body", "Test"]);
560
+
561
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
562
+ const store = createEventStore(eventsDbPath);
563
+ try {
564
+ const events: StoredEvent[] = store.getTimeline({
565
+ since: "2000-01-01T00:00:00Z",
566
+ });
567
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
568
+ expect(mailEvent).toBeDefined();
569
+ expect(mailEvent?.runId).toBeNull();
570
+ } finally {
571
+ store.close();
572
+ }
573
+ });
574
+ });
575
+
576
+ describe("mail check debounce", () => {
577
+ test("mail check without --debounce flag always executes", async () => {
578
+ // Send first message
579
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
580
+ const client = createMailClient(store);
581
+ client.send({
582
+ from: "orchestrator",
583
+ to: "test-agent",
584
+ subject: "Message 1",
585
+ body: "First message",
586
+ });
587
+ client.close();
588
+
589
+ // First check
590
+ output = "";
591
+ await mailCommand(["check", "--inject", "--agent", "test-agent"]);
592
+ const firstOutput = output;
593
+
594
+ // Send second message
595
+ const store2 = createMailStore(join(tempDir, ".agentplate", "mail.db"));
596
+ const client2 = createMailClient(store2);
597
+ client2.send({
598
+ from: "orchestrator",
599
+ to: "test-agent",
600
+ subject: "Message 2",
601
+ body: "Second message",
602
+ });
603
+ client2.close();
604
+
605
+ // Second check immediately after
606
+ output = "";
607
+ await mailCommand(["check", "--inject", "--agent", "test-agent"]);
608
+ const secondOutput = output;
609
+
610
+ // Both should execute (no debouncing without flag)
611
+ expect(firstOutput).toContain("Message 1");
612
+ expect(secondOutput).toContain("Message 2");
613
+ });
614
+
615
+ test("mail check with --debounce 500 skips second check within window", async () => {
616
+ // First check with debounce
617
+ output = "";
618
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
619
+ expect(output).toContain("Build task");
620
+
621
+ // Second check immediately (within debounce window)
622
+ output = "";
623
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
624
+ // Should be skipped silently
625
+ expect(output).toBe("");
626
+ });
627
+
628
+ test("mail check with --debounce allows check after window expires", async () => {
629
+ // Send first message
630
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
631
+ const client = createMailClient(store);
632
+ client.send({
633
+ from: "orchestrator",
634
+ to: "debounce-test",
635
+ subject: "First",
636
+ body: "First check",
637
+ });
638
+ client.close();
639
+
640
+ // First check with debounce
641
+ output = "";
642
+ await mailCommand(["check", "--inject", "--agent", "debounce-test", "--debounce", "100"]);
643
+ expect(output).toContain("First check");
644
+
645
+ // Wait for debounce window to expire
646
+ await Bun.sleep(150);
647
+
648
+ // Send second message
649
+ const store2 = createMailStore(join(tempDir, ".agentplate", "mail.db"));
650
+ const client2 = createMailClient(store2);
651
+ client2.send({
652
+ from: "orchestrator",
653
+ to: "debounce-test",
654
+ subject: "Second",
655
+ body: "Second check",
656
+ });
657
+ client2.close();
658
+
659
+ // Second check after debounce window
660
+ output = "";
661
+ await mailCommand(["check", "--inject", "--agent", "debounce-test", "--debounce", "100"]);
662
+ expect(output).toContain("Second check");
663
+ });
664
+
665
+ test("mail check with --debounce 0 disables debouncing", async () => {
666
+ // Send first message
667
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
668
+ const client = createMailClient(store);
669
+ client.send({
670
+ from: "orchestrator",
671
+ to: "zero-debounce",
672
+ subject: "Msg 1",
673
+ body: "Message one",
674
+ });
675
+ client.close();
676
+
677
+ // First check with --debounce 0
678
+ output = "";
679
+ await mailCommand(["check", "--inject", "--agent", "zero-debounce", "--debounce", "0"]);
680
+ expect(output).toContain("Message one");
681
+
682
+ // Send second message immediately
683
+ const store2 = createMailStore(join(tempDir, ".agentplate", "mail.db"));
684
+ const client2 = createMailClient(store2);
685
+ client2.send({
686
+ from: "orchestrator",
687
+ to: "zero-debounce",
688
+ subject: "Msg 2",
689
+ body: "Message two",
690
+ });
691
+ client2.close();
692
+
693
+ // Second check immediately (should work with debounce 0)
694
+ output = "";
695
+ await mailCommand(["check", "--inject", "--agent", "zero-debounce", "--debounce", "0"]);
696
+ expect(output).toContain("Message two");
697
+ });
698
+
699
+ test("mail check debounce is per-agent", async () => {
700
+ // Check for builder-1 with debounce
701
+ output = "";
702
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
703
+ expect(output).toContain("Build task");
704
+
705
+ // Check for scout-1 immediately (different agent)
706
+ output = "";
707
+ await mailCommand(["check", "--agent", "scout-1", "--debounce", "500"]);
708
+ expect(output).toContain("Explore API");
709
+
710
+ // Check for builder-1 again (should be debounced)
711
+ output = "";
712
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
713
+ expect(output).toBe("");
714
+ });
715
+
716
+ test("mail check --debounce with invalid value throws ValidationError", async () => {
717
+ try {
718
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "invalid"]);
719
+ expect(true).toBe(false); // Should not reach here
720
+ } catch (err) {
721
+ expect(err).toBeInstanceOf(Error);
722
+ if (err instanceof Error) {
723
+ expect(err.message).toContain("must be a non-negative integer");
724
+ }
725
+ }
726
+ });
727
+
728
+ test("mail check --debounce with negative value throws ValidationError", async () => {
729
+ try {
730
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "-100"]);
731
+ expect(true).toBe(false);
732
+ } catch (err) {
733
+ expect(err).toBeInstanceOf(Error);
734
+ if (err instanceof Error) {
735
+ expect(err.message).toContain("must be a non-negative integer");
736
+ }
737
+ }
738
+ });
739
+
740
+ test("mail check --inject with --debounce skips check within window", async () => {
741
+ // First inject check with debounce
742
+ output = "";
743
+ await mailCommand(["check", "--inject", "--agent", "builder-1", "--debounce", "500"]);
744
+ expect(output).toContain("Build task");
745
+
746
+ // Second inject check immediately (should be debounced)
747
+ output = "";
748
+ await mailCommand(["check", "--inject", "--agent", "builder-1", "--debounce", "500"]);
749
+ expect(output).toBe("");
750
+ });
751
+
752
+ test("mail check debounce state persists across invocations", async () => {
753
+ // First check
754
+ output = "";
755
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
756
+ expect(output).toContain("Build task");
757
+
758
+ // Verify state file was created
759
+ const statePath = join(tempDir, ".agentplate", "mail-check-state.json");
760
+ const file = Bun.file(statePath);
761
+ expect(await file.exists()).toBe(true);
762
+
763
+ const state = JSON.parse(await file.text()) as Record<string, number>;
764
+ expect(state["builder-1"]).toBeTruthy();
765
+ expect(typeof state["builder-1"]).toBe("number");
766
+ });
767
+
768
+ test("corrupted debounce state file is handled gracefully", async () => {
769
+ // Write corrupted state file
770
+ const statePath = join(tempDir, ".agentplate", "mail-check-state.json");
771
+ await Bun.write(statePath, "not valid json");
772
+
773
+ // Should not throw, should treat as fresh state
774
+ output = "";
775
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
776
+ expect(output).toContain("Build task");
777
+
778
+ // State should be corrected
779
+ const state = JSON.parse(await Bun.file(statePath).text()) as Record<string, number>;
780
+ expect(state["builder-1"]).toBeTruthy();
781
+ });
782
+
783
+ test("mail check debounce only records timestamp when flag is provided", async () => {
784
+ const statePath = join(tempDir, ".agentplate", "mail-check-state.json");
785
+
786
+ // Check without debounce flag
787
+ await mailCommand(["check", "--agent", "builder-1"]);
788
+
789
+ // State file should not be created
790
+ expect(await Bun.file(statePath).exists()).toBe(false);
791
+
792
+ // Check with debounce flag
793
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
794
+
795
+ // Now state file should exist
796
+ expect(await Bun.file(statePath).exists()).toBe(true);
797
+ });
798
+ });
799
+
800
+ describe("broadcast", () => {
801
+ // Helper to create active agent sessions for broadcast testing
802
+ async function seedActiveSessions(): Promise<void> {
803
+ const { createSessionStore } = await import("../sessions/store.ts");
804
+ const sessionsDbPath = join(tempDir, ".agentplate", "sessions.db");
805
+ const sessionStore = createSessionStore(sessionsDbPath);
806
+
807
+ const sessions = [
808
+ {
809
+ id: "session-orchestrator",
810
+ agentName: "orchestrator",
811
+ capability: "coordinator",
812
+ worktreePath: "/worktrees/orchestrator",
813
+ branchName: "main",
814
+ taskId: "bead-001",
815
+ tmuxSession: "agentplate-test-orchestrator",
816
+ state: "working" as const,
817
+ pid: 12345,
818
+ parentAgent: null,
819
+ depth: 0,
820
+ runId: "run-001",
821
+ startedAt: new Date().toISOString(),
822
+ lastActivity: new Date().toISOString(),
823
+ escalationLevel: 0,
824
+ stalledSince: null,
825
+ transcriptPath: null,
826
+ },
827
+ {
828
+ id: "session-builder-1",
829
+ agentName: "builder-1",
830
+ capability: "builder",
831
+ worktreePath: "/worktrees/builder-1",
832
+ branchName: "builder-1",
833
+ taskId: "bead-002",
834
+ tmuxSession: "agentplate-test-builder-1",
835
+ state: "working" as const,
836
+ pid: 12346,
837
+ parentAgent: "orchestrator",
838
+ depth: 1,
839
+ runId: "run-001",
840
+ startedAt: new Date().toISOString(),
841
+ lastActivity: new Date().toISOString(),
842
+ escalationLevel: 0,
843
+ stalledSince: null,
844
+ transcriptPath: null,
845
+ },
846
+ {
847
+ id: "session-builder-2",
848
+ agentName: "builder-2",
849
+ capability: "builder",
850
+ worktreePath: "/worktrees/builder-2",
851
+ branchName: "builder-2",
852
+ taskId: "bead-003",
853
+ tmuxSession: "agentplate-test-builder-2",
854
+ state: "working" as const,
855
+ pid: 12347,
856
+ parentAgent: "orchestrator",
857
+ depth: 1,
858
+ runId: "run-001",
859
+ startedAt: new Date().toISOString(),
860
+ lastActivity: new Date().toISOString(),
861
+ escalationLevel: 0,
862
+ stalledSince: null,
863
+ transcriptPath: null,
864
+ },
865
+ {
866
+ id: "session-scout-1",
867
+ agentName: "scout-1",
868
+ capability: "scout",
869
+ worktreePath: "/worktrees/scout-1",
870
+ branchName: "scout-1",
871
+ taskId: "bead-004",
872
+ tmuxSession: "agentplate-test-scout-1",
873
+ state: "working" as const,
874
+ pid: 12348,
875
+ parentAgent: "orchestrator",
876
+ depth: 1,
877
+ runId: "run-001",
878
+ startedAt: new Date().toISOString(),
879
+ lastActivity: new Date().toISOString(),
880
+ escalationLevel: 0,
881
+ stalledSince: null,
882
+ transcriptPath: null,
883
+ },
884
+ ];
885
+
886
+ for (const session of sessions) {
887
+ sessionStore.upsert(session);
888
+ }
889
+
890
+ sessionStore.close();
891
+ }
892
+
893
+ test("@all broadcasts to all active agents except sender", async () => {
894
+ await seedActiveSessions();
895
+
896
+ output = "";
897
+ await mailCommand([
898
+ "send",
899
+ "--to",
900
+ "@all",
901
+ "--subject",
902
+ "Team update",
903
+ "--body",
904
+ "Important announcement",
905
+ ]);
906
+
907
+ expect(output).toContain("Broadcast sent to 3 recipients (@all)");
908
+ expect(stripAnsi(output)).toContain("→ builder-1");
909
+ expect(stripAnsi(output)).toContain("→ builder-2");
910
+ expect(stripAnsi(output)).toContain("→ scout-1");
911
+ expect(output).not.toContain("orchestrator"); // sender excluded
912
+
913
+ // Verify messages were actually stored
914
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
915
+ const client = createMailClient(store);
916
+ const messages = client.list();
917
+ const broadcastMsgs = messages.filter((m) => m.subject === "Team update");
918
+ expect(broadcastMsgs.length).toBe(3);
919
+ expect(broadcastMsgs.map((m) => m.to).sort()).toEqual(["builder-1", "builder-2", "scout-1"]);
920
+ client.close();
921
+ });
922
+
923
+ test("@builders broadcasts to all builder agents", async () => {
924
+ await seedActiveSessions();
925
+
926
+ output = "";
927
+ await mailCommand([
928
+ "send",
929
+ "--to",
930
+ "@builders",
931
+ "--subject",
932
+ "Builder update",
933
+ "--body",
934
+ "Build instructions",
935
+ ]);
936
+
937
+ expect(output).toContain("Broadcast sent to 2 recipients (@builders)");
938
+ expect(stripAnsi(output)).toContain("→ builder-1");
939
+ expect(stripAnsi(output)).toContain("→ builder-2");
940
+ expect(output).not.toContain("scout-1");
941
+
942
+ // Verify messages
943
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
944
+ const client = createMailClient(store);
945
+ const messages = client.list();
946
+ const broadcastMsgs = messages.filter((m) => m.subject === "Builder update");
947
+ expect(broadcastMsgs.length).toBe(2);
948
+ client.close();
949
+ });
950
+
951
+ test("@scouts broadcasts to all scout agents", async () => {
952
+ await seedActiveSessions();
953
+
954
+ output = "";
955
+ await mailCommand([
956
+ "send",
957
+ "--to",
958
+ "@scouts",
959
+ "--subject",
960
+ "Scout task",
961
+ "--body",
962
+ "Explore this area",
963
+ ]);
964
+
965
+ expect(output).toContain("Broadcast sent to 1 recipient (@scouts)");
966
+ expect(stripAnsi(output)).toContain("→ scout-1");
967
+
968
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
969
+ const client = createMailClient(store);
970
+ const messages = client.list();
971
+ const broadcastMsgs = messages.filter((m) => m.subject === "Scout task");
972
+ expect(broadcastMsgs.length).toBe(1);
973
+ expect(broadcastMsgs[0]?.to).toBe("scout-1");
974
+ client.close();
975
+ });
976
+
977
+ test("singular alias @builder works same as @builders", async () => {
978
+ await seedActiveSessions();
979
+
980
+ output = "";
981
+ await mailCommand([
982
+ "send",
983
+ "--to",
984
+ "@builder",
985
+ "--subject",
986
+ "Builder task",
987
+ "--body",
988
+ "Singular alias test",
989
+ ]);
990
+
991
+ expect(output).toContain("Broadcast sent to 2 recipients (@builder)");
992
+ expect(stripAnsi(output)).toContain("→ builder-1");
993
+ expect(stripAnsi(output)).toContain("→ builder-2");
994
+ });
995
+
996
+ test("sender is excluded from broadcast recipients", async () => {
997
+ await seedActiveSessions();
998
+
999
+ output = "";
1000
+ await mailCommand([
1001
+ "send",
1002
+ "--to",
1003
+ "@builders",
1004
+ "--from",
1005
+ "builder-1",
1006
+ "--subject",
1007
+ "Peer message",
1008
+ "--body",
1009
+ "Message from builder-1",
1010
+ ]);
1011
+
1012
+ expect(output).toContain("Broadcast sent to 1 recipient (@builders)");
1013
+ expect(stripAnsi(output)).toContain("→ builder-2");
1014
+ expect(stripAnsi(output)).not.toContain("→ builder-1");
1015
+
1016
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
1017
+ const client = createMailClient(store);
1018
+ const messages = client.list();
1019
+ const broadcastMsgs = messages.filter((m) => m.subject === "Peer message");
1020
+ expect(broadcastMsgs.length).toBe(1);
1021
+ expect(broadcastMsgs[0]?.to).toBe("builder-2");
1022
+ client.close();
1023
+ });
1024
+
1025
+ test("throws when group resolves to zero recipients", async () => {
1026
+ await seedActiveSessions();
1027
+
1028
+ // @all from all agents (impossible — at least one agent needed)
1029
+ // Instead, test a capability group with no members
1030
+ let error: Error | null = null;
1031
+ try {
1032
+ await mailCommand(["send", "--to", "@reviewers", "--subject", "Test", "--body", "Body"]);
1033
+ } catch (e) {
1034
+ error = e as Error;
1035
+ }
1036
+
1037
+ expect(error).toBeTruthy();
1038
+ expect(error?.message).toContain("resolved to zero recipients");
1039
+ });
1040
+
1041
+ test("throws when group is unknown", async () => {
1042
+ await seedActiveSessions();
1043
+
1044
+ let error: Error | null = null;
1045
+ try {
1046
+ await mailCommand(["send", "--to", "@unknown", "--subject", "Test", "--body", "Body"]);
1047
+ } catch (e) {
1048
+ error = e as Error;
1049
+ }
1050
+
1051
+ expect(error).toBeTruthy();
1052
+ expect(error?.message).toContain("Unknown group address");
1053
+ });
1054
+
1055
+ test("broadcast with --json outputs message IDs and recipient count", async () => {
1056
+ await seedActiveSessions();
1057
+
1058
+ output = "";
1059
+ await mailCommand([
1060
+ "send",
1061
+ "--to",
1062
+ "@builders",
1063
+ "--subject",
1064
+ "Test",
1065
+ "--body",
1066
+ "Body",
1067
+ "--json",
1068
+ ]);
1069
+
1070
+ const result = JSON.parse(output) as { messageIds: string[]; recipientCount: number };
1071
+ expect(result.messageIds).toBeInstanceOf(Array);
1072
+ expect(result.messageIds.length).toBe(2);
1073
+ expect(result.recipientCount).toBe(2);
1074
+ });
1075
+
1076
+ test("broadcast records event for each individual message", async () => {
1077
+ await seedActiveSessions();
1078
+
1079
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1080
+ const eventStore = createEventStore(eventsDbPath);
1081
+ eventStore.close(); // Just to initialize the DB
1082
+
1083
+ output = "";
1084
+ await mailCommand(["send", "--to", "@builders", "--subject", "Test", "--body", "Body"]);
1085
+
1086
+ // Check events by agent (orchestrator is the sender)
1087
+ const eventStore2 = createEventStore(eventsDbPath);
1088
+ const events = eventStore2.getByAgent("orchestrator");
1089
+ eventStore2.close();
1090
+
1091
+ const mailSentEvents = events.filter((e) => e.eventType === "mail_sent");
1092
+ expect(mailSentEvents.length).toBe(2);
1093
+ for (const evt of mailSentEvents) {
1094
+ expect(evt.eventType).toBe("mail_sent");
1095
+ const data = JSON.parse(evt.data ?? "{}") as {
1096
+ to: string;
1097
+ broadcast: boolean;
1098
+ };
1099
+ expect(data.broadcast).toBe(true);
1100
+ expect(["builder-1", "builder-2"]).toContain(data.to);
1101
+ }
1102
+ });
1103
+
1104
+ test("broadcast with urgent priority writes pending nudge for each recipient", async () => {
1105
+ await seedActiveSessions();
1106
+
1107
+ output = "";
1108
+ await mailCommand([
1109
+ "send",
1110
+ "--to",
1111
+ "@builders",
1112
+ "--subject",
1113
+ "Urgent task",
1114
+ "--body",
1115
+ "Do this now",
1116
+ "--priority",
1117
+ "urgent",
1118
+ ]);
1119
+
1120
+ // Check pending nudge markers
1121
+ const nudgesDir = join(tempDir, ".agentplate", "pending-nudges");
1122
+ const nudgeFiles = await readdir(nudgesDir);
1123
+ expect(nudgeFiles).toContain("builder-1.json");
1124
+ expect(nudgeFiles).toContain("builder-2.json");
1125
+
1126
+ // Verify nudge content
1127
+ const nudge1 = JSON.parse(await Bun.file(join(nudgesDir, "builder-1.json")).text()) as {
1128
+ reason: string;
1129
+ subject: string;
1130
+ };
1131
+ expect(nudge1.reason).toBe("urgent priority");
1132
+ expect(nudge1.subject).toBe("Urgent task");
1133
+ });
1134
+
1135
+ test("broadcast with auto-nudge type writes pending nudge for each recipient", async () => {
1136
+ await seedActiveSessions();
1137
+
1138
+ output = "";
1139
+ await mailCommand([
1140
+ "send",
1141
+ "--to",
1142
+ "@builders",
1143
+ "--subject",
1144
+ "Error occurred",
1145
+ "--body",
1146
+ "Something went wrong",
1147
+ "--type",
1148
+ "error",
1149
+ ]);
1150
+
1151
+ // Check pending nudge markers
1152
+ const nudgesDir = join(tempDir, ".agentplate", "pending-nudges");
1153
+ const nudgeFiles = await readdir(nudgesDir);
1154
+ expect(nudgeFiles).toContain("builder-1.json");
1155
+ expect(nudgeFiles).toContain("builder-2.json");
1156
+
1157
+ const nudge1 = JSON.parse(await Bun.file(join(nudgesDir, "builder-1.json")).text()) as {
1158
+ reason: string;
1159
+ };
1160
+ expect(nudge1.reason).toBe("error");
1161
+ });
1162
+ });
1163
+
1164
+ describe("merge_ready reviewer validation", () => {
1165
+ // Helper to set up sessions in sessions.db
1166
+ async function seedSessions(
1167
+ sessions: Array<{
1168
+ agentName: string;
1169
+ capability: string;
1170
+ parentAgent: string | null;
1171
+ }>,
1172
+ ): Promise<void> {
1173
+ const { createSessionStore } = await import("../sessions/store.ts");
1174
+ const sessionsDbPath = join(tempDir, ".agentplate", "sessions.db");
1175
+ const sessionStore = createSessionStore(sessionsDbPath);
1176
+
1177
+ for (const [idx, session] of sessions.entries()) {
1178
+ sessionStore.upsert({
1179
+ id: `session-${idx}`,
1180
+ agentName: session.agentName,
1181
+ capability: session.capability as
1182
+ | "builder"
1183
+ | "reviewer"
1184
+ | "scout"
1185
+ | "coordinator"
1186
+ | "lead"
1187
+ | "merger"
1188
+ | "supervisor"
1189
+ | "monitor",
1190
+ worktreePath: `/worktrees/${session.agentName}`,
1191
+ branchName: session.agentName,
1192
+ taskId: `bead-${idx}`,
1193
+ tmuxSession: `agentplate-test-${session.agentName}`,
1194
+ state: "working" as const,
1195
+ pid: 10000 + idx,
1196
+ parentAgent: session.parentAgent,
1197
+ depth: 1,
1198
+ runId: "run-001",
1199
+ startedAt: new Date().toISOString(),
1200
+ lastActivity: new Date().toISOString(),
1201
+ escalationLevel: 0,
1202
+ stalledSince: null,
1203
+ transcriptPath: null,
1204
+ });
1205
+ }
1206
+
1207
+ sessionStore.close();
1208
+ }
1209
+
1210
+ test("merge_ready with no reviewers emits warning", async () => {
1211
+ await seedSessions([
1212
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1213
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1214
+ ]);
1215
+
1216
+ output = "";
1217
+ stderrOutput = "";
1218
+ await mailCommand([
1219
+ "send",
1220
+ "--to",
1221
+ "coordinator",
1222
+ "--subject",
1223
+ "Ready to merge",
1224
+ "--body",
1225
+ "All builders complete",
1226
+ "--type",
1227
+ "merge_ready",
1228
+ "--from",
1229
+ "lead-1",
1230
+ ]);
1231
+
1232
+ // Verify warning on stderr
1233
+ expect(stderrOutput).toContain("Warning:");
1234
+ expect(stderrOutput).toContain("NO reviewer sessions found");
1235
+ expect(stderrOutput).toContain("lead-1");
1236
+ expect(stderrOutput).toContain("2 builder(s)");
1237
+ expect(stderrOutput).toContain("review-before-merge requirement");
1238
+ expect(stderrOutput).toContain("REVIEW_SKIP");
1239
+ });
1240
+
1241
+ test("merge_ready with partial reviewers emits note", async () => {
1242
+ await seedSessions([
1243
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1244
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1245
+ { agentName: "builder-3", capability: "builder", parentAgent: "lead-1" },
1246
+ { agentName: "reviewer-1", capability: "reviewer", parentAgent: "lead-1" },
1247
+ ]);
1248
+
1249
+ output = "";
1250
+ stderrOutput = "";
1251
+ await mailCommand([
1252
+ "send",
1253
+ "--to",
1254
+ "coordinator",
1255
+ "--subject",
1256
+ "Ready to merge",
1257
+ "--body",
1258
+ "Partial review complete",
1259
+ "--type",
1260
+ "merge_ready",
1261
+ "--from",
1262
+ "lead-1",
1263
+ ]);
1264
+
1265
+ // Verify note on stderr
1266
+ expect(stderrOutput).toContain("Note:");
1267
+ expect(stderrOutput).toContain("Only 1 reviewer(s) for 3 builder(s)");
1268
+ expect(stderrOutput).toContain("review-verified");
1269
+ });
1270
+
1271
+ test("merge_ready with full coverage emits no warning", async () => {
1272
+ await seedSessions([
1273
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1274
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1275
+ { agentName: "reviewer-1", capability: "reviewer", parentAgent: "lead-1" },
1276
+ { agentName: "reviewer-2", capability: "reviewer", parentAgent: "lead-1" },
1277
+ ]);
1278
+
1279
+ output = "";
1280
+ stderrOutput = "";
1281
+ await mailCommand([
1282
+ "send",
1283
+ "--to",
1284
+ "coordinator",
1285
+ "--subject",
1286
+ "Ready to merge",
1287
+ "--body",
1288
+ "Full review complete",
1289
+ "--type",
1290
+ "merge_ready",
1291
+ "--from",
1292
+ "lead-1",
1293
+ ]);
1294
+
1295
+ // No warning should be emitted
1296
+ expect(stderrOutput).toBe("");
1297
+ });
1298
+
1299
+ test("non-merge_ready types skip reviewer check", async () => {
1300
+ await seedSessions([
1301
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1302
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1303
+ ]);
1304
+
1305
+ output = "";
1306
+ stderrOutput = "";
1307
+ await mailCommand([
1308
+ "send",
1309
+ "--to",
1310
+ "coordinator",
1311
+ "--subject",
1312
+ "Status update",
1313
+ "--body",
1314
+ "Work in progress",
1315
+ "--type",
1316
+ "status",
1317
+ "--from",
1318
+ "lead-1",
1319
+ ]);
1320
+
1321
+ // No warning should be emitted for non-merge_ready types
1322
+ expect(stderrOutput).toBe("");
1323
+ });
1324
+ });
1325
+
1326
+ describe("terminal-state recipient rejection (agentplate-f5be)", () => {
1327
+ async function seedRecipient(name: string, state: "working" | "completed" | "zombie") {
1328
+ const { createSessionStore } = await import("../sessions/store.ts");
1329
+ const sessionsDbPath = join(tempDir, ".agentplate", "sessions.db");
1330
+ const sessionStore = createSessionStore(sessionsDbPath);
1331
+ sessionStore.upsert({
1332
+ id: `session-${name}`,
1333
+ agentName: name,
1334
+ capability: "builder",
1335
+ worktreePath: `/worktrees/${name}`,
1336
+ branchName: name,
1337
+ taskId: "bead-x",
1338
+ tmuxSession: `agentplate-test-${name}`,
1339
+ state,
1340
+ pid: 99999,
1341
+ parentAgent: "orchestrator",
1342
+ depth: 1,
1343
+ runId: "run-001",
1344
+ startedAt: new Date().toISOString(),
1345
+ lastActivity: new Date().toISOString(),
1346
+ escalationLevel: 0,
1347
+ stalledSince: null,
1348
+ transcriptPath: null,
1349
+ });
1350
+ sessionStore.close();
1351
+ }
1352
+
1353
+ test("rejects send to recipient in completed state", async () => {
1354
+ await seedRecipient("dead-builder", "completed");
1355
+
1356
+ let caught: unknown;
1357
+ try {
1358
+ await mailCommand([
1359
+ "send",
1360
+ "--to",
1361
+ "dead-builder",
1362
+ "--subject",
1363
+ "Hello",
1364
+ "--body",
1365
+ "Are you there?",
1366
+ ]);
1367
+ } catch (err) {
1368
+ caught = err;
1369
+ }
1370
+
1371
+ expect(caught).toBeDefined();
1372
+ expect((caught as Error).name).toBe("MailError");
1373
+ expect((caught as Error).message).toContain("dead-builder");
1374
+ expect((caught as Error).message).toContain("completed");
1375
+
1376
+ // Confirm no message was inserted
1377
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
1378
+ const client = createMailClient(store);
1379
+ const messages = client.list({ to: "dead-builder" });
1380
+ expect(messages.length).toBe(0);
1381
+ client.close();
1382
+ });
1383
+
1384
+ test("rejects send to recipient in zombie state", async () => {
1385
+ await seedRecipient("crashed-builder", "zombie");
1386
+
1387
+ let caught: unknown;
1388
+ try {
1389
+ await mailCommand([
1390
+ "send",
1391
+ "--to",
1392
+ "crashed-builder",
1393
+ "--subject",
1394
+ "Status?",
1395
+ "--body",
1396
+ "Ping",
1397
+ ]);
1398
+ } catch (err) {
1399
+ caught = err;
1400
+ }
1401
+
1402
+ expect(caught).toBeDefined();
1403
+ expect((caught as Error).name).toBe("MailError");
1404
+ expect((caught as Error).message).toContain("zombie");
1405
+ });
1406
+
1407
+ test("allows send when recipient has no session row (e.g. orchestrator)", async () => {
1408
+ // No session seeded for "orchestrator" — the existing beforeEach
1409
+ // only inserts mail rows, not session rows.
1410
+ await mailCommand([
1411
+ "send",
1412
+ "--to",
1413
+ "orchestrator",
1414
+ "--subject",
1415
+ "Hello",
1416
+ "--body",
1417
+ "Top-level role",
1418
+ ]);
1419
+
1420
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
1421
+ const client = createMailClient(store);
1422
+ const messages = client.list({ to: "orchestrator" });
1423
+ expect(messages.length).toBeGreaterThanOrEqual(1);
1424
+ client.close();
1425
+ });
1426
+
1427
+ test("allows send to active (working) recipient", async () => {
1428
+ await seedRecipient("live-builder", "working");
1429
+
1430
+ await mailCommand(["send", "--to", "live-builder", "--subject", "Hello", "--body", "Active"]);
1431
+
1432
+ const store = createMailStore(join(tempDir, ".agentplate", "mail.db"));
1433
+ const client = createMailClient(store);
1434
+ const messages = client.list({ to: "live-builder" });
1435
+ expect(messages.length).toBe(1);
1436
+ client.close();
1437
+ });
1438
+ });
1439
+ });
1440
+
1441
+ describe("shouldAutoNudge", () => {
1442
+ test("returns true for urgent priority regardless of type", () => {
1443
+ expect(shouldAutoNudge("status", "urgent")).toBe(true);
1444
+ });
1445
+
1446
+ test("returns true for high priority regardless of type", () => {
1447
+ expect(shouldAutoNudge("status", "high")).toBe(true);
1448
+ });
1449
+
1450
+ test("returns true for worker_done type at normal priority", () => {
1451
+ expect(shouldAutoNudge("worker_done", "normal")).toBe(true);
1452
+ });
1453
+
1454
+ test("returns true for merge_ready type at normal priority", () => {
1455
+ expect(shouldAutoNudge("merge_ready", "normal")).toBe(true);
1456
+ });
1457
+
1458
+ test("returns true for error type at normal priority", () => {
1459
+ expect(shouldAutoNudge("error", "normal")).toBe(true);
1460
+ });
1461
+
1462
+ test("returns false for status type at normal priority", () => {
1463
+ expect(shouldAutoNudge("status", "normal")).toBe(false);
1464
+ });
1465
+
1466
+ test("returns false for question type at low priority", () => {
1467
+ expect(shouldAutoNudge("question", "low")).toBe(false);
1468
+ });
1469
+ });
1470
+
1471
+ describe("isDispatchNudge", () => {
1472
+ test("returns true for dispatch type", () => {
1473
+ expect(isDispatchNudge("dispatch")).toBe(true);
1474
+ });
1475
+
1476
+ test("returns false for worker_done type", () => {
1477
+ expect(isDispatchNudge("worker_done")).toBe(false);
1478
+ });
1479
+
1480
+ test("returns false for status type", () => {
1481
+ expect(isDispatchNudge("status")).toBe(false);
1482
+ });
1483
+
1484
+ test("returns false for error type", () => {
1485
+ expect(isDispatchNudge("error")).toBe(false);
1486
+ });
1487
+ });
1488
+
1489
+ describe("AUTO_NUDGE_TYPES", () => {
1490
+ test("contains worker_done, merge_ready, and error", () => {
1491
+ expect(AUTO_NUDGE_TYPES.has("worker_done")).toBe(true);
1492
+ expect(AUTO_NUDGE_TYPES.has("merge_ready")).toBe(true);
1493
+ expect(AUTO_NUDGE_TYPES.has("error")).toBe(true);
1494
+ });
1495
+
1496
+ test("does not contain regular semantic types", () => {
1497
+ expect(AUTO_NUDGE_TYPES.has("status")).toBe(false);
1498
+ expect(AUTO_NUDGE_TYPES.has("question")).toBe(false);
1499
+ expect(AUTO_NUDGE_TYPES.has("result")).toBe(false);
1500
+ });
1501
+ });