@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,1535 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ clearProjectRootOverride,
7
+ clearWarningsSeen,
8
+ DEFAULT_CONFIG,
9
+ DEFAULT_QUALITY_GATES,
10
+ loadConfig,
11
+ resolveProjectRoot,
12
+ setProjectRootOverride,
13
+ } from "./config.ts";
14
+ import { ValidationError } from "./errors.ts";
15
+ import { cleanupTempDir, createTempGitRepo, runGitInDir } from "./test-helpers.ts";
16
+
17
+ describe("loadConfig", () => {
18
+ let tempDir: string;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-test-"));
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await cleanupTempDir(tempDir);
26
+ });
27
+
28
+ async function writeConfig(yaml: string): Promise<void> {
29
+ const agentplateDir = join(tempDir, ".agentplate");
30
+ await Bun.write(join(agentplateDir, "config.yaml"), yaml);
31
+ }
32
+
33
+ async function ensureAgentplateDir(): Promise<void> {
34
+ const { mkdir } = await import("node:fs/promises");
35
+ await mkdir(join(tempDir, ".agentplate"), { recursive: true });
36
+ }
37
+
38
+ test("returns defaults when no config file exists", async () => {
39
+ const config = await loadConfig(tempDir);
40
+
41
+ expect(config.project.root).toBe(tempDir);
42
+ expect(config.project.canonicalBranch).toBe("main");
43
+ expect(config.agents.maxConcurrent).toBe(25);
44
+ expect(config.agents.maxDepth).toBe(2);
45
+ expect(config.taskTracker.enabled).toBe(true);
46
+ expect(config.loam.enabled).toBe(true);
47
+ expect(config.loam.primeFormat).toBe("markdown");
48
+ expect(config.logging.verbose).toBe(false);
49
+ });
50
+
51
+ test("sets project.name from directory name", async () => {
52
+ const config = await loadConfig(tempDir);
53
+ const parts = tempDir.split("/");
54
+ const expectedName = parts[parts.length - 1] ?? "unknown";
55
+ expect(config.project.name).toBe(expectedName);
56
+ });
57
+
58
+ test("merges config file values over defaults", async () => {
59
+ await ensureAgentplateDir();
60
+ await writeConfig(`
61
+ project:
62
+ canonicalBranch: develop
63
+ agents:
64
+ maxConcurrent: 10
65
+ `);
66
+
67
+ const config = await loadConfig(tempDir);
68
+
69
+ expect(config.project.canonicalBranch).toBe("develop");
70
+ expect(config.agents.maxConcurrent).toBe(10);
71
+ // Non-overridden values keep defaults
72
+ expect(config.agents.maxDepth).toBe(2);
73
+ expect(config.taskTracker.enabled).toBe(true);
74
+ });
75
+
76
+ test("always sets project.root to the actual projectRoot", async () => {
77
+ await ensureAgentplateDir();
78
+ await writeConfig(`
79
+ project:
80
+ root: /some/wrong/path
81
+ `);
82
+
83
+ const config = await loadConfig(tempDir);
84
+ expect(config.project.root).toBe(tempDir);
85
+ });
86
+
87
+ test("parses boolean values correctly", async () => {
88
+ await ensureAgentplateDir();
89
+ await writeConfig(`
90
+ beads:
91
+ enabled: false
92
+ loam:
93
+ enabled: true
94
+ logging:
95
+ verbose: true
96
+ redactSecrets: false
97
+ `);
98
+
99
+ const config = await loadConfig(tempDir);
100
+
101
+ expect(config.taskTracker.enabled).toBe(false);
102
+ expect(config.loam.enabled).toBe(true);
103
+ expect(config.logging.verbose).toBe(true);
104
+ expect(config.logging.redactSecrets).toBe(false);
105
+ });
106
+
107
+ test("parses empty array literal", async () => {
108
+ await ensureAgentplateDir();
109
+ await writeConfig(`
110
+ loam:
111
+ domains: []
112
+ `);
113
+
114
+ const config = await loadConfig(tempDir);
115
+ expect(config.loam.domains).toEqual([]);
116
+ });
117
+
118
+ test("parses numeric values including underscore-separated", async () => {
119
+ await ensureAgentplateDir();
120
+ await writeConfig(`
121
+ agents:
122
+ staggerDelayMs: 5000
123
+ watchdog:
124
+ tier0IntervalMs: 60000
125
+ staleThresholdMs: 120000
126
+ zombieThresholdMs: 300000
127
+ `);
128
+
129
+ const config = await loadConfig(tempDir);
130
+ expect(config.agents.staggerDelayMs).toBe(5000);
131
+ expect(config.watchdog.tier0IntervalMs).toBe(60000);
132
+ });
133
+
134
+ test("handles quoted string values", async () => {
135
+ await ensureAgentplateDir();
136
+ await writeConfig(`
137
+ project:
138
+ canonicalBranch: "develop"
139
+ `);
140
+
141
+ const config = await loadConfig(tempDir);
142
+ expect(config.project.canonicalBranch).toBe("develop");
143
+ });
144
+
145
+ test("ignores comments and empty lines", async () => {
146
+ await ensureAgentplateDir();
147
+ await writeConfig(`
148
+ # This is a comment
149
+ project:
150
+ canonicalBranch: develop # inline comment
151
+
152
+ # Another comment
153
+ agents:
154
+ maxConcurrent: 3
155
+ `);
156
+
157
+ const config = await loadConfig(tempDir);
158
+ expect(config.project.canonicalBranch).toBe("develop");
159
+ expect(config.agents.maxConcurrent).toBe(3);
160
+ });
161
+
162
+ test("config.local.yaml overrides values from config.yaml", async () => {
163
+ await ensureAgentplateDir();
164
+ await writeConfig(`
165
+ project:
166
+ canonicalBranch: develop
167
+ agents:
168
+ maxConcurrent: 10
169
+ `);
170
+ await Bun.write(
171
+ join(tempDir, ".agentplate", "config.local.yaml"),
172
+ `agents:\n maxConcurrent: 4\n`,
173
+ );
174
+
175
+ const config = await loadConfig(tempDir);
176
+ // Local override wins
177
+ expect(config.agents.maxConcurrent).toBe(4);
178
+ // Non-overridden value from config.yaml preserved
179
+ expect(config.project.canonicalBranch).toBe("develop");
180
+ });
181
+
182
+ test("config.local.yaml works when config.yaml does not exist", async () => {
183
+ await ensureAgentplateDir();
184
+ // No config.yaml, only config.local.yaml
185
+ await Bun.write(
186
+ join(tempDir, ".agentplate", "config.local.yaml"),
187
+ `agents:\n maxConcurrent: 3\n`,
188
+ );
189
+
190
+ const config = await loadConfig(tempDir);
191
+ expect(config.agents.maxConcurrent).toBe(3);
192
+ // Defaults still applied
193
+ expect(config.project.canonicalBranch).toBe("main");
194
+ });
195
+
196
+ test("values from config.local.yaml are validated", async () => {
197
+ await ensureAgentplateDir();
198
+ await writeConfig(`
199
+ project:
200
+ canonicalBranch: main
201
+ `);
202
+ await Bun.write(
203
+ join(tempDir, ".agentplate", "config.local.yaml"),
204
+ `agents:\n maxConcurrent: -1\n`,
205
+ );
206
+
207
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
208
+ });
209
+
210
+ test("config.local.yaml deep merges nested objects", async () => {
211
+ await ensureAgentplateDir();
212
+ await writeConfig(`
213
+ watchdog:
214
+ tier0Enabled: false
215
+ staleThresholdMs: 120000
216
+ `);
217
+ await Bun.write(
218
+ join(tempDir, ".agentplate", "config.local.yaml"),
219
+ `watchdog:\n tier0Enabled: true\n`,
220
+ );
221
+
222
+ const config = await loadConfig(tempDir);
223
+ // Local override
224
+ expect(config.watchdog.tier0Enabled).toBe(true);
225
+ // Non-overridden value from config.yaml preserved
226
+ expect(config.watchdog.staleThresholdMs).toBe(120000);
227
+ });
228
+
229
+ test("parses providers section from config.yaml", async () => {
230
+ await ensureAgentplateDir();
231
+ await writeConfig(`
232
+ providers:
233
+ openrouter:
234
+ type: gateway
235
+ baseUrl: https://openrouter.ai/api/v1
236
+ authTokenEnv: OPENROUTER_API_KEY
237
+ `);
238
+ const config = await loadConfig(tempDir);
239
+ expect(config.providers.openrouter).toEqual({
240
+ type: "gateway",
241
+ baseUrl: "https://openrouter.ai/api/v1",
242
+ authTokenEnv: "OPENROUTER_API_KEY",
243
+ });
244
+ // Default anthropic provider preserved via deep merge
245
+ expect(config.providers.anthropic).toEqual({ type: "native" });
246
+ });
247
+
248
+ test("config.local.yaml overrides provider settings", async () => {
249
+ await ensureAgentplateDir();
250
+ await writeConfig(`
251
+ providers:
252
+ anthropic:
253
+ type: native
254
+ `);
255
+ await Bun.write(
256
+ join(tempDir, ".agentplate", "config.local.yaml"),
257
+ `providers:\n anthropic:\n type: gateway\n baseUrl: http://localhost:8080\n authTokenEnv: ANTHROPIC_GATEWAY_KEY\n`,
258
+ );
259
+ const config = await loadConfig(tempDir);
260
+ expect(config.providers.anthropic).toEqual({
261
+ type: "gateway",
262
+ baseUrl: "http://localhost:8080",
263
+ authTokenEnv: "ANTHROPIC_GATEWAY_KEY",
264
+ });
265
+ });
266
+
267
+ test("empty providers section preserves defaults", async () => {
268
+ await ensureAgentplateDir();
269
+ await writeConfig(`
270
+ providers:
271
+ `);
272
+ const config = await loadConfig(tempDir);
273
+ expect(config.providers.anthropic).toEqual({ type: "native" });
274
+ });
275
+
276
+ test("multiple providers parsed correctly", async () => {
277
+ await ensureAgentplateDir();
278
+ await writeConfig(`
279
+ providers:
280
+ anthropic:
281
+ type: native
282
+ openrouter:
283
+ type: gateway
284
+ baseUrl: https://openrouter.ai/api/v1
285
+ authTokenEnv: OPENROUTER_API_KEY
286
+ litellm:
287
+ type: gateway
288
+ baseUrl: http://localhost:4000
289
+ authTokenEnv: LITELLM_API_KEY
290
+ `);
291
+ const config = await loadConfig(tempDir);
292
+ expect(Object.keys(config.providers).length).toBe(3);
293
+ expect(config.providers.anthropic).toEqual({ type: "native" });
294
+ expect(config.providers.openrouter).toEqual({
295
+ type: "gateway",
296
+ baseUrl: "https://openrouter.ai/api/v1",
297
+ authTokenEnv: "OPENROUTER_API_KEY",
298
+ });
299
+ expect(config.providers.litellm).toEqual({
300
+ type: "gateway",
301
+ baseUrl: "http://localhost:4000",
302
+ authTokenEnv: "LITELLM_API_KEY",
303
+ });
304
+ });
305
+
306
+ test("config.local.yaml adds new provider alongside config.yaml providers", async () => {
307
+ await ensureAgentplateDir();
308
+ await writeConfig(`
309
+ providers:
310
+ openrouter:
311
+ type: gateway
312
+ baseUrl: https://openrouter.ai/api/v1
313
+ authTokenEnv: OPENROUTER_API_KEY
314
+ `);
315
+ await Bun.write(
316
+ join(tempDir, ".agentplate", "config.local.yaml"),
317
+ `providers:\n litellm:\n type: gateway\n baseUrl: http://localhost:4000\n authTokenEnv: LITELLM_API_KEY\n`,
318
+ );
319
+ const config = await loadConfig(tempDir);
320
+ // All three providers present: default anthropic + openrouter from config.yaml + litellm from config.local.yaml
321
+ expect(config.providers.anthropic).toEqual({ type: "native" });
322
+ expect(config.providers.openrouter).toEqual({
323
+ type: "gateway",
324
+ baseUrl: "https://openrouter.ai/api/v1",
325
+ authTokenEnv: "OPENROUTER_API_KEY",
326
+ });
327
+ expect(config.providers.litellm).toEqual({
328
+ type: "gateway",
329
+ baseUrl: "http://localhost:4000",
330
+ authTokenEnv: "LITELLM_API_KEY",
331
+ });
332
+ });
333
+
334
+ test("simple model strings still work without providers section", async () => {
335
+ await ensureAgentplateDir();
336
+ await writeConfig(`
337
+ models:
338
+ coordinator: sonnet
339
+ builder: opus
340
+ monitor: haiku
341
+ `);
342
+ const config = await loadConfig(tempDir);
343
+ expect(config.models.coordinator).toBe("sonnet");
344
+ expect(config.models.builder).toBe("opus");
345
+ expect(config.models.monitor).toBe("haiku");
346
+ // Default anthropic provider still present even without explicit providers section
347
+ expect(config.providers.anthropic).toEqual({ type: "native" });
348
+ });
349
+
350
+ test("migrates deprecated watchdog tier1/tier2 keys to tier0/tier1", async () => {
351
+ await ensureAgentplateDir();
352
+ await writeConfig(`
353
+ watchdog:
354
+ tier1Enabled: true
355
+ tier1IntervalMs: 45000
356
+ tier2Enabled: true
357
+ `);
358
+
359
+ const config = await loadConfig(tempDir);
360
+ // Old tier1 (mechanical daemon) → new tier0
361
+ expect(config.watchdog.tier0Enabled).toBe(true);
362
+ expect(config.watchdog.tier0IntervalMs).toBe(45000);
363
+ // Old tier2 (AI triage) → new tier1
364
+ expect(config.watchdog.tier1Enabled).toBe(true);
365
+ });
366
+
367
+ test("new-style tier keys take precedence over deprecated keys", async () => {
368
+ await ensureAgentplateDir();
369
+ await writeConfig(`
370
+ watchdog:
371
+ tier0Enabled: false
372
+ tier0IntervalMs: 20000
373
+ tier1Enabled: true
374
+ triageTimeoutMs: 15000
375
+ `);
376
+
377
+ const config = await loadConfig(tempDir);
378
+ // New keys used directly — no migration needed
379
+ expect(config.watchdog.tier0Enabled).toBe(false);
380
+ expect(config.watchdog.tier0IntervalMs).toBe(20000);
381
+ expect(config.watchdog.tier1Enabled).toBe(true);
382
+ });
383
+
384
+ test("migrates deprecated beads: key to taskTracker:", async () => {
385
+ await ensureAgentplateDir();
386
+ await writeConfig(`
387
+ beads:
388
+ enabled: false
389
+ `);
390
+
391
+ const config = await loadConfig(tempDir);
392
+ expect(config.taskTracker.backend).toBe("beads");
393
+ expect(config.taskTracker.enabled).toBe(false);
394
+ });
395
+
396
+ test("migrates deprecated sprout: key to taskTracker:", async () => {
397
+ await ensureAgentplateDir();
398
+ await writeConfig(`
399
+ sprout:
400
+ enabled: true
401
+ `);
402
+
403
+ const config = await loadConfig(tempDir);
404
+ expect(config.taskTracker.backend).toBe("sprout");
405
+ expect(config.taskTracker.enabled).toBe(true);
406
+ });
407
+
408
+ test("taskTracker: key takes precedence over legacy keys", async () => {
409
+ await ensureAgentplateDir();
410
+ await writeConfig(`
411
+ taskTracker:
412
+ backend: auto
413
+ enabled: true
414
+ beads:
415
+ enabled: false
416
+ `);
417
+
418
+ const config = await loadConfig(tempDir);
419
+ // taskTracker present — beads key ignored
420
+ expect(config.taskTracker.backend).toBe("auto");
421
+ expect(config.taskTracker.enabled).toBe(true);
422
+ });
423
+
424
+ test("defaults taskTracker.backend to auto", async () => {
425
+ const config = await loadConfig(tempDir);
426
+ expect(config.taskTracker.backend).toBe("auto");
427
+ });
428
+ });
429
+
430
+ describe("validateConfig", () => {
431
+ let tempDir: string;
432
+
433
+ beforeEach(async () => {
434
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-test-"));
435
+ const { mkdir } = await import("node:fs/promises");
436
+ await mkdir(join(tempDir, ".agentplate"), { recursive: true });
437
+ clearWarningsSeen();
438
+ });
439
+
440
+ afterEach(async () => {
441
+ clearWarningsSeen();
442
+ await cleanupTempDir(tempDir);
443
+ });
444
+
445
+ async function writeConfig(yaml: string): Promise<void> {
446
+ await Bun.write(join(tempDir, ".agentplate", "config.yaml"), yaml);
447
+ }
448
+
449
+ test("rejects negative maxConcurrent", async () => {
450
+ await writeConfig(`
451
+ agents:
452
+ maxConcurrent: -1
453
+ `);
454
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
455
+ });
456
+
457
+ test("rejects zero maxConcurrent", async () => {
458
+ await writeConfig(`
459
+ agents:
460
+ maxConcurrent: 0
461
+ `);
462
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
463
+ });
464
+
465
+ test("rejects negative maxDepth", async () => {
466
+ await writeConfig(`
467
+ agents:
468
+ maxDepth: -1
469
+ `);
470
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
471
+ });
472
+
473
+ test("rejects negative staggerDelayMs", async () => {
474
+ await writeConfig(`
475
+ agents:
476
+ staggerDelayMs: -100
477
+ `);
478
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
479
+ });
480
+
481
+ test("accepts maxSessionsPerRun of 0 (unlimited)", async () => {
482
+ await writeConfig(`
483
+ agents:
484
+ maxSessionsPerRun: 0
485
+ `);
486
+ const config = await loadConfig(tempDir);
487
+ expect(config.agents.maxSessionsPerRun).toBe(0);
488
+ });
489
+
490
+ test("accepts positive maxSessionsPerRun", async () => {
491
+ await writeConfig(`
492
+ agents:
493
+ maxSessionsPerRun: 20
494
+ `);
495
+ const config = await loadConfig(tempDir);
496
+ expect(config.agents.maxSessionsPerRun).toBe(20);
497
+ });
498
+
499
+ test("rejects negative maxSessionsPerRun", async () => {
500
+ await writeConfig(`
501
+ agents:
502
+ maxSessionsPerRun: -1
503
+ `);
504
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
505
+ });
506
+
507
+ test("rejects non-integer maxSessionsPerRun", async () => {
508
+ await writeConfig(`
509
+ agents:
510
+ maxSessionsPerRun: 1.5
511
+ `);
512
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
513
+ });
514
+
515
+ test("validates maxAgentsPerLead must be non-negative", async () => {
516
+ await writeConfig("agents:\n maxAgentsPerLead: -1\n");
517
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
518
+ });
519
+
520
+ test("accepts maxAgentsPerLead of 0 (unlimited)", async () => {
521
+ await writeConfig("agents:\n maxAgentsPerLead: 0\n");
522
+ const config = await loadConfig(tempDir);
523
+ expect(config.agents.maxAgentsPerLead).toBe(0);
524
+ });
525
+
526
+ test("reads maxAgentsPerLead from config", async () => {
527
+ await writeConfig("agents:\n maxAgentsPerLead: 8\n");
528
+ const config = await loadConfig(tempDir);
529
+ expect(config.agents.maxAgentsPerLead).toBe(8);
530
+ });
531
+
532
+ test("defaults maxAgentsPerLead to 5", async () => {
533
+ const config = await loadConfig(tempDir);
534
+ expect(config.agents.maxAgentsPerLead).toBe(5);
535
+ });
536
+
537
+ test("rejects invalid loam.primeFormat", async () => {
538
+ await writeConfig(`
539
+ loam:
540
+ primeFormat: yaml
541
+ `);
542
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
543
+ });
544
+
545
+ test("rejects invalid taskTracker.backend", async () => {
546
+ await writeConfig(`
547
+ taskTracker:
548
+ backend: invalid
549
+ enabled: true
550
+ `);
551
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
552
+ });
553
+
554
+ test("rejects zombieThresholdMs <= staleThresholdMs", async () => {
555
+ await writeConfig(`
556
+ watchdog:
557
+ staleThresholdMs: 300000
558
+ zombieThresholdMs: 300000
559
+ `);
560
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
561
+ });
562
+
563
+ test("rejects non-positive tier0IntervalMs when tier0 is enabled", async () => {
564
+ await writeConfig(`
565
+ watchdog:
566
+ tier0Enabled: true
567
+ tier0IntervalMs: 0
568
+ `);
569
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
570
+ });
571
+
572
+ // rpcTimeoutMs tests
573
+ test("defaults rpcTimeoutMs to 5000", async () => {
574
+ const config = await loadConfig(tempDir);
575
+ expect(config.watchdog.rpcTimeoutMs).toBe(5000);
576
+ });
577
+
578
+ test("accepts valid rpcTimeoutMs", async () => {
579
+ await writeConfig(`
580
+ watchdog:
581
+ rpcTimeoutMs: 10000
582
+ `);
583
+ const config = await loadConfig(tempDir);
584
+ expect(config.watchdog.rpcTimeoutMs).toBe(10000);
585
+ });
586
+
587
+ test("rejects rpcTimeoutMs below 1000", async () => {
588
+ await writeConfig(`
589
+ watchdog:
590
+ rpcTimeoutMs: 999
591
+ `);
592
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
593
+ });
594
+
595
+ test("rejects rpcTimeoutMs above 30000", async () => {
596
+ await writeConfig(`
597
+ watchdog:
598
+ rpcTimeoutMs: 30001
599
+ `);
600
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
601
+ });
602
+
603
+ // triageTimeoutMs tests
604
+ test("defaults triageTimeoutMs to 30000", async () => {
605
+ const config = await loadConfig(tempDir);
606
+ expect(config.watchdog.triageTimeoutMs).toBe(30000);
607
+ });
608
+
609
+ test("accepts valid triageTimeoutMs", async () => {
610
+ await writeConfig(`
611
+ watchdog:
612
+ triageTimeoutMs: 60000
613
+ `);
614
+ const config = await loadConfig(tempDir);
615
+ expect(config.watchdog.triageTimeoutMs).toBe(60000);
616
+ });
617
+
618
+ test("rejects triageTimeoutMs below 5000", async () => {
619
+ await writeConfig(`
620
+ watchdog:
621
+ triageTimeoutMs: 4999
622
+ `);
623
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
624
+ });
625
+
626
+ test("rejects triageTimeoutMs above 120000", async () => {
627
+ await writeConfig(`
628
+ watchdog:
629
+ triageTimeoutMs: 120001
630
+ `);
631
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
632
+ });
633
+
634
+ test("rejects triageTimeoutMs >= tier0IntervalMs when tier1 is enabled", async () => {
635
+ // Must include tier0Enabled to avoid deprecated-key migration that would remap tier1Enabled
636
+ await writeConfig(`
637
+ watchdog:
638
+ tier0Enabled: true
639
+ tier1Enabled: true
640
+ tier0IntervalMs: 30000
641
+ triageTimeoutMs: 30000
642
+ `);
643
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
644
+ });
645
+
646
+ test("accepts triageTimeoutMs < tier0IntervalMs when tier1 is enabled", async () => {
647
+ await writeConfig(`
648
+ watchdog:
649
+ tier0Enabled: true
650
+ tier1Enabled: true
651
+ tier0IntervalMs: 60000
652
+ triageTimeoutMs: 30000
653
+ `);
654
+ const config = await loadConfig(tempDir);
655
+ expect(config.watchdog.triageTimeoutMs).toBe(30000);
656
+ });
657
+
658
+ test("allows triageTimeoutMs >= tier0IntervalMs when tier1 is disabled", async () => {
659
+ await writeConfig(`
660
+ watchdog:
661
+ tier0Enabled: true
662
+ tier1Enabled: false
663
+ tier0IntervalMs: 30000
664
+ triageTimeoutMs: 30000
665
+ `);
666
+ const config = await loadConfig(tempDir);
667
+ expect(config.watchdog.triageTimeoutMs).toBe(30000);
668
+ });
669
+
670
+ // maxEscalationLevel tests
671
+ test("defaults maxEscalationLevel to 3", async () => {
672
+ const config = await loadConfig(tempDir);
673
+ expect(config.watchdog.maxEscalationLevel).toBe(3);
674
+ });
675
+
676
+ test("accepts valid maxEscalationLevel", async () => {
677
+ await writeConfig(`
678
+ watchdog:
679
+ maxEscalationLevel: 5
680
+ `);
681
+ const config = await loadConfig(tempDir);
682
+ expect(config.watchdog.maxEscalationLevel).toBe(5);
683
+ });
684
+
685
+ test("rejects maxEscalationLevel below 1", async () => {
686
+ await writeConfig(`
687
+ watchdog:
688
+ maxEscalationLevel: 0
689
+ `);
690
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
691
+ });
692
+
693
+ test("rejects maxEscalationLevel above 5", async () => {
694
+ await writeConfig(`
695
+ watchdog:
696
+ maxEscalationLevel: 6
697
+ `);
698
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
699
+ });
700
+
701
+ test("accepts maxEscalationLevel boundary values 1 and 5", async () => {
702
+ await writeConfig(`
703
+ watchdog:
704
+ maxEscalationLevel: 1
705
+ `);
706
+ let config = await loadConfig(tempDir);
707
+ expect(config.watchdog.maxEscalationLevel).toBe(1);
708
+
709
+ await writeConfig(`
710
+ watchdog:
711
+ maxEscalationLevel: 5
712
+ `);
713
+ config = await loadConfig(tempDir);
714
+ expect(config.watchdog.maxEscalationLevel).toBe(5);
715
+ });
716
+
717
+ test("accepts empty models section", async () => {
718
+ await writeConfig(`
719
+ models:
720
+ `);
721
+ const config = await loadConfig(tempDir);
722
+ expect(config.models).toBeDefined();
723
+ });
724
+
725
+ test("accepts valid model names in models section", async () => {
726
+ await writeConfig(`
727
+ models:
728
+ coordinator: sonnet
729
+ monitor: haiku
730
+ builder: opus
731
+ `);
732
+ const config = await loadConfig(tempDir);
733
+ expect(config.models.coordinator).toBe("sonnet");
734
+ expect(config.models.monitor).toBe("haiku");
735
+ expect(config.models.builder).toBe("opus");
736
+ });
737
+
738
+ test("rejects invalid model name in models section", async () => {
739
+ await writeConfig(`
740
+ models:
741
+ coordinator: gpt4
742
+ `);
743
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
744
+ });
745
+
746
+ // Provider validation tests
747
+
748
+ test("rejects provider with invalid type", async () => {
749
+ await writeConfig(`
750
+ providers:
751
+ custom:
752
+ type: custom
753
+ `);
754
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
755
+ });
756
+
757
+ test("rejects gateway provider without baseUrl", async () => {
758
+ await writeConfig(`
759
+ providers:
760
+ mygateway:
761
+ type: gateway
762
+ authTokenEnv: MY_TOKEN
763
+ `);
764
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
765
+ });
766
+
767
+ test("rejects gateway provider without authTokenEnv", async () => {
768
+ await writeConfig(`
769
+ providers:
770
+ mygateway:
771
+ type: gateway
772
+ baseUrl: https://example.com
773
+ `);
774
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
775
+ });
776
+
777
+ test("accepts native provider without baseUrl or authTokenEnv", async () => {
778
+ await writeConfig(`
779
+ providers:
780
+ mylocal:
781
+ type: native
782
+ `);
783
+ const config = await loadConfig(tempDir);
784
+ expect(config.providers.mylocal).toEqual({ type: "native" });
785
+ });
786
+
787
+ // Model validation tests
788
+
789
+ test("accepts provider-prefixed model ref when provider exists", async () => {
790
+ await writeConfig(`
791
+ providers:
792
+ openrouter:
793
+ type: gateway
794
+ baseUrl: https://openrouter.ai/api/v1
795
+ authTokenEnv: OPENROUTER_API_KEY
796
+ models:
797
+ coordinator: openrouter/openai/gpt-5.3
798
+ `);
799
+ const config = await loadConfig(tempDir);
800
+ expect(config.models.coordinator).toBe("openrouter/openai/gpt-5.3");
801
+ });
802
+
803
+ test("rejects provider-prefixed model ref when provider is unknown", async () => {
804
+ await writeConfig(`
805
+ models:
806
+ coordinator: unknown/model
807
+ `);
808
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
809
+ });
810
+
811
+ test("rejects model ref with deeply nested slashes when provider unknown", async () => {
812
+ await writeConfig(`
813
+ models:
814
+ coordinator: unknown/openai/gpt-5.3/latest
815
+ `);
816
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
817
+ });
818
+
819
+ test("accepts model ref with deeply nested slashes when provider exists", async () => {
820
+ await writeConfig(`
821
+ providers:
822
+ openrouter:
823
+ type: gateway
824
+ baseUrl: https://openrouter.ai/api/v1
825
+ authTokenEnv: OPENROUTER_API_KEY
826
+ models:
827
+ coordinator: openrouter/openai/gpt-5.3/variant
828
+ `);
829
+ const config = await loadConfig(tempDir);
830
+ expect(config.models.coordinator).toBe("openrouter/openai/gpt-5.3/variant");
831
+ });
832
+
833
+ test("rejects bare invalid model name", async () => {
834
+ await writeConfig(`
835
+ models:
836
+ coordinator: gpt4
837
+ `);
838
+ const err = await loadConfig(tempDir).catch((e: unknown) => e);
839
+ expect(err).toBeInstanceOf(ValidationError);
840
+ expect((err as ValidationError).message).toContain("provider-prefixed ref");
841
+ });
842
+
843
+ test("accepts bare model name when runtime.default is codex", async () => {
844
+ await writeConfig(`
845
+ runtime:
846
+ default: codex
847
+ models:
848
+ coordinator: gpt-5.3-codex
849
+ `);
850
+ const config = await loadConfig(tempDir);
851
+ expect(config.models.coordinator).toBe("gpt-5.3-codex");
852
+ });
853
+
854
+ test("warns on bare non-Anthropic model in tool-heavy role when runtime.default is codex", async () => {
855
+ await writeConfig(`
856
+ runtime:
857
+ default: codex
858
+ models:
859
+ builder: gpt-5.3-codex
860
+ `);
861
+ const origWrite = process.stderr.write;
862
+ let capturedStderr = "";
863
+ process.stderr.write = ((s: string | Uint8Array) => {
864
+ if (typeof s === "string") capturedStderr += s;
865
+ return true;
866
+ }) as typeof process.stderr.write;
867
+ try {
868
+ await loadConfig(tempDir);
869
+ } finally {
870
+ process.stderr.write = origWrite;
871
+ }
872
+ expect(capturedStderr).toContain("WARNING: models.builder uses non-Anthropic model");
873
+ expect(capturedStderr).toContain("gpt-5.3-codex");
874
+ });
875
+
876
+ test("warns on non-Anthropic model in tool-heavy role", async () => {
877
+ await writeConfig(`
878
+ providers:
879
+ openrouter:
880
+ type: gateway
881
+ baseUrl: https://openrouter.ai/api/v1
882
+ authTokenEnv: OPENROUTER_API_KEY
883
+ models:
884
+ builder: openrouter/openai/gpt-4
885
+ `);
886
+ const origWrite = process.stderr.write;
887
+ let capturedStderr = "";
888
+ process.stderr.write = ((s: string | Uint8Array) => {
889
+ if (typeof s === "string") capturedStderr += s;
890
+ return true;
891
+ }) as typeof process.stderr.write;
892
+ try {
893
+ await loadConfig(tempDir);
894
+ } finally {
895
+ process.stderr.write = origWrite;
896
+ }
897
+ expect(capturedStderr).toContain("WARNING: models.builder uses non-Anthropic model");
898
+ expect(capturedStderr).toContain("openrouter/openai/gpt-4");
899
+ });
900
+
901
+ test("warns only once per role/model combination across multiple loadConfig calls", async () => {
902
+ await writeConfig(`
903
+ providers:
904
+ openrouter:
905
+ type: gateway
906
+ baseUrl: https://openrouter.ai/api/v1
907
+ authTokenEnv: OPENROUTER_API_KEY
908
+ models:
909
+ builder: openrouter/openai/gpt-4
910
+ `);
911
+ const origWrite = process.stderr.write;
912
+ const stderrLines: string[] = [];
913
+ process.stderr.write = ((s: string | Uint8Array) => {
914
+ if (typeof s === "string") stderrLines.push(s);
915
+ return true;
916
+ }) as typeof process.stderr.write;
917
+ try {
918
+ await loadConfig(tempDir);
919
+ await loadConfig(tempDir);
920
+ await loadConfig(tempDir);
921
+ } finally {
922
+ process.stderr.write = origWrite;
923
+ }
924
+ const warnings = stderrLines.filter((l) => l.includes("WARNING: models.builder"));
925
+ expect(warnings.length).toBe(1);
926
+ });
927
+
928
+ test("does not warn for non-Anthropic model in non-tool-heavy role", async () => {
929
+ await writeConfig(`
930
+ providers:
931
+ openrouter:
932
+ type: gateway
933
+ baseUrl: https://openrouter.ai/api/v1
934
+ authTokenEnv: OPENROUTER_API_KEY
935
+ models:
936
+ coordinator: openrouter/openai/gpt-4
937
+ `);
938
+ const origWrite = process.stderr.write;
939
+ let capturedStderr = "";
940
+ process.stderr.write = ((s: string | Uint8Array) => {
941
+ if (typeof s === "string") capturedStderr += s;
942
+ return true;
943
+ }) as typeof process.stderr.write;
944
+ try {
945
+ await loadConfig(tempDir);
946
+ } finally {
947
+ process.stderr.write = origWrite;
948
+ }
949
+ expect(capturedStderr).not.toContain("WARNING");
950
+ });
951
+
952
+ test("custom qualityGates from config.yaml are loaded", async () => {
953
+ await writeConfig(`
954
+ project:
955
+ canonicalBranch: main
956
+ qualityGates:
957
+ - name: Test
958
+ command: pytest
959
+ description: all tests pass
960
+ - name: Lint
961
+ command: ruff check .
962
+ description: no lint errors
963
+ `);
964
+ const config = await loadConfig(tempDir);
965
+ expect(config.project.qualityGates?.length).toBe(2);
966
+ expect(config.project.qualityGates?.[0]?.command).toBe("pytest");
967
+ expect(config.project.qualityGates?.[1]?.command).toBe("ruff check .");
968
+ });
969
+
970
+ test("rejects qualityGate with empty name", async () => {
971
+ await writeConfig(`
972
+ project:
973
+ canonicalBranch: main
974
+ qualityGates:
975
+ - name: ""
976
+ command: pytest
977
+ description: all tests pass
978
+ `);
979
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
980
+ });
981
+
982
+ test("rejects qualityGate with empty command", async () => {
983
+ await writeConfig(`
984
+ project:
985
+ canonicalBranch: main
986
+ qualityGates:
987
+ - name: Test
988
+ command: ""
989
+ description: all tests pass
990
+ `);
991
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
992
+ });
993
+
994
+ test("resets negative shellInitDelayMs to 0 with warning", async () => {
995
+ await writeConfig("runtime:\n shellInitDelayMs: -100\n");
996
+ const origWrite = process.stderr.write;
997
+ let capturedStderr = "";
998
+ process.stderr.write = ((s: string | Uint8Array) => {
999
+ if (typeof s === "string") capturedStderr += s;
1000
+ return true;
1001
+ }) as typeof process.stderr.write;
1002
+ try {
1003
+ const config = await loadConfig(tempDir);
1004
+ expect(config.runtime?.shellInitDelayMs).toBe(0);
1005
+ } finally {
1006
+ process.stderr.write = origWrite;
1007
+ }
1008
+ expect(capturedStderr).toContain("WARNING: runtime.shellInitDelayMs");
1009
+ });
1010
+
1011
+ test("resets Infinity shellInitDelayMs to 0 with warning", async () => {
1012
+ await writeConfig("runtime:\n shellInitDelayMs: .inf\n");
1013
+ const origWrite = process.stderr.write;
1014
+ let capturedStderr = "";
1015
+ process.stderr.write = ((s: string | Uint8Array) => {
1016
+ if (typeof s === "string") capturedStderr += s;
1017
+ return true;
1018
+ }) as typeof process.stderr.write;
1019
+ try {
1020
+ const config = await loadConfig(tempDir);
1021
+ expect(config.runtime?.shellInitDelayMs).toBe(0);
1022
+ } finally {
1023
+ process.stderr.write = origWrite;
1024
+ }
1025
+ expect(capturedStderr).toContain("WARNING: runtime.shellInitDelayMs");
1026
+ });
1027
+
1028
+ test("warns when shellInitDelayMs exceeds 30s", async () => {
1029
+ await writeConfig("runtime:\n shellInitDelayMs: 60000\n");
1030
+ const origWrite = process.stderr.write;
1031
+ let capturedStderr = "";
1032
+ process.stderr.write = ((s: string | Uint8Array) => {
1033
+ if (typeof s === "string") capturedStderr += s;
1034
+ return true;
1035
+ }) as typeof process.stderr.write;
1036
+ try {
1037
+ const config = await loadConfig(tempDir);
1038
+ expect(config.runtime?.shellInitDelayMs).toBe(60000);
1039
+ } finally {
1040
+ process.stderr.write = origWrite;
1041
+ }
1042
+ expect(capturedStderr).toContain("WARNING: runtime.shellInitDelayMs is 60000ms");
1043
+ });
1044
+
1045
+ test("accepts valid shellInitDelayMs without warning", async () => {
1046
+ await writeConfig("runtime:\n shellInitDelayMs: 2000\n");
1047
+ const origWrite = process.stderr.write;
1048
+ let capturedStderr = "";
1049
+ process.stderr.write = ((s: string | Uint8Array) => {
1050
+ if (typeof s === "string") capturedStderr += s;
1051
+ return true;
1052
+ }) as typeof process.stderr.write;
1053
+ try {
1054
+ const config = await loadConfig(tempDir);
1055
+ expect(config.runtime?.shellInitDelayMs).toBe(2000);
1056
+ } finally {
1057
+ process.stderr.write = origWrite;
1058
+ }
1059
+ expect(capturedStderr).not.toContain("shellInitDelayMs");
1060
+ });
1061
+
1062
+ test("rejects qualityGate with empty description", async () => {
1063
+ await writeConfig(`
1064
+ project:
1065
+ canonicalBranch: main
1066
+ qualityGates:
1067
+ - name: Test
1068
+ command: pytest
1069
+ description: ""
1070
+ `);
1071
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
1072
+ });
1073
+ });
1074
+
1075
+ describe("resolveProjectRoot", () => {
1076
+ let repoDir: string;
1077
+
1078
+ afterEach(async () => {
1079
+ if (repoDir) {
1080
+ // Remove worktrees before cleaning up
1081
+ try {
1082
+ await runGitInDir(repoDir, ["worktree", "prune"]);
1083
+ } catch {
1084
+ // Best effort
1085
+ }
1086
+ await cleanupTempDir(repoDir);
1087
+ }
1088
+ });
1089
+
1090
+ test("returns startDir when .agentplate/config.yaml exists there", async () => {
1091
+ repoDir = await createTempGitRepo();
1092
+ await mkdir(join(repoDir, ".agentplate"), { recursive: true });
1093
+ await Bun.write(
1094
+ join(repoDir, ".agentplate", "config.yaml"),
1095
+ "project:\n canonicalBranch: main\n",
1096
+ );
1097
+
1098
+ const result = await resolveProjectRoot(repoDir);
1099
+ expect(result).toBe(repoDir);
1100
+ });
1101
+
1102
+ test("resolves worktree to main project root", async () => {
1103
+ repoDir = await createTempGitRepo();
1104
+ // Resolve symlinks (macOS /var -> /private/var) to match git's output
1105
+ repoDir = await realpath(repoDir);
1106
+ await mkdir(join(repoDir, ".agentplate"), { recursive: true });
1107
+ await Bun.write(
1108
+ join(repoDir, ".agentplate", "config.yaml"),
1109
+ "project:\n canonicalBranch: main\n",
1110
+ );
1111
+
1112
+ // Create a worktree like agentplate sling does
1113
+ const worktreeDir = join(repoDir, ".agentplate", "worktrees", "test-agent");
1114
+ await mkdir(join(repoDir, ".agentplate", "worktrees"), { recursive: true });
1115
+ await runGitInDir(repoDir, [
1116
+ "worktree",
1117
+ "add",
1118
+ "-b",
1119
+ "agentplate/test-agent/task-1",
1120
+ worktreeDir,
1121
+ ]);
1122
+
1123
+ // resolveProjectRoot from the worktree should return the main repo
1124
+ const result = await resolveProjectRoot(worktreeDir);
1125
+ expect(result).toBe(repoDir);
1126
+ });
1127
+
1128
+ test("resolves worktree to main root even when config.yaml is committed (regression)", async () => {
1129
+ repoDir = await createTempGitRepo();
1130
+ repoDir = await realpath(repoDir);
1131
+
1132
+ // Commit .agentplate/config.yaml so the worktree gets a copy via git
1133
+ // (this is what agentplate init does — the file is tracked)
1134
+ await mkdir(join(repoDir, ".agentplate"), { recursive: true });
1135
+ await Bun.write(
1136
+ join(repoDir, ".agentplate", "config.yaml"),
1137
+ "project:\n canonicalBranch: main\n",
1138
+ );
1139
+ await runGitInDir(repoDir, ["add", ".agentplate/config.yaml"]);
1140
+ await runGitInDir(repoDir, ["commit", "-m", "add agentplate config"]);
1141
+
1142
+ // Create a worktree — it will now have .agentplate/config.yaml from git
1143
+ const worktreeDir = join(repoDir, ".agentplate", "worktrees", "mail-scout");
1144
+ await mkdir(join(repoDir, ".agentplate", "worktrees"), { recursive: true });
1145
+ await runGitInDir(repoDir, [
1146
+ "worktree",
1147
+ "add",
1148
+ "-b",
1149
+ "agentplate/mail-scout/task-1",
1150
+ worktreeDir,
1151
+ ]);
1152
+
1153
+ // Must resolve to main repo root, NOT the worktree
1154
+ // (even though worktree has its own .agentplate/config.yaml)
1155
+ const result = await resolveProjectRoot(worktreeDir);
1156
+ expect(result).toBe(repoDir);
1157
+ });
1158
+
1159
+ test("loadConfig resolves correct root from worktree", async () => {
1160
+ repoDir = await createTempGitRepo();
1161
+ // Resolve symlinks (macOS /var -> /private/var) to match git's output
1162
+ repoDir = await realpath(repoDir);
1163
+ await mkdir(join(repoDir, ".agentplate"), { recursive: true });
1164
+ await Bun.write(
1165
+ join(repoDir, ".agentplate", "config.yaml"),
1166
+ "project:\n canonicalBranch: develop\n",
1167
+ );
1168
+
1169
+ const worktreeDir = join(repoDir, ".agentplate", "worktrees", "agent-2");
1170
+ await mkdir(join(repoDir, ".agentplate", "worktrees"), { recursive: true });
1171
+ await runGitInDir(repoDir, ["worktree", "add", "-b", "agentplate/agent-2/task-2", worktreeDir]);
1172
+
1173
+ // loadConfig from the worktree should resolve to the main project root
1174
+ const config = await loadConfig(worktreeDir);
1175
+ expect(config.project.root).toBe(repoDir);
1176
+ expect(config.project.canonicalBranch).toBe("develop");
1177
+ });
1178
+ });
1179
+
1180
+ describe("resolveProjectRoot — env var and walk-up", () => {
1181
+ let tempDir: string;
1182
+ let savedEnv: string | undefined;
1183
+
1184
+ beforeEach(async () => {
1185
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-envtest-"));
1186
+ savedEnv = process.env.AGENTPLATE_PROJECT_ROOT;
1187
+ delete process.env.AGENTPLATE_PROJECT_ROOT;
1188
+ clearProjectRootOverride();
1189
+ });
1190
+
1191
+ afterEach(async () => {
1192
+ if (savedEnv !== undefined) {
1193
+ process.env.AGENTPLATE_PROJECT_ROOT = savedEnv;
1194
+ } else {
1195
+ delete process.env.AGENTPLATE_PROJECT_ROOT;
1196
+ }
1197
+ clearProjectRootOverride();
1198
+ await cleanupTempDir(tempDir);
1199
+ });
1200
+
1201
+ test("AGENTPLATE_PROJECT_ROOT env var is returned immediately", async () => {
1202
+ await mkdir(join(tempDir, ".agentplate"), { recursive: true });
1203
+ await Bun.write(
1204
+ join(tempDir, ".agentplate", "config.yaml"),
1205
+ "project:\n canonicalBranch: main\n",
1206
+ );
1207
+ process.env.AGENTPLATE_PROJECT_ROOT = tempDir;
1208
+ const result = await resolveProjectRoot("/some/unrelated/path");
1209
+ expect(result).toBe(tempDir);
1210
+ });
1211
+
1212
+ test("env var beats walk-up worktree resolution", async () => {
1213
+ // Set up a parent root with config.yaml
1214
+ const parentRoot = tempDir;
1215
+ await mkdir(join(parentRoot, ".agentplate", "worktrees", "some-agent"), { recursive: true });
1216
+ await Bun.write(
1217
+ join(parentRoot, ".agentplate", "config.yaml"),
1218
+ "project:\n canonicalBranch: main\n",
1219
+ );
1220
+ const worktreePath = join(parentRoot, ".agentplate", "worktrees", "some-agent");
1221
+ // Even though walk-up would resolve parentRoot, env var pointing elsewhere wins
1222
+ const envTarget = await mkdtemp(join(tmpdir(), "agentplate-envtarget-"));
1223
+ try {
1224
+ process.env.AGENTPLATE_PROJECT_ROOT = envTarget;
1225
+ const result = await resolveProjectRoot(worktreePath);
1226
+ expect(result).toBe(envTarget);
1227
+ } finally {
1228
+ await cleanupTempDir(envTarget);
1229
+ }
1230
+ });
1231
+
1232
+ test("walk-up resolves submodule path without git", async () => {
1233
+ // Simulate a submodule worktree: {tempDir}/.agentplate/worktrees/my-agent/sub
1234
+ // config.yaml exists at {tempDir}/.agentplate/config.yaml
1235
+ const worktreeBase = join(tempDir, ".agentplate", "worktrees", "my-agent");
1236
+ const subDir = join(worktreeBase, "sub");
1237
+ await mkdir(subDir, { recursive: true });
1238
+ await mkdir(join(tempDir, ".agentplate"), { recursive: true });
1239
+ await Bun.write(
1240
+ join(tempDir, ".agentplate", "config.yaml"),
1241
+ "project:\n canonicalBranch: main\n",
1242
+ );
1243
+ const result = await resolveProjectRoot(subDir);
1244
+ expect(result).toBe(tempDir);
1245
+ });
1246
+
1247
+ test("walk-up is skipped when parent has no config.yaml", async () => {
1248
+ // Same path structure but NO config.yaml at parentRoot
1249
+ const worktreeBase = join(tempDir, ".agentplate", "worktrees", "my-agent");
1250
+ await mkdir(worktreeBase, { recursive: true });
1251
+ // No config.yaml written — walk-up guard should prevent false resolution
1252
+ const result = await resolveProjectRoot(worktreeBase);
1253
+ // Falls through to startDir fallback
1254
+ expect(result).toBe(worktreeBase);
1255
+ });
1256
+ });
1257
+
1258
+ describe("projectRootOverride", () => {
1259
+ let tempDir: string;
1260
+
1261
+ beforeEach(async () => {
1262
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-test-"));
1263
+ clearProjectRootOverride();
1264
+ });
1265
+
1266
+ afterEach(async () => {
1267
+ clearProjectRootOverride();
1268
+ await cleanupTempDir(tempDir);
1269
+ });
1270
+
1271
+ test("setProjectRootOverride makes resolveProjectRoot return the override", async () => {
1272
+ setProjectRootOverride(tempDir);
1273
+ const result = await resolveProjectRoot("/some/other/dir");
1274
+ expect(result).toBe(tempDir);
1275
+ });
1276
+
1277
+ test("clearProjectRootOverride restores normal resolution", async () => {
1278
+ setProjectRootOverride("/completely/fake/path");
1279
+ clearProjectRootOverride();
1280
+ // After clearing, normal resolution returns startDir when no .agentplate present
1281
+ const result = await resolveProjectRoot(tempDir);
1282
+ expect(result).toBe(tempDir);
1283
+ });
1284
+
1285
+ test("loadConfig respects project root override", async () => {
1286
+ await mkdir(join(tempDir, ".agentplate"), { recursive: true });
1287
+ await Bun.write(
1288
+ join(tempDir, ".agentplate", "config.yaml"),
1289
+ "project:\n canonicalBranch: override-branch\n",
1290
+ );
1291
+ setProjectRootOverride(tempDir);
1292
+ const config = await loadConfig("/completely/different/path");
1293
+ expect(config.project.root).toBe(tempDir);
1294
+ expect(config.project.canonicalBranch).toBe("override-branch");
1295
+ });
1296
+
1297
+ test("override takes precedence over worktree resolution", async () => {
1298
+ // Even if we're in a worktree, the override wins
1299
+ const otherDir = await mkdtemp(join(tmpdir(), "agentplate-other-"));
1300
+ try {
1301
+ await mkdir(join(otherDir, ".agentplate"), { recursive: true });
1302
+ await Bun.write(
1303
+ join(otherDir, ".agentplate", "config.yaml"),
1304
+ "project:\n canonicalBranch: other-branch\n",
1305
+ );
1306
+ setProjectRootOverride(otherDir);
1307
+ const result = await resolveProjectRoot(tempDir);
1308
+ expect(result).toBe(otherDir);
1309
+ } finally {
1310
+ await cleanupTempDir(otherDir);
1311
+ }
1312
+ });
1313
+ });
1314
+
1315
+ describe("coordinator.exitTriggers", () => {
1316
+ let tempDir: string;
1317
+
1318
+ beforeEach(async () => {
1319
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-test-"));
1320
+ const { mkdir } = await import("node:fs/promises");
1321
+ await mkdir(join(tempDir, ".agentplate"), { recursive: true });
1322
+ });
1323
+
1324
+ afterEach(async () => {
1325
+ await cleanupTempDir(tempDir);
1326
+ });
1327
+
1328
+ async function writeConfig(yaml: string): Promise<void> {
1329
+ await Bun.write(join(tempDir, ".agentplate", "config.yaml"), yaml);
1330
+ }
1331
+
1332
+ test("defaults all exitTriggers to false", async () => {
1333
+ const config = await loadConfig(tempDir);
1334
+ expect(config.coordinator?.exitTriggers.allAgentsDone).toBe(false);
1335
+ expect(config.coordinator?.exitTriggers.taskTrackerEmpty).toBe(false);
1336
+ expect(config.coordinator?.exitTriggers.onShutdownSignal).toBe(false);
1337
+ });
1338
+
1339
+ test("parses coordinator.exitTriggers from config.yaml", async () => {
1340
+ await writeConfig(`
1341
+ coordinator:
1342
+ exitTriggers:
1343
+ allAgentsDone: true
1344
+ taskTrackerEmpty: true
1345
+ onShutdownSignal: false
1346
+ `);
1347
+ const config = await loadConfig(tempDir);
1348
+ expect(config.coordinator?.exitTriggers.allAgentsDone).toBe(true);
1349
+ expect(config.coordinator?.exitTriggers.taskTrackerEmpty).toBe(true);
1350
+ expect(config.coordinator?.exitTriggers.onShutdownSignal).toBe(false);
1351
+ });
1352
+
1353
+ test("partial exitTriggers override keeps unset values at default (false)", async () => {
1354
+ await writeConfig(`
1355
+ coordinator:
1356
+ exitTriggers:
1357
+ onShutdownSignal: true
1358
+ `);
1359
+ const config = await loadConfig(tempDir);
1360
+ expect(config.coordinator?.exitTriggers.allAgentsDone).toBe(false);
1361
+ expect(config.coordinator?.exitTriggers.taskTrackerEmpty).toBe(false);
1362
+ expect(config.coordinator?.exitTriggers.onShutdownSignal).toBe(true);
1363
+ });
1364
+
1365
+ test("config.local.yaml can override exitTriggers", async () => {
1366
+ await writeConfig(`
1367
+ coordinator:
1368
+ exitTriggers:
1369
+ allAgentsDone: false
1370
+ `);
1371
+ await Bun.write(
1372
+ join(tempDir, ".agentplate", "config.local.yaml"),
1373
+ `coordinator:\n exitTriggers:\n allAgentsDone: true\n`,
1374
+ );
1375
+ const config = await loadConfig(tempDir);
1376
+ expect(config.coordinator?.exitTriggers.allAgentsDone).toBe(true);
1377
+ });
1378
+ });
1379
+
1380
+ describe("YAML parser edge cases", () => {
1381
+ let tempDir: string;
1382
+
1383
+ beforeEach(async () => {
1384
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-test-"));
1385
+ await mkdir(join(tempDir, ".agentplate"), { recursive: true });
1386
+ });
1387
+
1388
+ afterEach(async () => {
1389
+ await cleanupTempDir(tempDir);
1390
+ });
1391
+
1392
+ async function writeConfig(yaml: string): Promise<void> {
1393
+ await Bun.write(join(tempDir, ".agentplate", "config.yaml"), yaml);
1394
+ }
1395
+
1396
+ test("inline comments are stripped from values", async () => {
1397
+ await writeConfig(`
1398
+ project:
1399
+ canonicalBranch: develop # this is a comment
1400
+ `);
1401
+ const config = await loadConfig(tempDir);
1402
+ expect(config.project.canonicalBranch).toBe("develop");
1403
+ });
1404
+
1405
+ test("quoted strings containing # are preserved (not treated as comments)", async () => {
1406
+ await writeConfig(`
1407
+ project:
1408
+ canonicalBranch: "feature#branch"
1409
+ `);
1410
+ const config = await loadConfig(tempDir);
1411
+ expect(config.project.canonicalBranch).toBe("feature#branch");
1412
+ });
1413
+
1414
+ test("single-quoted strings containing # are preserved", async () => {
1415
+ await writeConfig(`
1416
+ project:
1417
+ canonicalBranch: 'feature#branch'
1418
+ `);
1419
+ const config = await loadConfig(tempDir);
1420
+ expect(config.project.canonicalBranch).toBe("feature#branch");
1421
+ });
1422
+
1423
+ test("boolean coercion: true/True/TRUE all parse as true", async () => {
1424
+ // Test with three separate configs since they all map to the same field
1425
+ for (const val of ["true", "True", "TRUE"]) {
1426
+ await writeConfig(`loam:\n enabled: ${val}\n`);
1427
+ const config = await loadConfig(tempDir);
1428
+ expect(config.loam.enabled).toBe(true);
1429
+ }
1430
+ });
1431
+
1432
+ test("boolean coercion: false/False/FALSE all parse as false", async () => {
1433
+ for (const val of ["false", "False", "FALSE"]) {
1434
+ await writeConfig(`loam:\n enabled: ${val}\n`);
1435
+ const config = await loadConfig(tempDir);
1436
+ expect(config.loam.enabled).toBe(false);
1437
+ }
1438
+ });
1439
+
1440
+ test("yes/no are treated as plain strings, not booleans", async () => {
1441
+ // The YAML parser does NOT treat yes/no as booleans (unlike YAML 1.1)
1442
+ await writeConfig(`
1443
+ project:
1444
+ canonicalBranch: yes
1445
+ `);
1446
+ const config = await loadConfig(tempDir);
1447
+ // "yes" is a plain string, not coerced to boolean
1448
+ expect(config.project.canonicalBranch).toBe("yes");
1449
+ });
1450
+
1451
+ test("integer number coercion", async () => {
1452
+ await writeConfig(`
1453
+ agents:
1454
+ maxConcurrent: 42
1455
+ `);
1456
+ const config = await loadConfig(tempDir);
1457
+ expect(config.agents.maxConcurrent).toBe(42);
1458
+ });
1459
+
1460
+ test("float number coercion", async () => {
1461
+ // maxSessionsPerRun doesn't accept floats, but the parser itself parses them.
1462
+ // Use a field that passes validation as a number.
1463
+ await writeConfig(`
1464
+ agents:
1465
+ maxSessionsPerRun: 5
1466
+ staggerDelayMs: 1500
1467
+ `);
1468
+ const config = await loadConfig(tempDir);
1469
+ expect(config.agents.staggerDelayMs).toBe(1500);
1470
+ });
1471
+
1472
+ test("underscore-separated numbers are coerced correctly", async () => {
1473
+ await writeConfig(`
1474
+ watchdog:
1475
+ staleThresholdMs: 300_000
1476
+ zombieThresholdMs: 600_000
1477
+ `);
1478
+ const config = await loadConfig(tempDir);
1479
+ expect(config.watchdog.staleThresholdMs).toBe(300_000);
1480
+ expect(config.watchdog.zombieThresholdMs).toBe(600_000);
1481
+ });
1482
+ });
1483
+
1484
+ describe("DEFAULT_CONFIG", () => {
1485
+ test("has all required top-level keys", () => {
1486
+ expect(DEFAULT_CONFIG.project).toBeDefined();
1487
+ expect(DEFAULT_CONFIG.agents).toBeDefined();
1488
+ expect(DEFAULT_CONFIG.worktrees).toBeDefined();
1489
+ expect(DEFAULT_CONFIG.taskTracker).toBeDefined();
1490
+ expect(DEFAULT_CONFIG.loam).toBeDefined();
1491
+ expect(DEFAULT_CONFIG.merge).toBeDefined();
1492
+ expect(DEFAULT_CONFIG.providers).toBeDefined();
1493
+ expect(DEFAULT_CONFIG.watchdog).toBeDefined();
1494
+ expect(DEFAULT_CONFIG.models).toBeDefined();
1495
+ expect(DEFAULT_CONFIG.logging).toBeDefined();
1496
+ });
1497
+
1498
+ test("has default providers with anthropic native", () => {
1499
+ expect(DEFAULT_CONFIG.providers).toBeDefined();
1500
+ expect(DEFAULT_CONFIG.providers.anthropic).toEqual({ type: "native" });
1501
+ });
1502
+
1503
+ test("has sensible default values", () => {
1504
+ expect(DEFAULT_CONFIG.project.canonicalBranch).toBe("main");
1505
+ expect(DEFAULT_CONFIG.agents.maxConcurrent).toBe(25);
1506
+ expect(DEFAULT_CONFIG.agents.maxDepth).toBe(2);
1507
+ expect(DEFAULT_CONFIG.agents.staggerDelayMs).toBe(2_000);
1508
+ expect(DEFAULT_CONFIG.agents.maxSessionsPerRun).toBe(0);
1509
+ expect(DEFAULT_CONFIG.watchdog.tier0IntervalMs).toBe(30_000);
1510
+ expect(DEFAULT_CONFIG.watchdog.staleThresholdMs).toBe(300_000);
1511
+ expect(DEFAULT_CONFIG.watchdog.zombieThresholdMs).toBe(600_000);
1512
+ });
1513
+
1514
+ test("includes default qualityGates", () => {
1515
+ expect(DEFAULT_CONFIG.project.qualityGates).toBeDefined();
1516
+ expect(DEFAULT_CONFIG.project.qualityGates?.length).toBe(3);
1517
+ expect(DEFAULT_CONFIG.project.qualityGates?.[0]?.command).toBe("bun test");
1518
+ expect(DEFAULT_CONFIG.project.qualityGates?.[1]?.command).toBe("bun run lint");
1519
+ expect(DEFAULT_CONFIG.project.qualityGates?.[2]?.command).toBe("bun run typecheck");
1520
+ });
1521
+
1522
+ test("has coordinator with exitTriggers defaulting to false", () => {
1523
+ expect(DEFAULT_CONFIG.coordinator).toBeDefined();
1524
+ expect(DEFAULT_CONFIG.coordinator?.exitTriggers.allAgentsDone).toBe(false);
1525
+ expect(DEFAULT_CONFIG.coordinator?.exitTriggers.taskTrackerEmpty).toBe(false);
1526
+ expect(DEFAULT_CONFIG.coordinator?.exitTriggers.onShutdownSignal).toBe(false);
1527
+ });
1528
+
1529
+ test("DEFAULT_QUALITY_GATES matches the project default gates", () => {
1530
+ expect(DEFAULT_QUALITY_GATES).toHaveLength(3);
1531
+ expect(DEFAULT_QUALITY_GATES[0]?.name).toBe("Tests");
1532
+ expect(DEFAULT_QUALITY_GATES[1]?.name).toBe("Lint");
1533
+ expect(DEFAULT_QUALITY_GATES[2]?.name).toBe("Typecheck");
1534
+ });
1535
+ });