@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,1913 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, readdir, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ValidationError } from "../errors.ts";
6
+ import { createEventStore } from "../events/store.ts";
7
+ import type { LoamClient } from "../loam/client.ts";
8
+ import { createMailClient } from "../mail/client.ts";
9
+ import { createMailStore } from "../mail/store.ts";
10
+ import { createMetricsStore } from "../metrics/store.ts";
11
+ import { createRunStore, createSessionStore } from "../sessions/store.ts";
12
+ import { cleanupTempDir } from "../test-helpers.ts";
13
+ import type { AgentSession, LoamLearnResult, StoredEvent } from "../types.ts";
14
+ import { appendOutcomeToAppliedRecords, autoRecordExpertise, logCommand } from "./log.ts";
15
+
16
+ /**
17
+ * Tests for `agentplate log` command.
18
+ *
19
+ * Uses real filesystem (temp dirs) and real bun:sqlite to test logging behavior.
20
+ * Captures process.stdout.write to verify help text output.
21
+ */
22
+
23
+ describe("logCommand", () => {
24
+ let chunks: string[];
25
+ let originalWrite: typeof process.stdout.write;
26
+ let tempDir: string;
27
+ let originalCwd: string;
28
+
29
+ beforeEach(async () => {
30
+ // Spy on stdout
31
+ chunks = [];
32
+ originalWrite = process.stdout.write;
33
+ process.stdout.write = ((chunk: string) => {
34
+ chunks.push(chunk);
35
+ return true;
36
+ }) as typeof process.stdout.write;
37
+
38
+ // Create temp dir with .agentplate/config.yaml structure
39
+ tempDir = await mkdtemp(join(tmpdir(), "log-test-"));
40
+ const agentplateDir = join(tempDir, ".agentplate");
41
+ await Bun.write(
42
+ join(agentplateDir, "config.yaml"),
43
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
44
+ );
45
+
46
+ // Change to temp dir so loadConfig() works
47
+ originalCwd = process.cwd();
48
+ process.chdir(tempDir);
49
+ });
50
+
51
+ afterEach(async () => {
52
+ process.stdout.write = originalWrite;
53
+ process.chdir(originalCwd);
54
+ await cleanupTempDir(tempDir);
55
+ });
56
+
57
+ function output(): string {
58
+ return chunks.join("");
59
+ }
60
+
61
+ /**
62
+ * Fake LoamClient for testing autoRecordExpertise and appendOutcomeToAppliedRecords.
63
+ * Only learn(), record(), and appendOutcome() are implemented — other methods are stubs.
64
+ * Justified: we are testing orchestration logic, not the loam CLI itself.
65
+ */
66
+ function createFakeLoamClient(
67
+ learnResult: LoamLearnResult,
68
+ opts?: { recordShouldFail?: boolean; appendOutcomeShouldFail?: boolean },
69
+ ): {
70
+ client: LoamClient;
71
+ recordCalls: Array<{ domain: string; options: Record<string, unknown> }>;
72
+ appendOutcomeCalls: Array<{
73
+ domain: string;
74
+ id: string;
75
+ outcome: Record<string, unknown>;
76
+ }>;
77
+ } {
78
+ const recordCalls: Array<{ domain: string; options: Record<string, unknown> }> = [];
79
+ const appendOutcomeCalls: Array<{
80
+ domain: string;
81
+ id: string;
82
+ outcome: Record<string, unknown>;
83
+ }> = [];
84
+ const client = {
85
+ async learn() {
86
+ return learnResult;
87
+ },
88
+ async record(domain: string, options: Record<string, unknown>) {
89
+ if (opts?.recordShouldFail) {
90
+ throw new Error("loam record failed");
91
+ }
92
+ recordCalls.push({ domain, options });
93
+ },
94
+ async appendOutcome(domain: string, id: string, outcome: Record<string, unknown>) {
95
+ if (opts?.appendOutcomeShouldFail) {
96
+ throw new Error("loam appendOutcome failed");
97
+ }
98
+ appendOutcomeCalls.push({ domain, id, outcome });
99
+ },
100
+ } as unknown as LoamClient;
101
+ return { client, recordCalls, appendOutcomeCalls };
102
+ }
103
+
104
+ test("--help flag shows help text", async () => {
105
+ await logCommand(["--help"]);
106
+ const out = output();
107
+
108
+ expect(out).toContain("log");
109
+ expect(out).toContain("tool-start");
110
+ expect(out).toContain("tool-end");
111
+ expect(out).toContain("session-end");
112
+ expect(out).toContain("--agent");
113
+ });
114
+
115
+ test("-h flag shows help text", async () => {
116
+ await logCommand(["-h"]);
117
+ const out = output();
118
+
119
+ expect(out).toContain("log");
120
+ expect(out).toContain("tool-start");
121
+ expect(out).toContain("tool-end");
122
+ expect(out).toContain("session-end");
123
+ expect(out).toContain("--agent");
124
+ });
125
+
126
+ test("missing event argument throws when required argument missing", async () => {
127
+ // Commander throws when a required positional argument is missing
128
+ await expect(async () => {
129
+ await logCommand([]);
130
+ }).toThrow();
131
+ });
132
+
133
+ test("invalid event name throws ValidationError", async () => {
134
+ expect(async () => {
135
+ await logCommand(["invalid-event", "--agent", "test-agent"]);
136
+ }).toThrow(ValidationError);
137
+
138
+ expect(async () => {
139
+ await logCommand(["invalid-event", "--agent", "test-agent"]);
140
+ }).toThrow("Invalid event");
141
+ });
142
+
143
+ test("missing --agent flag throws ValidationError", async () => {
144
+ expect(async () => {
145
+ await logCommand(["tool-start"]);
146
+ }).toThrow(ValidationError);
147
+
148
+ expect(async () => {
149
+ await logCommand(["tool-start"]);
150
+ }).toThrow("--agent is required");
151
+ });
152
+
153
+ test("tool-start creates log directory structure", async () => {
154
+ await logCommand(["tool-start", "--agent", "test-builder", "--tool-name", "Read"]);
155
+
156
+ const logsDir = join(tempDir, ".agentplate", "logs", "test-builder");
157
+ const contents = await readdir(logsDir);
158
+
159
+ // Should have at least .current-session marker and a session directory
160
+ expect(contents).toContain(".current-session");
161
+ expect(contents.length).toBeGreaterThanOrEqual(2);
162
+ });
163
+
164
+ test("tool-start creates session directory and .current-session marker", async () => {
165
+ await logCommand(["tool-start", "--agent", "test-scout", "--tool-name", "Grep"]);
166
+
167
+ const logsDir = join(tempDir, ".agentplate", "logs", "test-scout");
168
+ const markerPath = join(logsDir, ".current-session");
169
+ const markerFile = Bun.file(markerPath);
170
+
171
+ expect(await markerFile.exists()).toBe(true);
172
+
173
+ const sessionDir = (await markerFile.text()).trim();
174
+ expect(sessionDir).toBeTruthy();
175
+ expect(sessionDir).toContain(logsDir);
176
+
177
+ // Session directory should exist
178
+ const dirStat = await stat(sessionDir);
179
+ expect(dirStat.isDirectory()).toBe(true);
180
+ });
181
+
182
+ test("tool-start creates log files in session directory", async () => {
183
+ await logCommand(["tool-start", "--agent", "test-builder", "--tool-name", "Write"]);
184
+
185
+ // Wait for async file writes to complete
186
+ await new Promise((resolve) => setTimeout(resolve, 50));
187
+
188
+ const logsDir = join(tempDir, ".agentplate", "logs", "test-builder");
189
+ const markerPath = join(logsDir, ".current-session");
190
+ const sessionDir = (await Bun.file(markerPath).text()).trim();
191
+
192
+ // Check for events.ndjson file
193
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
194
+ expect(await eventsFile.exists()).toBe(true);
195
+ });
196
+
197
+ test("tool-end uses the same session directory as tool-start", async () => {
198
+ await logCommand(["tool-start", "--agent", "test-agent", "--tool-name", "Edit"]);
199
+
200
+ const logsDir = join(tempDir, ".agentplate", "logs", "test-agent");
201
+ const markerPath = join(logsDir, ".current-session");
202
+ const sessionDirAfterStart = (await Bun.file(markerPath).text()).trim();
203
+
204
+ await logCommand(["tool-end", "--agent", "test-agent", "--tool-name", "Edit"]);
205
+
206
+ const sessionDirAfterEnd = (await Bun.file(markerPath).text()).trim();
207
+ expect(sessionDirAfterEnd).toBe(sessionDirAfterStart);
208
+ });
209
+
210
+ test("tool-end writes to the same session directory", async () => {
211
+ await logCommand(["tool-start", "--agent", "test-worker", "--tool-name", "Bash"]);
212
+ await logCommand(["tool-end", "--agent", "test-worker", "--tool-name", "Bash"]);
213
+
214
+ // Wait for async file writes to complete
215
+ await new Promise((resolve) => setTimeout(resolve, 50));
216
+
217
+ const logsDir = join(tempDir, ".agentplate", "logs", "test-worker");
218
+ const markerPath = join(logsDir, ".current-session");
219
+ const sessionDir = (await Bun.file(markerPath).text()).trim();
220
+
221
+ // Events file should contain both tool-start and tool-end events
222
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
223
+ const eventsContent = await eventsFile.text();
224
+
225
+ expect(eventsContent).toContain("tool.start");
226
+ expect(eventsContent).toContain("tool.end");
227
+ });
228
+
229
+ test("session-end transitions agent state to completed in sessions.db", async () => {
230
+ // Create sessions.db with a test agent
231
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
232
+ const session: AgentSession = {
233
+ id: "session-001",
234
+ agentName: "test-agent",
235
+ capability: "builder",
236
+ worktreePath: "/tmp/test",
237
+ branchName: "test-branch",
238
+ taskId: "bead-001",
239
+ tmuxSession: "test-tmux",
240
+ state: "working",
241
+ pid: 12345,
242
+ parentAgent: null,
243
+ depth: 0,
244
+ runId: null,
245
+ startedAt: new Date().toISOString(),
246
+ lastActivity: new Date().toISOString(),
247
+ escalationLevel: 0,
248
+ stalledSince: null,
249
+ transcriptPath: null,
250
+ };
251
+ const store = createSessionStore(dbPath);
252
+ store.upsert(session);
253
+ store.close();
254
+
255
+ await logCommand(["session-end", "--agent", "test-agent"]);
256
+
257
+ // Read sessions.db and verify state changed to completed
258
+ const readStore = createSessionStore(dbPath);
259
+ const updatedSession = readStore.getByName("test-agent");
260
+ readStore.close();
261
+
262
+ expect(updatedSession).toBeDefined();
263
+ expect(updatedSession?.state).toBe("completed");
264
+ });
265
+
266
+ test("session-end clears the .current-session marker", async () => {
267
+ // First create a session with tool-start
268
+ await logCommand(["tool-start", "--agent", "test-cleanup", "--tool-name", "Read"]);
269
+
270
+ const logsDir = join(tempDir, ".agentplate", "logs", "test-cleanup");
271
+ const markerPath = join(logsDir, ".current-session");
272
+
273
+ // Verify marker exists before session-end
274
+ let markerFile = Bun.file(markerPath);
275
+ expect(await markerFile.exists()).toBe(true);
276
+
277
+ // Now end the session
278
+ await logCommand(["session-end", "--agent", "test-cleanup"]);
279
+
280
+ // Marker should be removed - need to create a new Bun.file reference
281
+ markerFile = Bun.file(markerPath);
282
+ expect(await markerFile.exists()).toBe(false);
283
+ });
284
+
285
+ test("session-end records metrics when agent session exists in sessions.db", async () => {
286
+ // Create sessions.db with a test agent
287
+ const sessionsDbPath = join(tempDir, ".agentplate", "sessions.db");
288
+ const session: AgentSession = {
289
+ id: "session-002",
290
+ agentName: "metrics-agent",
291
+ capability: "scout",
292
+ worktreePath: "/tmp/metrics",
293
+ branchName: "metrics-branch",
294
+ taskId: "bead-002",
295
+ tmuxSession: "metrics-tmux",
296
+ state: "working",
297
+ pid: 54321,
298
+ parentAgent: "parent-agent",
299
+ depth: 1,
300
+ runId: null,
301
+ startedAt: new Date(Date.now() - 60_000).toISOString(), // 1 minute ago
302
+ lastActivity: new Date().toISOString(),
303
+ escalationLevel: 0,
304
+ stalledSince: null,
305
+ transcriptPath: null,
306
+ };
307
+ const sessStore = createSessionStore(sessionsDbPath);
308
+ sessStore.upsert(session);
309
+ sessStore.close();
310
+
311
+ await logCommand(["session-end", "--agent", "metrics-agent"]);
312
+
313
+ // Verify metrics.db was created and has the session record
314
+ const metricsDbPath = join(tempDir, ".agentplate", "metrics.db");
315
+ const metricsStore = createMetricsStore(metricsDbPath);
316
+ const metrics = metricsStore.getRecentSessions(1);
317
+ metricsStore.close();
318
+
319
+ expect(metrics).toHaveLength(1);
320
+ expect(metrics[0]?.agentName).toBe("metrics-agent");
321
+ expect(metrics[0]?.taskId).toBe("bead-002");
322
+ expect(metrics[0]?.capability).toBe("scout");
323
+ expect(metrics[0]?.parentAgent).toBe("parent-agent");
324
+ });
325
+
326
+ test("session-end does NOT transition coordinator to completed (persistent agent)", async () => {
327
+ // Create sessions.db with a coordinator agent
328
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
329
+ const session: AgentSession = {
330
+ id: "session-coord",
331
+ agentName: "coordinator",
332
+ capability: "coordinator",
333
+ worktreePath: tempDir,
334
+ branchName: "main",
335
+ taskId: "",
336
+ tmuxSession: "agentplate-coordinator",
337
+ state: "working",
338
+ pid: 11111,
339
+ parentAgent: null,
340
+ depth: 0,
341
+ runId: null,
342
+ startedAt: new Date().toISOString(),
343
+ lastActivity: new Date(Date.now() - 60_000).toISOString(),
344
+ escalationLevel: 0,
345
+ stalledSince: null,
346
+ transcriptPath: null,
347
+ };
348
+ const store = createSessionStore(dbPath);
349
+ store.upsert(session);
350
+ store.close();
351
+
352
+ await logCommand(["session-end", "--agent", "coordinator"]);
353
+
354
+ // Coordinator should remain 'working', not transition to 'completed'
355
+ const readStore = createSessionStore(dbPath);
356
+ const updatedSession = readStore.getByName("coordinator");
357
+ readStore.close();
358
+
359
+ expect(updatedSession).toBeDefined();
360
+ expect(updatedSession?.state).toBe("working");
361
+ // But lastActivity should be updated
362
+ expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
363
+ new Date(session.lastActivity).getTime(),
364
+ );
365
+ });
366
+
367
+ test("session-end does NOT transition monitor to completed (persistent agent)", async () => {
368
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
369
+ const session: AgentSession = {
370
+ id: "session-mon",
371
+ agentName: "monitor",
372
+ capability: "monitor",
373
+ worktreePath: tempDir,
374
+ branchName: "main",
375
+ taskId: "",
376
+ tmuxSession: "agentplate-monitor",
377
+ state: "working",
378
+ pid: 22222,
379
+ parentAgent: null,
380
+ depth: 0,
381
+ runId: null,
382
+ startedAt: new Date().toISOString(),
383
+ lastActivity: new Date(Date.now() - 60_000).toISOString(),
384
+ escalationLevel: 0,
385
+ stalledSince: null,
386
+ transcriptPath: null,
387
+ };
388
+ const store = createSessionStore(dbPath);
389
+ store.upsert(session);
390
+ store.close();
391
+
392
+ await logCommand(["session-end", "--agent", "monitor"]);
393
+
394
+ const readStore = createSessionStore(dbPath);
395
+ const updatedSession = readStore.getByName("monitor");
396
+ readStore.close();
397
+
398
+ expect(updatedSession).toBeDefined();
399
+ expect(updatedSession?.state).toBe("working");
400
+ });
401
+
402
+ test("session-end does NOT transition orchestrator to completed (persistent agent)", async () => {
403
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
404
+ const session: AgentSession = {
405
+ id: "session-orch",
406
+ agentName: "orchestrator",
407
+ capability: "orchestrator",
408
+ worktreePath: tempDir,
409
+ branchName: "main",
410
+ taskId: "",
411
+ tmuxSession: "agentplate-orchestrator",
412
+ state: "working",
413
+ pid: 33333,
414
+ parentAgent: null,
415
+ depth: 0,
416
+ runId: null,
417
+ startedAt: new Date().toISOString(),
418
+ lastActivity: new Date(Date.now() - 60_000).toISOString(),
419
+ escalationLevel: 0,
420
+ stalledSince: null,
421
+ transcriptPath: null,
422
+ };
423
+ const store = createSessionStore(dbPath);
424
+ store.upsert(session);
425
+ store.close();
426
+
427
+ await logCommand(["session-end", "--agent", "orchestrator"]);
428
+
429
+ const readStore = createSessionStore(dbPath);
430
+ const updatedSession = readStore.getByName("orchestrator");
431
+ readStore.close();
432
+
433
+ expect(updatedSession).toBeDefined();
434
+ expect(updatedSession?.state).toBe("working");
435
+ });
436
+
437
+ describe("session-end coordinator run completion", () => {
438
+ test("session-end does NOT auto-complete the active run for coordinator agent (per-turn Stop hook guard)", async () => {
439
+ // Regression test for agentplate-adc5:
440
+ // The coordinator's Stop hook fires on every turn boundary, not just at true session exit.
441
+ // session-end must NOT auto-complete the run, or the coordinator dies after its first turn.
442
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
443
+ const sessionStoreLocal = createSessionStore(dbPath);
444
+ sessionStoreLocal.upsert({
445
+ id: "session-coord-run",
446
+ agentName: "coordinator",
447
+ capability: "coordinator",
448
+ worktreePath: tempDir,
449
+ branchName: "main",
450
+ taskId: "",
451
+ tmuxSession: "agentplate-coordinator",
452
+ state: "working",
453
+ pid: 11111,
454
+ parentAgent: null,
455
+ depth: 0,
456
+ runId: "run-test-001",
457
+ startedAt: new Date().toISOString(),
458
+ lastActivity: new Date().toISOString(),
459
+ escalationLevel: 0,
460
+ stalledSince: null,
461
+ transcriptPath: null,
462
+ });
463
+ sessionStoreLocal.close();
464
+
465
+ // Create the run
466
+ const runStore = createRunStore(dbPath);
467
+ runStore.createRun({
468
+ id: "run-test-001",
469
+ startedAt: new Date().toISOString(),
470
+ coordinatorSessionId: "session-coord-run",
471
+ status: "active",
472
+ });
473
+ runStore.close();
474
+
475
+ // Write current-run.txt
476
+ const currentRunPath = join(tempDir, ".agentplate", "current-run.txt");
477
+ await Bun.write(currentRunPath, "run-test-001");
478
+
479
+ // Call session-end (simulates per-turn Stop hook)
480
+ await logCommand(["session-end", "--agent", "coordinator"]);
481
+
482
+ // Verify: run status remains "active" — session-end must NOT auto-complete the run
483
+ const runStoreRead = createRunStore(dbPath);
484
+ const run = runStoreRead.getRun("run-test-001");
485
+ runStoreRead.close();
486
+
487
+ expect(run).toBeDefined();
488
+ expect(run?.status).toBe("active");
489
+ expect(run?.completedAt).toBeNull();
490
+
491
+ // Verify: current-run.txt is NOT deleted (coordinator is still running)
492
+ expect(await Bun.file(currentRunPath).exists()).toBe(true);
493
+ });
494
+
495
+ test("session-end does not fail when no active run for coordinator", async () => {
496
+ // Create a coordinator session but no current-run.txt
497
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
498
+ const sessionStoreLocal = createSessionStore(dbPath);
499
+ sessionStoreLocal.upsert({
500
+ id: "session-coord-no-run",
501
+ agentName: "coordinator-no-run",
502
+ capability: "coordinator",
503
+ worktreePath: tempDir,
504
+ branchName: "main",
505
+ taskId: "",
506
+ tmuxSession: "agentplate-coordinator-no-run",
507
+ state: "working",
508
+ pid: 11112,
509
+ parentAgent: null,
510
+ depth: 0,
511
+ runId: null,
512
+ startedAt: new Date().toISOString(),
513
+ lastActivity: new Date().toISOString(),
514
+ escalationLevel: 0,
515
+ stalledSince: null,
516
+ transcriptPath: null,
517
+ });
518
+ sessionStoreLocal.close();
519
+
520
+ // Call session-end (should not throw)
521
+ await expect(async () => {
522
+ await logCommand(["session-end", "--agent", "coordinator-no-run"]);
523
+ }).not.toThrow();
524
+ });
525
+
526
+ test("session-end does not complete run for non-coordinator agents", async () => {
527
+ // Create a builder session, create a run, write current-run.txt
528
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
529
+ const sessionStoreLocal = createSessionStore(dbPath);
530
+ sessionStoreLocal.upsert({
531
+ id: "session-builder-run",
532
+ agentName: "test-builder",
533
+ capability: "builder",
534
+ worktreePath: tempDir,
535
+ branchName: "builder-branch",
536
+ taskId: "bead-builder-001",
537
+ tmuxSession: "agentplate-builder",
538
+ state: "working",
539
+ pid: 11113,
540
+ parentAgent: null,
541
+ depth: 2,
542
+ runId: "run-test-002",
543
+ startedAt: new Date().toISOString(),
544
+ lastActivity: new Date().toISOString(),
545
+ escalationLevel: 0,
546
+ stalledSince: null,
547
+ transcriptPath: null,
548
+ });
549
+ sessionStoreLocal.close();
550
+
551
+ // Create the run
552
+ const runStore = createRunStore(dbPath);
553
+ runStore.createRun({
554
+ id: "run-test-002",
555
+ startedAt: new Date().toISOString(),
556
+ coordinatorSessionId: "session-coord-run",
557
+ status: "active",
558
+ });
559
+ runStore.close();
560
+
561
+ // Write current-run.txt
562
+ await Bun.write(join(tempDir, ".agentplate", "current-run.txt"), "run-test-002");
563
+
564
+ // Call session-end for builder
565
+ await logCommand(["session-end", "--agent", "test-builder"]);
566
+
567
+ // Verify: run status remains "active"
568
+ const runStoreRead = createRunStore(dbPath);
569
+ const run = runStoreRead.getRun("run-test-002");
570
+ runStoreRead.close();
571
+
572
+ expect(run).toBeDefined();
573
+ expect(run?.status).toBe("active");
574
+ expect(run?.completedAt).toBeNull();
575
+
576
+ // Verify: current-run.txt still exists
577
+ const currentRunFile = Bun.file(join(tempDir, ".agentplate", "current-run.txt"));
578
+ expect(await currentRunFile.exists()).toBe(true);
579
+ });
580
+
581
+ test("session-end handles already-completed run gracefully", async () => {
582
+ // Create a coordinator session, create a run that is already completed
583
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
584
+ const sessionStoreLocal = createSessionStore(dbPath);
585
+ sessionStoreLocal.upsert({
586
+ id: "session-coord-completed",
587
+ agentName: "coordinator-completed",
588
+ capability: "coordinator",
589
+ worktreePath: tempDir,
590
+ branchName: "main",
591
+ taskId: "",
592
+ tmuxSession: "agentplate-coordinator-completed",
593
+ state: "working",
594
+ pid: 11114,
595
+ parentAgent: null,
596
+ depth: 0,
597
+ runId: "run-test-003",
598
+ startedAt: new Date().toISOString(),
599
+ lastActivity: new Date().toISOString(),
600
+ escalationLevel: 0,
601
+ stalledSince: null,
602
+ transcriptPath: null,
603
+ });
604
+ sessionStoreLocal.close();
605
+
606
+ // Create the run already completed
607
+ const runStore = createRunStore(dbPath);
608
+ runStore.createRun({
609
+ id: "run-test-003",
610
+ startedAt: new Date().toISOString(),
611
+ coordinatorSessionId: "session-coord-completed",
612
+ status: "active",
613
+ });
614
+ // Complete it immediately
615
+ runStore.completeRun("run-test-003", "completed");
616
+ runStore.close();
617
+
618
+ // Write current-run.txt
619
+ await Bun.write(join(tempDir, ".agentplate", "current-run.txt"), "run-test-003");
620
+
621
+ // Call session-end (should not throw — completeRun is idempotent)
622
+ await expect(async () => {
623
+ await logCommand(["session-end", "--agent", "coordinator-completed"]);
624
+ }).not.toThrow();
625
+
626
+ // Verify: run is still completed
627
+ const runStoreRead = createRunStore(dbPath);
628
+ const run = runStoreRead.getRun("run-test-003");
629
+ runStoreRead.close();
630
+
631
+ expect(run).toBeDefined();
632
+ expect(run?.status).toBe("completed");
633
+ });
634
+ });
635
+
636
+ test("session-end does NOT transition lead to completed (persistent agent)", async () => {
637
+ // Regression test for agentplate-49a7:
638
+ // The lead's Stop hook fires every turn (interactive Claude Code), not just at
639
+ // true session end. session-end must NOT mark leads completed, or they vanish
640
+ // from getActive() after their first turn while their tmux is still alive.
641
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
642
+ const session: AgentSession = {
643
+ id: "session-lead",
644
+ agentName: "lead-alpha",
645
+ capability: "lead",
646
+ worktreePath: tempDir,
647
+ branchName: "lead-alpha-branch",
648
+ taskId: "bead-lead-001",
649
+ tmuxSession: "agentplate-lead-alpha",
650
+ state: "working",
651
+ pid: 33333,
652
+ parentAgent: null,
653
+ depth: 0,
654
+ runId: null,
655
+ startedAt: new Date().toISOString(),
656
+ lastActivity: new Date(Date.now() - 60_000).toISOString(),
657
+ escalationLevel: 0,
658
+ stalledSince: null,
659
+ transcriptPath: null,
660
+ };
661
+ const store = createSessionStore(dbPath);
662
+ store.upsert(session);
663
+ store.close();
664
+
665
+ await logCommand(["session-end", "--agent", "lead-alpha"]);
666
+
667
+ // Lead should remain 'working', not transition to 'completed'
668
+ const readStore = createSessionStore(dbPath);
669
+ const updatedSession = readStore.getByName("lead-alpha");
670
+ readStore.close();
671
+
672
+ expect(updatedSession).toBeDefined();
673
+ expect(updatedSession?.state).toBe("working");
674
+ // But lastActivity should be updated
675
+ expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
676
+ new Date(session.lastActivity).getTime(),
677
+ );
678
+ });
679
+
680
+ test("session-end does NOT write pending-nudge marker for leads (moved to ap stop)", async () => {
681
+ // Regression test for agentplate-49a7:
682
+ // The lead_completed nudge used to fire from the per-turn Stop hook, spamming
683
+ // the coordinator with false completion signals every turn. It is now emitted
684
+ // only by `ap stop <lead>` (the real completion signal).
685
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
686
+ const session: AgentSession = {
687
+ id: "session-lead",
688
+ agentName: "lead-alpha",
689
+ capability: "lead",
690
+ worktreePath: tempDir,
691
+ branchName: "lead-alpha-branch",
692
+ taskId: "bead-lead-001",
693
+ tmuxSession: "agentplate-lead-alpha",
694
+ state: "working",
695
+ pid: 33333,
696
+ parentAgent: null,
697
+ depth: 0,
698
+ runId: null,
699
+ startedAt: new Date().toISOString(),
700
+ lastActivity: new Date().toISOString(),
701
+ escalationLevel: 0,
702
+ stalledSince: null,
703
+ transcriptPath: null,
704
+ };
705
+ const store = createSessionStore(dbPath);
706
+ store.upsert(session);
707
+ store.close();
708
+
709
+ await logCommand(["session-end", "--agent", "lead-alpha"]);
710
+
711
+ // No pending-nudge marker should be written from session-end
712
+ const markerPath = join(tempDir, ".agentplate", "pending-nudges", "coordinator.json");
713
+ const markerFile = Bun.file(markerPath);
714
+ expect(await markerFile.exists()).toBe(false);
715
+ });
716
+
717
+ test("session-end does NOT write pending-nudge marker for non-lead agents", async () => {
718
+ // Create sessions.db with a builder agent (not a lead)
719
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
720
+ const session: AgentSession = {
721
+ id: "session-builder",
722
+ agentName: "builder-beta",
723
+ capability: "builder",
724
+ worktreePath: tempDir,
725
+ branchName: "builder-beta-branch",
726
+ taskId: "bead-builder-001",
727
+ tmuxSession: "agentplate-builder-beta",
728
+ state: "working",
729
+ pid: 44444,
730
+ parentAgent: null,
731
+ depth: 0,
732
+ runId: null,
733
+ startedAt: new Date().toISOString(),
734
+ lastActivity: new Date().toISOString(),
735
+ escalationLevel: 0,
736
+ stalledSince: null,
737
+ transcriptPath: null,
738
+ };
739
+ const store = createSessionStore(dbPath);
740
+ store.upsert(session);
741
+ store.close();
742
+
743
+ await logCommand(["session-end", "--agent", "builder-beta"]);
744
+
745
+ // Verify no pending-nudge marker was written
746
+ const markerPath = join(tempDir, ".agentplate", "pending-nudges", "coordinator.json");
747
+ const markerFile = Bun.file(markerPath);
748
+ expect(await markerFile.exists()).toBe(false);
749
+ });
750
+
751
+ test("session-end does not crash when sessions.db does not exist", async () => {
752
+ // No sessions.db file exists
753
+ // session-end should complete without throwing
754
+ await expect(
755
+ logCommand(["session-end", "--agent", "nonexistent-agent"]),
756
+ ).resolves.toBeUndefined();
757
+ });
758
+
759
+ test("tool-start updates lastActivity timestamp in sessions.db", async () => {
760
+ // Create sessions.db with a test agent
761
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
762
+ const oldTimestamp = new Date(Date.now() - 120_000).toISOString(); // 2 minutes ago
763
+ const session: AgentSession = {
764
+ id: "session-003",
765
+ agentName: "activity-agent",
766
+ capability: "builder",
767
+ worktreePath: "/tmp/activity",
768
+ branchName: "activity-branch",
769
+ taskId: "bead-003",
770
+ tmuxSession: "activity-tmux",
771
+ state: "working",
772
+ pid: 99999,
773
+ parentAgent: null,
774
+ depth: 0,
775
+ runId: null,
776
+ startedAt: oldTimestamp,
777
+ lastActivity: oldTimestamp,
778
+ escalationLevel: 0,
779
+ stalledSince: null,
780
+ transcriptPath: null,
781
+ };
782
+ const store = createSessionStore(dbPath);
783
+ store.upsert(session);
784
+ store.close();
785
+
786
+ await logCommand(["tool-start", "--agent", "activity-agent", "--tool-name", "Glob"]);
787
+
788
+ // Read sessions.db and verify lastActivity was updated
789
+ const readStore = createSessionStore(dbPath);
790
+ const updatedSession = readStore.getByName("activity-agent");
791
+ readStore.close();
792
+
793
+ expect(updatedSession).toBeDefined();
794
+ expect(updatedSession?.lastActivity).not.toBe(oldTimestamp);
795
+ expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
796
+ new Date(oldTimestamp).getTime(),
797
+ );
798
+ });
799
+
800
+ test("tool-start transitions state from booting to working", async () => {
801
+ // Create sessions.db with agent in 'booting' state
802
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
803
+ const session: AgentSession = {
804
+ id: "session-004",
805
+ agentName: "booting-agent",
806
+ capability: "builder",
807
+ worktreePath: "/tmp/booting",
808
+ branchName: "booting-branch",
809
+ taskId: "bead-004",
810
+ tmuxSession: "booting-tmux",
811
+ state: "booting",
812
+ pid: 11111,
813
+ parentAgent: null,
814
+ depth: 0,
815
+ runId: null,
816
+ startedAt: new Date().toISOString(),
817
+ lastActivity: new Date().toISOString(),
818
+ escalationLevel: 0,
819
+ stalledSince: null,
820
+ transcriptPath: null,
821
+ };
822
+ const store = createSessionStore(dbPath);
823
+ store.upsert(session);
824
+ store.close();
825
+
826
+ await logCommand(["tool-start", "--agent", "booting-agent", "--tool-name", "Read"]);
827
+
828
+ // Read sessions.db and verify state changed to working
829
+ const readStore = createSessionStore(dbPath);
830
+ const updatedSession = readStore.getByName("booting-agent");
831
+ readStore.close();
832
+
833
+ expect(updatedSession).toBeDefined();
834
+ expect(updatedSession?.state).toBe("working");
835
+ });
836
+
837
+ test("tool-start defaults to unknown when --tool-name not provided", async () => {
838
+ // Should not throw when --tool-name is missing
839
+ await expect(
840
+ logCommand(["tool-start", "--agent", "default-tool-agent"]),
841
+ ).resolves.toBeUndefined();
842
+
843
+ // Verify log was created
844
+ const logsDir = join(tempDir, ".agentplate", "logs", "default-tool-agent");
845
+ const markerPath = join(logsDir, ".current-session");
846
+ const markerFile = Bun.file(markerPath);
847
+
848
+ expect(await markerFile.exists()).toBe(true);
849
+
850
+ // Wait for async file writes to complete (logger uses fire-and-forget appendFile)
851
+ await new Promise((resolve) => setTimeout(resolve, 50));
852
+
853
+ const sessionDir = (await markerFile.text()).trim();
854
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
855
+ const eventsContent = await eventsFile.text();
856
+
857
+ // Should contain "unknown" as the tool name
858
+ expect(eventsContent).toContain("unknown");
859
+ });
860
+
861
+ test("tool-end defaults to unknown when --tool-name not provided", async () => {
862
+ await logCommand(["tool-start", "--agent", "default-end-agent"]);
863
+
864
+ // tool-end without --tool-name should not throw
865
+ await expect(logCommand(["tool-end", "--agent", "default-end-agent"])).resolves.toBeUndefined();
866
+
867
+ // Wait for async file writes to complete
868
+ await new Promise((resolve) => setTimeout(resolve, 50));
869
+
870
+ const logsDir = join(tempDir, ".agentplate", "logs", "default-end-agent");
871
+ const markerPath = join(logsDir, ".current-session");
872
+ const sessionDir = (await Bun.file(markerPath).text()).trim();
873
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
874
+ const eventsContent = await eventsFile.text();
875
+
876
+ expect(eventsContent).toContain("unknown");
877
+ });
878
+
879
+ test("tool-start writes to EventStore without --stdin flag (Pi runtime path)", async () => {
880
+ await logCommand(["tool-start", "--agent", "pi-agent", "--tool-name", "Read"]);
881
+
882
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
883
+ const eventStore = createEventStore(eventsDbPath);
884
+ const events = eventStore.getByAgent("pi-agent");
885
+ eventStore.close();
886
+
887
+ expect(events).toHaveLength(1);
888
+ expect(events[0]?.eventType).toBe("tool_start");
889
+ expect(events[0]?.toolName).toBe("Read");
890
+ expect(events[0]?.sessionId).toBeNull();
891
+ expect(events[0]?.agentName).toBe("pi-agent");
892
+ });
893
+
894
+ test("tool-end writes to EventStore without --stdin flag (Pi runtime path)", async () => {
895
+ await logCommand(["tool-start", "--agent", "pi-end-agent", "--tool-name", "Write"]);
896
+ await logCommand(["tool-end", "--agent", "pi-end-agent", "--tool-name", "Write"]);
897
+
898
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
899
+ const eventStore = createEventStore(eventsDbPath);
900
+ const events = eventStore.getByAgent("pi-end-agent");
901
+ eventStore.close();
902
+
903
+ expect(events).toHaveLength(2);
904
+ const startEv = events.find((e) => e.eventType === "tool_start");
905
+ const endEv = events.find((e) => e.eventType === "tool_end");
906
+ expect(startEv).toBeDefined();
907
+ expect(endEv).toBeDefined();
908
+ expect(startEv?.toolName).toBe("Write");
909
+ expect(endEv?.toolName).toBe("Write");
910
+ expect(startEv?.sessionId).toBeNull();
911
+ });
912
+
913
+ test("session-end writes to EventStore without --stdin flag (Pi runtime path)", async () => {
914
+ await logCommand(["session-end", "--agent", "pi-session-agent"]);
915
+
916
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
917
+ const eventStore = createEventStore(eventsDbPath);
918
+ const events = eventStore.getByAgent("pi-session-agent");
919
+ eventStore.close();
920
+
921
+ expect(events).toHaveLength(1);
922
+ expect(events[0]?.eventType).toBe("session_end");
923
+ expect(events[0]?.sessionId).toBeNull();
924
+ expect(events[0]?.agentName).toBe("pi-session-agent");
925
+ });
926
+
927
+ test("--help includes --stdin option in output", async () => {
928
+ await logCommand(["--help"]);
929
+ const out = output();
930
+
931
+ expect(out).toContain("--stdin");
932
+ });
933
+
934
+ test("session-end does not crash when loam learn/record fails", async () => {
935
+ // Create sessions.db with a builder agent (non-persistent)
936
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
937
+ const session: AgentSession = {
938
+ id: "session-loam-fail",
939
+ agentName: "loam-fail-agent",
940
+ capability: "builder",
941
+ worktreePath: tempDir,
942
+ branchName: "loam-fail-branch",
943
+ taskId: "bead-loam-001",
944
+ tmuxSession: "agentplate-loam-fail",
945
+ state: "working",
946
+ pid: 55555,
947
+ parentAgent: "parent-agent",
948
+ depth: 1,
949
+ runId: null,
950
+ startedAt: new Date().toISOString(),
951
+ lastActivity: new Date().toISOString(),
952
+ escalationLevel: 0,
953
+ stalledSince: null,
954
+ transcriptPath: null,
955
+ };
956
+ const store = createSessionStore(dbPath);
957
+ store.upsert(session);
958
+ store.close();
959
+
960
+ // session-end should complete without throwing even if loam learn/record fails
961
+ await expect(
962
+ logCommand(["session-end", "--agent", "loam-fail-agent"]),
963
+ ).resolves.toBeUndefined();
964
+
965
+ // Verify state transitioned to completed
966
+ const readStore = createSessionStore(dbPath);
967
+ const updatedSession = readStore.getByName("loam-fail-agent");
968
+ readStore.close();
969
+
970
+ expect(updatedSession).toBeDefined();
971
+ expect(updatedSession?.state).toBe("completed");
972
+ });
973
+
974
+ test("session-end skips loam auto-record for coordinator (persistent agent)", async () => {
975
+ // Create sessions.db with a coordinator agent
976
+ const dbPath = join(tempDir, ".agentplate", "sessions.db");
977
+ const session: AgentSession = {
978
+ id: "session-coord-loam",
979
+ agentName: "coordinator-loam",
980
+ capability: "coordinator",
981
+ worktreePath: tempDir,
982
+ branchName: "main",
983
+ taskId: "",
984
+ tmuxSession: "agentplate-coordinator-loam",
985
+ state: "working",
986
+ pid: 66666,
987
+ parentAgent: null,
988
+ depth: 0,
989
+ runId: null,
990
+ startedAt: new Date().toISOString(),
991
+ lastActivity: new Date().toISOString(),
992
+ escalationLevel: 0,
993
+ stalledSince: null,
994
+ transcriptPath: null,
995
+ };
996
+ const store = createSessionStore(dbPath);
997
+ store.upsert(session);
998
+ store.close();
999
+
1000
+ await logCommand(["session-end", "--agent", "coordinator-loam"]);
1001
+
1002
+ // Verify no mail.db was created (loam auto-record was skipped)
1003
+ const mailDbPath = join(tempDir, ".agentplate", "mail.db");
1004
+ const mailDbFile = Bun.file(mailDbPath);
1005
+ expect(await mailDbFile.exists()).toBe(false);
1006
+
1007
+ // Coordinator should remain working (persistent agent)
1008
+ const readStore = createSessionStore(dbPath);
1009
+ const updatedSession = readStore.getByName("coordinator-loam");
1010
+ readStore.close();
1011
+
1012
+ expect(updatedSession).toBeDefined();
1013
+ expect(updatedSession?.state).toBe("working");
1014
+ });
1015
+
1016
+ test("autoRecordExpertise calls record for each suggested domain", async () => {
1017
+ const learnResult: LoamLearnResult = {
1018
+ success: true,
1019
+ command: "loam learn",
1020
+ changedFiles: ["src/foo.ts", "src/bar.ts"],
1021
+ suggestedDomains: ["typescript", "cli"],
1022
+ unmatchedFiles: [],
1023
+ };
1024
+ const { client, recordCalls } = createFakeLoamClient(learnResult);
1025
+ const mailDbPath = join(tempDir, ".agentplate", "auto-record-mail.db");
1026
+
1027
+ const result = await autoRecordExpertise({
1028
+ loamClient: client,
1029
+ agentName: "test-builder",
1030
+ capability: "builder",
1031
+ taskId: "bead-123",
1032
+ mailDbPath,
1033
+ parentAgent: "parent-lead",
1034
+ projectRoot: tempDir,
1035
+ sessionStartedAt: new Date().toISOString(),
1036
+ });
1037
+
1038
+ expect(result).toEqual(["typescript", "cli"]);
1039
+ expect(recordCalls).toHaveLength(2);
1040
+ expect(recordCalls[0]?.domain).toBe("typescript");
1041
+ expect(recordCalls[0]?.options).toMatchObject({
1042
+ type: "reference",
1043
+ tags: ["auto-session-end", "builder"],
1044
+ evidenceBead: "bead-123",
1045
+ });
1046
+ expect(recordCalls[1]?.domain).toBe("cli");
1047
+ });
1048
+
1049
+ test("autoRecordExpertise sends mail with auto-recorded subject", async () => {
1050
+ const learnResult: LoamLearnResult = {
1051
+ success: true,
1052
+ command: "loam learn",
1053
+ changedFiles: ["src/foo.ts"],
1054
+ suggestedDomains: ["typescript"],
1055
+ unmatchedFiles: [],
1056
+ };
1057
+ const { client } = createFakeLoamClient(learnResult);
1058
+ const mailDbPath = join(tempDir, ".agentplate", "auto-record-mail2.db");
1059
+
1060
+ await autoRecordExpertise({
1061
+ loamClient: client,
1062
+ agentName: "test-builder",
1063
+ capability: "builder",
1064
+ taskId: "bead-456",
1065
+ mailDbPath,
1066
+ parentAgent: "parent-lead",
1067
+ projectRoot: tempDir,
1068
+ sessionStartedAt: new Date().toISOString(),
1069
+ });
1070
+
1071
+ const mailStore = createMailStore(mailDbPath);
1072
+ const mailClient = createMailClient(mailStore);
1073
+ const messages = mailClient.list({ to: "parent-lead" });
1074
+ mailClient.close();
1075
+
1076
+ expect(messages).toHaveLength(1);
1077
+ expect(messages[0]?.subject).toBe("loam: auto-recorded insights in typescript");
1078
+ expect(messages[0]?.body).toContain("Auto-recorded expertise in: typescript");
1079
+ });
1080
+
1081
+ test("autoRecordExpertise continues when individual record calls fail", async () => {
1082
+ const learnResult: LoamLearnResult = {
1083
+ success: true,
1084
+ command: "loam learn",
1085
+ changedFiles: ["src/foo.ts"],
1086
+ suggestedDomains: ["typescript", "cli"],
1087
+ unmatchedFiles: [],
1088
+ };
1089
+ const { client } = createFakeLoamClient(learnResult, { recordShouldFail: true });
1090
+ const mailDbPath = join(tempDir, ".agentplate", "auto-record-fail.db");
1091
+
1092
+ const result = await autoRecordExpertise({
1093
+ loamClient: client,
1094
+ agentName: "test-builder",
1095
+ capability: "builder",
1096
+ taskId: null,
1097
+ mailDbPath,
1098
+ parentAgent: null,
1099
+ projectRoot: tempDir,
1100
+ sessionStartedAt: new Date().toISOString(),
1101
+ });
1102
+
1103
+ // All records failed, so no domains recorded and no mail sent
1104
+ expect(result).toEqual([]);
1105
+ const mailFile = Bun.file(mailDbPath);
1106
+ expect(await mailFile.exists()).toBe(false);
1107
+ });
1108
+
1109
+ test("autoRecordExpertise returns empty when no domains suggested", async () => {
1110
+ const learnResult: LoamLearnResult = {
1111
+ success: true,
1112
+ command: "loam learn",
1113
+ changedFiles: ["src/foo.ts"],
1114
+ suggestedDomains: [],
1115
+ unmatchedFiles: [],
1116
+ };
1117
+ const { client, recordCalls } = createFakeLoamClient(learnResult);
1118
+ const mailDbPath = join(tempDir, ".agentplate", "auto-record-empty.db");
1119
+
1120
+ const result = await autoRecordExpertise({
1121
+ loamClient: client,
1122
+ agentName: "test-builder",
1123
+ capability: "builder",
1124
+ taskId: null,
1125
+ mailDbPath,
1126
+ parentAgent: null,
1127
+ projectRoot: tempDir,
1128
+ sessionStartedAt: new Date().toISOString(),
1129
+ });
1130
+
1131
+ expect(result).toEqual([]);
1132
+ expect(recordCalls).toHaveLength(0);
1133
+ });
1134
+
1135
+ test("autoRecordExpertise records pattern insights when EventStore has tool data", async () => {
1136
+ const learnResult: LoamLearnResult = {
1137
+ success: true,
1138
+ command: "loam learn",
1139
+ changedFiles: ["src/mail/store.ts"],
1140
+ suggestedDomains: ["messaging"],
1141
+ unmatchedFiles: [],
1142
+ };
1143
+ const { client, recordCalls } = createFakeLoamClient(learnResult);
1144
+ const mailDbPath = join(tempDir, ".agentplate", "insight-analysis-mail.db");
1145
+
1146
+ // Create EventStore with test data
1147
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1148
+ const eventStore = createEventStore(eventsDbPath);
1149
+
1150
+ const sessionStartedAt = new Date(Date.now() - 60_000).toISOString(); // 1 minute ago
1151
+
1152
+ // Insert tool events: 15 tool calls total (10+ triggers workflow insight)
1153
+ // Read-heavy: 12 Read, 3 Edit → should classify as read-heavy
1154
+ for (let i = 0; i < 12; i++) {
1155
+ eventStore.insert({
1156
+ runId: null,
1157
+ agentName: "insight-agent",
1158
+ sessionId: "sess-insight",
1159
+ eventType: "tool_start",
1160
+ toolName: "Read",
1161
+ toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
1162
+ toolDurationMs: null,
1163
+ level: "info",
1164
+ data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
1165
+ });
1166
+ }
1167
+
1168
+ // Add 4 edits to same file → hot file
1169
+ for (let i = 0; i < 4; i++) {
1170
+ eventStore.insert({
1171
+ runId: null,
1172
+ agentName: "insight-agent",
1173
+ sessionId: "sess-insight",
1174
+ eventType: "tool_start",
1175
+ toolName: "Edit",
1176
+ toolArgs: JSON.stringify({ file_path: "src/mail/store.ts" }),
1177
+ toolDurationMs: null,
1178
+ level: "info",
1179
+ data: JSON.stringify({ summary: "edit: src/mail/store.ts" }),
1180
+ });
1181
+ }
1182
+
1183
+ // Add 1 error event → error pattern
1184
+ eventStore.insert({
1185
+ runId: null,
1186
+ agentName: "insight-agent",
1187
+ sessionId: "sess-insight",
1188
+ eventType: "tool_start",
1189
+ toolName: "Bash",
1190
+ toolArgs: JSON.stringify({ command: "bun test" }),
1191
+ toolDurationMs: null,
1192
+ level: "error",
1193
+ data: "Test failed",
1194
+ });
1195
+
1196
+ eventStore.close();
1197
+
1198
+ // Run autoRecordExpertise
1199
+ const result = await autoRecordExpertise({
1200
+ loamClient: client,
1201
+ agentName: "insight-agent",
1202
+ capability: "builder",
1203
+ taskId: "bead-insight",
1204
+ mailDbPath,
1205
+ parentAgent: "parent-agent",
1206
+ projectRoot: tempDir,
1207
+ sessionStartedAt,
1208
+ });
1209
+
1210
+ // Verify reference + insights were recorded
1211
+ expect(recordCalls.length).toBeGreaterThanOrEqual(2); // At least reference + 1 insight
1212
+
1213
+ // Verify reference entry
1214
+ const referenceCall = recordCalls.find((c) => c.options.type === "reference");
1215
+ expect(referenceCall).toBeDefined();
1216
+ expect(referenceCall?.domain).toBe("messaging");
1217
+
1218
+ // Verify pattern insights
1219
+ const patternCalls = recordCalls.filter((c) => c.options.type === "pattern");
1220
+ expect(patternCalls.length).toBeGreaterThanOrEqual(2);
1221
+
1222
+ // Verify workflow insight
1223
+ const workflowInsight = patternCalls.find((c) => {
1224
+ const desc = c.options.description;
1225
+ return typeof desc === "string" && desc.includes("read-heavy workflow");
1226
+ });
1227
+ expect(workflowInsight).toBeDefined();
1228
+
1229
+ // Verify hot file insight
1230
+ const hotFileInsight = patternCalls.find((c) => {
1231
+ const desc = c.options.description;
1232
+ return (
1233
+ typeof desc === "string" && desc.includes("src/mail/store.ts") && desc.includes("4 edits")
1234
+ );
1235
+ });
1236
+ expect(hotFileInsight).toBeDefined();
1237
+ expect(hotFileInsight?.domain).toBe("messaging"); // Inferred from src/mail/
1238
+
1239
+ // Verify failure insight
1240
+ const failureCall = recordCalls.find((c) => c.options.type === "failure");
1241
+ expect(failureCall).toBeDefined();
1242
+
1243
+ // Verify recorded domains includes unique domains from insights
1244
+ expect(result).toContain("messaging");
1245
+ });
1246
+
1247
+ test("autoRecordExpertise includes insight summary in notification mail", async () => {
1248
+ const learnResult: LoamLearnResult = {
1249
+ success: true,
1250
+ command: "loam learn",
1251
+ changedFiles: ["src/config.ts"],
1252
+ suggestedDomains: ["typescript"],
1253
+ unmatchedFiles: [],
1254
+ };
1255
+ const { client } = createFakeLoamClient(learnResult);
1256
+ const mailDbPath = join(tempDir, ".agentplate", "insight-mail-summary.db");
1257
+
1258
+ // Create EventStore with 10+ tool calls to trigger workflow insight
1259
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1260
+ const eventStore = createEventStore(eventsDbPath);
1261
+ const sessionStartedAt = new Date(Date.now() - 60_000).toISOString();
1262
+
1263
+ for (let i = 0; i < 10; i++) {
1264
+ eventStore.insert({
1265
+ runId: null,
1266
+ agentName: "mail-insight-agent",
1267
+ sessionId: "sess-mail",
1268
+ eventType: "tool_start",
1269
+ toolName: "Read",
1270
+ toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
1271
+ toolDurationMs: null,
1272
+ level: "info",
1273
+ data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
1274
+ });
1275
+ }
1276
+
1277
+ eventStore.close();
1278
+
1279
+ await autoRecordExpertise({
1280
+ loamClient: client,
1281
+ agentName: "mail-insight-agent",
1282
+ capability: "scout",
1283
+ taskId: "bead-mail",
1284
+ mailDbPath,
1285
+ parentAgent: "parent-agent",
1286
+ projectRoot: tempDir,
1287
+ sessionStartedAt,
1288
+ });
1289
+
1290
+ // Verify mail was sent with insight summary
1291
+ const mailStore = createMailStore(mailDbPath);
1292
+ const mailClient = createMailClient(mailStore);
1293
+ const messages = mailClient.list({ to: "parent-agent" });
1294
+ mailClient.close();
1295
+
1296
+ expect(messages).toHaveLength(1);
1297
+ const mail = messages[0];
1298
+ expect(mail?.body).toContain("Auto-insights:");
1299
+ expect(mail?.body).toContain("10 tool calls");
1300
+ expect(mail?.body).toContain("pattern"); // At least 1 pattern insight
1301
+ });
1302
+
1303
+ test("threads outcomeStatus into per-domain reference and per-insight records", async () => {
1304
+ const learnResult: LoamLearnResult = {
1305
+ success: true,
1306
+ command: "loam learn",
1307
+ changedFiles: ["src/foo.ts"],
1308
+ suggestedDomains: ["typescript"],
1309
+ unmatchedFiles: [],
1310
+ };
1311
+ const { client, recordCalls } = createFakeLoamClient(learnResult);
1312
+ const mailDbPath = join(tempDir, ".agentplate", "auto-record-outcome.db");
1313
+
1314
+ // Seed events so analyzer emits at least one insight (10+ tool calls).
1315
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1316
+ const eventStore = createEventStore(eventsDbPath);
1317
+ const sessionStartedAt = new Date(Date.now() - 60_000).toISOString();
1318
+ for (let i = 0; i < 10; i++) {
1319
+ eventStore.insert({
1320
+ runId: null,
1321
+ agentName: "outcome-agent",
1322
+ sessionId: "sess-outcome",
1323
+ eventType: "tool_start",
1324
+ toolName: "Read",
1325
+ toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
1326
+ toolDurationMs: null,
1327
+ level: "info",
1328
+ data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
1329
+ });
1330
+ }
1331
+ eventStore.close();
1332
+
1333
+ await autoRecordExpertise({
1334
+ loamClient: client,
1335
+ agentName: "outcome-agent",
1336
+ capability: "builder",
1337
+ taskId: "bead-outcome",
1338
+ mailDbPath,
1339
+ parentAgent: "parent-agent",
1340
+ projectRoot: tempDir,
1341
+ sessionStartedAt,
1342
+ outcomeStatus: "partial",
1343
+ });
1344
+
1345
+ expect(recordCalls.length).toBeGreaterThanOrEqual(2);
1346
+ for (const call of recordCalls) {
1347
+ expect(call.options.outcomeStatus).toBe("partial");
1348
+ expect(call.options.outcomeAgent).toBe("outcome-agent");
1349
+ }
1350
+ });
1351
+
1352
+ test("omits outcomeStatus when caller does not supply one", async () => {
1353
+ const learnResult: LoamLearnResult = {
1354
+ success: true,
1355
+ command: "loam learn",
1356
+ changedFiles: ["src/foo.ts"],
1357
+ suggestedDomains: ["typescript"],
1358
+ unmatchedFiles: [],
1359
+ };
1360
+ const { client, recordCalls } = createFakeLoamClient(learnResult);
1361
+ const mailDbPath = join(tempDir, ".agentplate", "auto-record-no-outcome.db");
1362
+
1363
+ await autoRecordExpertise({
1364
+ loamClient: client,
1365
+ agentName: "no-outcome-agent",
1366
+ capability: "builder",
1367
+ taskId: null,
1368
+ mailDbPath,
1369
+ parentAgent: null,
1370
+ projectRoot: tempDir,
1371
+ sessionStartedAt: new Date().toISOString(),
1372
+ });
1373
+
1374
+ expect(recordCalls).toHaveLength(1);
1375
+ expect(recordCalls[0]?.options.outcomeStatus).toBeUndefined();
1376
+ });
1377
+ });
1378
+
1379
+ /**
1380
+ * Tests for `agentplate log` with --stdin flag.
1381
+ *
1382
+ * Uses Bun.spawn to invoke the log command as a subprocess with piped stdin,
1383
+ * because Bun.stdin.stream() cannot be injected in-process.
1384
+ * Real filesystem + real SQLite for EventStore verification.
1385
+ */
1386
+ describe("logCommand --stdin integration", () => {
1387
+ let tempDir: string;
1388
+
1389
+ beforeEach(async () => {
1390
+ tempDir = await mkdtemp(join(tmpdir(), "log-stdin-test-"));
1391
+ const agentplateDir = join(tempDir, ".agentplate");
1392
+ await Bun.write(
1393
+ join(agentplateDir, "config.yaml"),
1394
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
1395
+ );
1396
+ });
1397
+
1398
+ afterEach(async () => {
1399
+ await cleanupTempDir(tempDir);
1400
+ });
1401
+
1402
+ /**
1403
+ * Helper: run `agentplate log` as a subprocess with stdin piped.
1404
+ * Uses bun to run the CLI entry point directly.
1405
+ */
1406
+ async function runLogWithStdin(
1407
+ event: string,
1408
+ agentName: string,
1409
+ stdinJson: Record<string, unknown>,
1410
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
1411
+ // Inline script that calls logCommand with --stdin and reads from stdin
1412
+ const scriptPath = join(tempDir, "_run-log.ts");
1413
+ const scriptContent = `
1414
+ import { logCommand } from "${join(import.meta.dir, "log.ts").replace(/\\/g, "/")}";
1415
+ const args = process.argv.slice(2);
1416
+ try {
1417
+ await logCommand(args);
1418
+ } catch (e) {
1419
+ console.error(e instanceof Error ? e.message : String(e));
1420
+ process.exit(1);
1421
+ }
1422
+ `;
1423
+ await Bun.write(scriptPath, scriptContent);
1424
+
1425
+ const proc = Bun.spawn(["bun", "run", scriptPath, event, "--agent", agentName, "--stdin"], {
1426
+ cwd: tempDir,
1427
+ stdin: "pipe",
1428
+ stdout: "pipe",
1429
+ stderr: "pipe",
1430
+ // Pin project root to tempDir. Without this, a subprocess started from
1431
+ // inside an `ap sling`-spawned worktree inherits AGENTPLATE_PROJECT_ROOT
1432
+ // pointing at the parent project, and writes events to prod's events.db.
1433
+ env: { ...process.env, AGENTPLATE_PROJECT_ROOT: tempDir },
1434
+ });
1435
+
1436
+ // Write the JSON payload to stdin and close
1437
+ proc.stdin.write(JSON.stringify(stdinJson));
1438
+ proc.stdin.end();
1439
+
1440
+ const exitCode = await proc.exited;
1441
+ const stdout = await new Response(proc.stdout).text();
1442
+ const stderr = await new Response(proc.stderr).text();
1443
+
1444
+ return { exitCode, stdout, stderr };
1445
+ }
1446
+
1447
+ test("tool-start with --stdin writes to EventStore", async () => {
1448
+ const payload = {
1449
+ tool_name: "Read",
1450
+ tool_input: { file_path: "/src/index.ts" },
1451
+ session_id: "sess-test-001",
1452
+ };
1453
+
1454
+ const result = await runLogWithStdin("tool-start", "stdin-builder", payload);
1455
+ expect(result.exitCode).toBe(0);
1456
+
1457
+ // Verify EventStore has the event
1458
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1459
+ const eventStore = createEventStore(eventsDbPath);
1460
+ const events = eventStore.getByAgent("stdin-builder");
1461
+ eventStore.close();
1462
+
1463
+ expect(events).toHaveLength(1);
1464
+ const event = events[0] as StoredEvent;
1465
+ expect(event.eventType).toBe("tool_start");
1466
+ expect(event.toolName).toBe("Read");
1467
+ expect(event.sessionId).toBe("sess-test-001");
1468
+ expect(event.agentName).toBe("stdin-builder");
1469
+
1470
+ // Verify filtered tool args were stored
1471
+ const toolArgs = JSON.parse(event.toolArgs ?? "{}");
1472
+ expect(toolArgs.file_path).toBe("/src/index.ts");
1473
+
1474
+ // Verify summary in data
1475
+ const data = JSON.parse(event.data ?? "{}");
1476
+ expect(data.summary).toBe("read: /src/index.ts");
1477
+ });
1478
+
1479
+ test("tool-end with --stdin writes to EventStore and correlates with tool-start", async () => {
1480
+ // First create a tool-start event
1481
+ const startPayload = {
1482
+ tool_name: "Bash",
1483
+ tool_input: { command: "bun test" },
1484
+ session_id: "sess-test-002",
1485
+ };
1486
+ const startResult = await runLogWithStdin("tool-start", "correlate-agent", startPayload);
1487
+ expect(startResult.exitCode).toBe(0);
1488
+
1489
+ // Small delay to ensure measurable duration
1490
+ await new Promise((resolve) => setTimeout(resolve, 10));
1491
+
1492
+ // Now send tool-end
1493
+ const endPayload = {
1494
+ tool_name: "Bash",
1495
+ tool_input: { command: "bun test" },
1496
+ session_id: "sess-test-002",
1497
+ };
1498
+ const endResult = await runLogWithStdin("tool-end", "correlate-agent", endPayload);
1499
+ expect(endResult.exitCode).toBe(0);
1500
+
1501
+ // Verify EventStore has both events
1502
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1503
+ const eventStore = createEventStore(eventsDbPath);
1504
+ const events = eventStore.getByAgent("correlate-agent");
1505
+ eventStore.close();
1506
+
1507
+ expect(events).toHaveLength(2);
1508
+
1509
+ const startEvent = events.find((e) => e.eventType === "tool_start");
1510
+ const endEvent = events.find((e) => e.eventType === "tool_end");
1511
+ expect(startEvent).toBeDefined();
1512
+ expect(endEvent).toBeDefined();
1513
+
1514
+ // The start event should have tool_duration_ms set by correlateToolEnd()
1515
+ // (value may be affected by SQLite timestamp vs Date.now() timezone behavior,
1516
+ // so we only assert it was populated — not the exact value)
1517
+ expect(startEvent?.toolDurationMs).not.toBeNull();
1518
+ });
1519
+
1520
+ test("tool-start with --stdin filters large tool_input", async () => {
1521
+ const payload = {
1522
+ tool_name: "Write",
1523
+ tool_input: {
1524
+ file_path: "/src/new-file.ts",
1525
+ content: "x".repeat(50_000), // 50KB of content — should be dropped
1526
+ },
1527
+ session_id: "sess-test-003",
1528
+ };
1529
+
1530
+ const result = await runLogWithStdin("tool-start", "filter-agent", payload);
1531
+ expect(result.exitCode).toBe(0);
1532
+
1533
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1534
+ const eventStore = createEventStore(eventsDbPath);
1535
+ const events = eventStore.getByAgent("filter-agent");
1536
+ eventStore.close();
1537
+
1538
+ expect(events).toHaveLength(1);
1539
+ const event = events[0] as StoredEvent;
1540
+
1541
+ // The Write filter keeps file_path but drops content
1542
+ const toolArgs = JSON.parse(event.toolArgs ?? "{}");
1543
+ expect(toolArgs.file_path).toBe("/src/new-file.ts");
1544
+ expect(toolArgs).not.toHaveProperty("content");
1545
+
1546
+ // Verify summary
1547
+ const data = JSON.parse(event.data ?? "{}");
1548
+ expect(data.summary).toBe("write: /src/new-file.ts");
1549
+ });
1550
+
1551
+ test("session-end with --stdin writes to EventStore with transcript_path", async () => {
1552
+ const payload = {
1553
+ session_id: "sess-test-004",
1554
+ transcript_path: "/tmp/transcript.jsonl",
1555
+ };
1556
+
1557
+ const result = await runLogWithStdin("session-end", "session-end-agent", payload);
1558
+ expect(result.exitCode).toBe(0);
1559
+
1560
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1561
+ const eventStore = createEventStore(eventsDbPath);
1562
+ const events = eventStore.getByAgent("session-end-agent");
1563
+ eventStore.close();
1564
+
1565
+ expect(events).toHaveLength(1);
1566
+ const event = events[0] as StoredEvent;
1567
+ expect(event.eventType).toBe("session_end");
1568
+ expect(event.sessionId).toBe("sess-test-004");
1569
+
1570
+ // Verify transcript path stored in data
1571
+ const data = JSON.parse(event.data ?? "{}");
1572
+ expect(data.transcriptPath).toBe("/tmp/transcript.jsonl");
1573
+ });
1574
+
1575
+ test("tool-start with --stdin still writes to legacy log files", async () => {
1576
+ const payload = {
1577
+ tool_name: "Grep",
1578
+ tool_input: { pattern: "TODO", path: "/src" },
1579
+ session_id: "sess-test-005",
1580
+ };
1581
+
1582
+ const result = await runLogWithStdin("tool-start", "legacy-compat-agent", payload);
1583
+ expect(result.exitCode).toBe(0);
1584
+
1585
+ // Wait for async file writes to complete
1586
+ await new Promise((resolve) => setTimeout(resolve, 50));
1587
+
1588
+ // Verify legacy log files exist
1589
+ const logsDir = join(tempDir, ".agentplate", "logs", "legacy-compat-agent");
1590
+ const markerPath = join(logsDir, ".current-session");
1591
+ const markerFile = Bun.file(markerPath);
1592
+ expect(await markerFile.exists()).toBe(true);
1593
+
1594
+ const sessionDir = (await markerFile.text()).trim();
1595
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
1596
+ expect(await eventsFile.exists()).toBe(true);
1597
+
1598
+ const eventsContent = await eventsFile.text();
1599
+ expect(eventsContent).toContain("tool.start");
1600
+ expect(eventsContent).toContain("Grep");
1601
+ });
1602
+
1603
+ test("tool-start with --stdin handles empty stdin gracefully", async () => {
1604
+ // Send empty JSON object — should still work (falls back to "unknown" tool name)
1605
+ const scriptPath = join(tempDir, "_run-log-empty.ts");
1606
+ const scriptContent = `
1607
+ import { logCommand } from "${join(import.meta.dir, "log.ts").replace(/\\/g, "/")}";
1608
+
1609
+ try {
1610
+ await logCommand(["tool-start", "--agent", "empty-stdin-agent", "--stdin"]);
1611
+ } catch (e) {
1612
+ console.error(e instanceof Error ? e.message : String(e));
1613
+ process.exit(1);
1614
+ }
1615
+ `;
1616
+ await Bun.write(scriptPath, scriptContent);
1617
+
1618
+ const proc = Bun.spawn(["bun", "run", scriptPath], {
1619
+ cwd: tempDir,
1620
+ stdin: "pipe",
1621
+ stdout: "pipe",
1622
+ stderr: "pipe",
1623
+ env: { ...process.env, AGENTPLATE_PROJECT_ROOT: tempDir },
1624
+ });
1625
+
1626
+ // Write empty string and close immediately
1627
+ proc.stdin.end();
1628
+
1629
+ const exitCode = await proc.exited;
1630
+ expect(exitCode).toBe(0);
1631
+ });
1632
+
1633
+ test("tool-start with --stdin and unknown tool name uses fallback filter", async () => {
1634
+ const payload = {
1635
+ tool_name: "SomeCustomTool",
1636
+ tool_input: { custom_key: "custom_value" },
1637
+ session_id: "sess-test-006",
1638
+ };
1639
+
1640
+ const result = await runLogWithStdin("tool-start", "custom-tool-agent", payload);
1641
+ expect(result.exitCode).toBe(0);
1642
+
1643
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1644
+ const eventStore = createEventStore(eventsDbPath);
1645
+ const events = eventStore.getByAgent("custom-tool-agent");
1646
+ eventStore.close();
1647
+
1648
+ expect(events).toHaveLength(1);
1649
+ const event = events[0] as StoredEvent;
1650
+ expect(event.toolName).toBe("SomeCustomTool");
1651
+
1652
+ // Unknown tools get empty args from filterToolArgs
1653
+ const toolArgs = JSON.parse(event.toolArgs ?? "{}");
1654
+ expect(toolArgs).toEqual({});
1655
+
1656
+ const data = JSON.parse(event.data ?? "{}");
1657
+ expect(data.summary).toBe("SomeCustomTool");
1658
+ });
1659
+
1660
+ test("tool-end with --stdin handles large payloads (>64KB)", async () => {
1661
+ const payload = {
1662
+ tool_name: "Bash",
1663
+ tool_input: { command: "cat /some/file" },
1664
+ tool_result: "x".repeat(100_000), // 100KB payload
1665
+ session_id: "sess-large-payload",
1666
+ };
1667
+
1668
+ const result = await runLogWithStdin("tool-end", "large-payload-agent", payload);
1669
+ expect(result.exitCode).toBe(0);
1670
+
1671
+ // Verify EventStore received the event with correct tool name
1672
+ const eventsDbPath = join(tempDir, ".agentplate", "events.db");
1673
+ const eventStore = createEventStore(eventsDbPath);
1674
+ const events = eventStore.getByAgent("large-payload-agent");
1675
+ eventStore.close();
1676
+
1677
+ expect(events).toHaveLength(1);
1678
+ const event = events[0] as StoredEvent;
1679
+ expect(event.eventType).toBe("tool_end");
1680
+ expect(event.toolName).toBe("Bash");
1681
+ // tool_result is not stored in EventStore (filtered out), but tool_name was parsed correctly
1682
+ });
1683
+ });
1684
+
1685
+ describe("appendOutcomeToAppliedRecords", () => {
1686
+ let tempDir: string;
1687
+
1688
+ /** Minimal fake LoamClient for appendOutcomeToAppliedRecords tests. */
1689
+ function makeOutcomeClient(opts?: { appendOutcomeShouldFail?: boolean }): {
1690
+ client: LoamClient;
1691
+ appendOutcomeCalls: Array<{ domain: string; id: string; outcome: Record<string, unknown> }>;
1692
+ } {
1693
+ const appendOutcomeCalls: Array<{
1694
+ domain: string;
1695
+ id: string;
1696
+ outcome: Record<string, unknown>;
1697
+ }> = [];
1698
+ const client = {
1699
+ async appendOutcome(domain: string, id: string, outcome: Record<string, unknown>) {
1700
+ if (opts?.appendOutcomeShouldFail) throw new Error("loam appendOutcome failed");
1701
+ appendOutcomeCalls.push({ domain, id, outcome });
1702
+ },
1703
+ } as unknown as LoamClient;
1704
+ return { client, appendOutcomeCalls };
1705
+ }
1706
+
1707
+ beforeEach(async () => {
1708
+ tempDir = await mkdtemp(join(tmpdir(), "outcome-test-"));
1709
+ });
1710
+
1711
+ afterEach(async () => {
1712
+ await cleanupTempDir(tempDir);
1713
+ });
1714
+
1715
+ test("returns 0 when applied-records.json does not exist (backward compat)", async () => {
1716
+ const { client } = makeOutcomeClient();
1717
+ const count = await appendOutcomeToAppliedRecords({
1718
+ loamClient: client,
1719
+ agentName: "test-agent",
1720
+ capability: "builder",
1721
+ taskId: "bead-001",
1722
+ projectRoot: tempDir,
1723
+ });
1724
+ expect(count).toBe(0);
1725
+ });
1726
+
1727
+ test("returns 0 when records array is empty", async () => {
1728
+ const agentDir = join(tempDir, ".agentplate", "agents", "test-agent");
1729
+ await mkdir(agentDir, { recursive: true });
1730
+ await Bun.write(
1731
+ join(agentDir, "applied-records.json"),
1732
+ JSON.stringify({
1733
+ taskId: "bead-001",
1734
+ agentName: "test-agent",
1735
+ capability: "builder",
1736
+ records: [],
1737
+ }),
1738
+ );
1739
+
1740
+ const { client } = makeOutcomeClient();
1741
+ const count = await appendOutcomeToAppliedRecords({
1742
+ loamClient: client,
1743
+ agentName: "test-agent",
1744
+ capability: "builder",
1745
+ taskId: "bead-001",
1746
+ projectRoot: tempDir,
1747
+ });
1748
+ expect(count).toBe(0);
1749
+ });
1750
+
1751
+ test("calls appendOutcome for each record and returns count", async () => {
1752
+ const agentDir = join(tempDir, ".agentplate", "agents", "test-agent");
1753
+ await mkdir(agentDir, { recursive: true });
1754
+ const records = [
1755
+ { id: "mx-aaa111", domain: "agents" },
1756
+ { id: "mx-bbb222", domain: "typescript" },
1757
+ ];
1758
+ await Bun.write(
1759
+ join(agentDir, "applied-records.json"),
1760
+ JSON.stringify({
1761
+ taskId: "bead-001",
1762
+ agentName: "test-agent",
1763
+ capability: "builder",
1764
+ records,
1765
+ }),
1766
+ );
1767
+
1768
+ const { client, appendOutcomeCalls } = makeOutcomeClient();
1769
+ const count = await appendOutcomeToAppliedRecords({
1770
+ loamClient: client,
1771
+ agentName: "test-agent",
1772
+ capability: "builder",
1773
+ taskId: "bead-001",
1774
+ projectRoot: tempDir,
1775
+ });
1776
+
1777
+ expect(count).toBe(2);
1778
+ expect(appendOutcomeCalls).toHaveLength(2);
1779
+ expect(appendOutcomeCalls[0]).toMatchObject({ id: "mx-aaa111", domain: "agents" });
1780
+ expect(appendOutcomeCalls[1]).toMatchObject({ id: "mx-bbb222", domain: "typescript" });
1781
+ expect(appendOutcomeCalls[0]?.outcome).toMatchObject({
1782
+ status: "success",
1783
+ agent: "test-agent",
1784
+ });
1785
+ });
1786
+
1787
+ test("cleans up applied-records.json after processing", async () => {
1788
+ const agentDir = join(tempDir, ".agentplate", "agents", "test-agent");
1789
+ await mkdir(agentDir, { recursive: true });
1790
+ const appliedPath = join(agentDir, "applied-records.json");
1791
+ await Bun.write(
1792
+ appliedPath,
1793
+ JSON.stringify({
1794
+ taskId: "bead-001",
1795
+ agentName: "test-agent",
1796
+ capability: "builder",
1797
+ records: [{ id: "mx-abc123", domain: "agents" }],
1798
+ }),
1799
+ );
1800
+
1801
+ const { client } = makeOutcomeClient();
1802
+ await appendOutcomeToAppliedRecords({
1803
+ loamClient: client,
1804
+ agentName: "test-agent",
1805
+ capability: "builder",
1806
+ taskId: "bead-001",
1807
+ projectRoot: tempDir,
1808
+ });
1809
+
1810
+ expect(await Bun.file(appliedPath).exists()).toBe(false);
1811
+ });
1812
+
1813
+ test("continues when individual appendOutcome calls fail (non-fatal per record)", async () => {
1814
+ const agentDir = join(tempDir, ".agentplate", "agents", "test-agent");
1815
+ await mkdir(agentDir, { recursive: true });
1816
+ const records = [
1817
+ { id: "mx-fail111", domain: "agents" },
1818
+ { id: "mx-fail222", domain: "typescript" },
1819
+ ];
1820
+ await Bun.write(
1821
+ join(agentDir, "applied-records.json"),
1822
+ JSON.stringify({
1823
+ taskId: "bead-002",
1824
+ agentName: "test-agent",
1825
+ capability: "builder",
1826
+ records,
1827
+ }),
1828
+ );
1829
+
1830
+ // appendOutcomeShouldFail=true makes all calls throw — should return 0 but not throw
1831
+ const { client } = makeOutcomeClient({ appendOutcomeShouldFail: true });
1832
+ const count = await appendOutcomeToAppliedRecords({
1833
+ loamClient: client,
1834
+ agentName: "test-agent",
1835
+ capability: "builder",
1836
+ taskId: "bead-002",
1837
+ projectRoot: tempDir,
1838
+ });
1839
+ expect(count).toBe(0);
1840
+ });
1841
+
1842
+ test("returns 0 for malformed JSON", async () => {
1843
+ const agentDir = join(tempDir, ".agentplate", "agents", "test-agent");
1844
+ await mkdir(agentDir, { recursive: true });
1845
+ await Bun.write(join(agentDir, "applied-records.json"), "not-valid-json{{{");
1846
+
1847
+ const { client } = makeOutcomeClient();
1848
+ const count = await appendOutcomeToAppliedRecords({
1849
+ loamClient: client,
1850
+ agentName: "test-agent",
1851
+ capability: "builder",
1852
+ taskId: null,
1853
+ projectRoot: tempDir,
1854
+ });
1855
+ expect(count).toBe(0);
1856
+ });
1857
+
1858
+ test("uses supplied outcomeStatus when provided", async () => {
1859
+ const agentDir = join(tempDir, ".agentplate", "agents", "test-agent");
1860
+ await mkdir(agentDir, { recursive: true });
1861
+ await Bun.write(
1862
+ join(agentDir, "applied-records.json"),
1863
+ JSON.stringify({
1864
+ taskId: "bead-outcome",
1865
+ agentName: "test-agent",
1866
+ capability: "builder",
1867
+ records: [{ id: "mx-aaa111", domain: "agents" }],
1868
+ }),
1869
+ );
1870
+
1871
+ const { client, appendOutcomeCalls } = makeOutcomeClient();
1872
+ await appendOutcomeToAppliedRecords({
1873
+ loamClient: client,
1874
+ agentName: "test-agent",
1875
+ capability: "builder",
1876
+ taskId: "bead-outcome",
1877
+ projectRoot: tempDir,
1878
+ outcomeStatus: "failure",
1879
+ });
1880
+
1881
+ expect(appendOutcomeCalls).toHaveLength(1);
1882
+ expect(appendOutcomeCalls[0]?.outcome).toMatchObject({ status: "failure" });
1883
+ expect(appendOutcomeCalls[0]?.outcome.notes).toContain("Quality gates: failure");
1884
+ });
1885
+
1886
+ test("falls back to 'success' when outcomeStatus is undefined (backward compat)", async () => {
1887
+ const agentDir = join(tempDir, ".agentplate", "agents", "test-agent");
1888
+ await mkdir(agentDir, { recursive: true });
1889
+ await Bun.write(
1890
+ join(agentDir, "applied-records.json"),
1891
+ JSON.stringify({
1892
+ taskId: "bead-default",
1893
+ agentName: "test-agent",
1894
+ capability: "builder",
1895
+ records: [{ id: "mx-bbb222", domain: "agents" }],
1896
+ }),
1897
+ );
1898
+
1899
+ const { client, appendOutcomeCalls } = makeOutcomeClient();
1900
+ await appendOutcomeToAppliedRecords({
1901
+ loamClient: client,
1902
+ agentName: "test-agent",
1903
+ capability: "builder",
1904
+ taskId: "bead-default",
1905
+ projectRoot: tempDir,
1906
+ });
1907
+
1908
+ expect(appendOutcomeCalls).toHaveLength(1);
1909
+ expect(appendOutcomeCalls[0]?.outcome.status).toBe("success");
1910
+ // No "Quality gates:" annotation when caller didn't provide outcomeStatus
1911
+ expect(appendOutcomeCalls[0]?.outcome.notes).not.toContain("Quality gates:");
1912
+ });
1913
+ });