@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,3119 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { AgentError } from "../errors.ts";
6
+ import { createMailClient } from "../mail/client.ts";
7
+ import { createMailStore } from "../mail/store.ts";
8
+ import {
9
+ cleanupTempDir,
10
+ commitFile,
11
+ createTempGitRepo,
12
+ getDefaultBranch,
13
+ runGitInDir,
14
+ } from "../test-helpers.ts";
15
+ import {
16
+ buildBashFileGuardScript,
17
+ buildBashPathBoundaryScript,
18
+ buildLeadCloseGateScript,
19
+ buildPathBoundaryGuardScript,
20
+ buildTrackerCloseGuardScript,
21
+ deployHooks,
22
+ escapeForSingleQuotedShell,
23
+ extractQualityGatePrefixes,
24
+ getBashPathBoundaryGuards,
25
+ getCapabilityGuards,
26
+ getDangerGuards,
27
+ getLeadCloseGateGuards,
28
+ getPathBoundaryGuards,
29
+ getTrackerCloseGuards,
30
+ isAgentplateHookEntry,
31
+ PATH_PREFIX,
32
+ } from "./hooks-deployer.ts";
33
+
34
+ describe("deployHooks", () => {
35
+ let tempDir: string;
36
+
37
+ beforeEach(async () => {
38
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-hooks-test-"));
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await cleanupTempDir(tempDir);
43
+ });
44
+
45
+ test("creates .claude/settings.local.json in worktree directory", async () => {
46
+ const worktreePath = join(tempDir, "worktree");
47
+
48
+ await deployHooks(worktreePath, "test-agent");
49
+
50
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
51
+ const exists = await Bun.file(outputPath).exists();
52
+ expect(exists).toBe(true);
53
+ });
54
+
55
+ test("replaces {{AGENT_NAME}} with the actual agent name", async () => {
56
+ const worktreePath = join(tempDir, "worktree");
57
+
58
+ await deployHooks(worktreePath, "my-builder");
59
+
60
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
61
+ const content = await Bun.file(outputPath).text();
62
+ expect(content).toContain("my-builder");
63
+ expect(content).not.toContain("{{AGENT_NAME}}");
64
+ });
65
+
66
+ test("replaces all occurrences of {{AGENT_NAME}}", async () => {
67
+ const worktreePath = join(tempDir, "worktree");
68
+
69
+ await deployHooks(worktreePath, "scout-alpha");
70
+
71
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
72
+ const content = await Bun.file(outputPath).text();
73
+
74
+ // The template has {{AGENT_NAME}} in multiple hook commands
75
+ const occurrences = content.split("scout-alpha").length - 1;
76
+ expect(occurrences).toBeGreaterThanOrEqual(7);
77
+ expect(content).not.toContain("{{AGENT_NAME}}");
78
+ });
79
+
80
+ test("output is valid JSON", async () => {
81
+ const worktreePath = join(tempDir, "worktree");
82
+
83
+ await deployHooks(worktreePath, "json-test-agent");
84
+
85
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
86
+ const content = await Bun.file(outputPath).text();
87
+ const parsed = JSON.parse(content);
88
+ expect(parsed).toBeDefined();
89
+ expect(parsed.hooks).toBeDefined();
90
+ });
91
+
92
+ test("output contains SessionStart hook", async () => {
93
+ const worktreePath = join(tempDir, "worktree");
94
+
95
+ await deployHooks(worktreePath, "hook-check");
96
+
97
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
98
+ const content = await Bun.file(outputPath).text();
99
+ const parsed = JSON.parse(content);
100
+ expect(parsed.hooks.SessionStart).toBeDefined();
101
+ expect(parsed.hooks.SessionStart).toBeArray();
102
+ expect(parsed.hooks.SessionStart.length).toBeGreaterThan(0);
103
+ });
104
+
105
+ test("output contains UserPromptSubmit hook", async () => {
106
+ const worktreePath = join(tempDir, "worktree");
107
+
108
+ await deployHooks(worktreePath, "hook-check");
109
+
110
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
111
+ const content = await Bun.file(outputPath).text();
112
+ const parsed = JSON.parse(content);
113
+ expect(parsed.hooks.UserPromptSubmit).toBeDefined();
114
+ expect(parsed.hooks.UserPromptSubmit).toBeArray();
115
+ });
116
+
117
+ test("output contains PreToolUse hook", async () => {
118
+ const worktreePath = join(tempDir, "worktree");
119
+
120
+ await deployHooks(worktreePath, "hook-check");
121
+
122
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
123
+ const content = await Bun.file(outputPath).text();
124
+ const parsed = JSON.parse(content);
125
+ expect(parsed.hooks.PreToolUse).toBeDefined();
126
+ expect(parsed.hooks.PreToolUse).toBeArray();
127
+ });
128
+
129
+ test("output contains PostToolUse hook", async () => {
130
+ const worktreePath = join(tempDir, "worktree");
131
+
132
+ await deployHooks(worktreePath, "hook-check");
133
+
134
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
135
+ const content = await Bun.file(outputPath).text();
136
+ const parsed = JSON.parse(content);
137
+ expect(parsed.hooks.PostToolUse).toBeDefined();
138
+ expect(parsed.hooks.PostToolUse).toBeArray();
139
+ });
140
+
141
+ test("output contains Stop hook", async () => {
142
+ const worktreePath = join(tempDir, "worktree");
143
+
144
+ await deployHooks(worktreePath, "hook-check");
145
+
146
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
147
+ const content = await Bun.file(outputPath).text();
148
+ const parsed = JSON.parse(content);
149
+ expect(parsed.hooks.Stop).toBeDefined();
150
+ expect(parsed.hooks.Stop).toBeArray();
151
+ });
152
+
153
+ test("PostToolUse hook includes debounced mail check entry", async () => {
154
+ const worktreePath = join(tempDir, "worktree");
155
+
156
+ await deployHooks(worktreePath, "mail-check-agent");
157
+
158
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
159
+ const content = await Bun.file(outputPath).text();
160
+ const parsed = JSON.parse(content);
161
+ const postToolUse = parsed.hooks.PostToolUse;
162
+ // PostToolUse should have 3 entries: logger, mail check, and loam diff Bash hook
163
+ expect(postToolUse).toHaveLength(3);
164
+ // First entry is the logging hook
165
+ expect(postToolUse[0].hooks[0].command).toContain("ap log tool-end");
166
+ // Second entry is the debounced mail check
167
+ expect(postToolUse[1].hooks[0].command).toContain("ap mail check --inject");
168
+ expect(postToolUse[1].hooks[0].command).toContain("mail-check-agent");
169
+ expect(postToolUse[1].hooks[0].command).toContain("--debounce 30000");
170
+ expect(postToolUse[1].hooks[0].command).toContain("AGENTPLATE_AGENT_NAME");
171
+ });
172
+
173
+ test("PostToolUse hook includes loam diff Bash hook", async () => {
174
+ const worktreePath = join(tempDir, "loam-diff-wt");
175
+
176
+ await deployHooks(worktreePath, "loam-diff-agent");
177
+
178
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
179
+ const content = await Bun.file(outputPath).text();
180
+ const parsed = JSON.parse(content);
181
+ const postToolUse = parsed.hooks.PostToolUse;
182
+ // Third entry is the loam diff Bash hook
183
+ const loamDiffHook = postToolUse.find((h: { matcher: string }) => h.matcher === "Bash");
184
+ expect(loamDiffHook).toBeDefined();
185
+ expect(loamDiffHook.hooks[0].command).toContain("lm diff HEAD~1");
186
+ });
187
+
188
+ test("output contains PreCompact hook", async () => {
189
+ const worktreePath = join(tempDir, "worktree");
190
+
191
+ await deployHooks(worktreePath, "hook-check");
192
+
193
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
194
+ const content = await Bun.file(outputPath).text();
195
+ const parsed = JSON.parse(content);
196
+ expect(parsed.hooks.PreCompact).toBeDefined();
197
+ expect(parsed.hooks.PreCompact).toBeArray();
198
+ });
199
+
200
+ test("all six hook types are present", async () => {
201
+ const worktreePath = join(tempDir, "worktree");
202
+
203
+ await deployHooks(worktreePath, "all-hooks");
204
+
205
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
206
+ const content = await Bun.file(outputPath).text();
207
+ const parsed = JSON.parse(content);
208
+ const hookTypes = Object.keys(parsed.hooks);
209
+ expect(hookTypes).toContain("SessionStart");
210
+ expect(hookTypes).toContain("UserPromptSubmit");
211
+ expect(hookTypes).toContain("PreToolUse");
212
+ expect(hookTypes).toContain("PostToolUse");
213
+ expect(hookTypes).toContain("Stop");
214
+ expect(hookTypes).toContain("PreCompact");
215
+ expect(hookTypes).toHaveLength(6);
216
+ });
217
+
218
+ test("SessionStart hook runs agentplate prime with agent name", async () => {
219
+ const worktreePath = join(tempDir, "worktree");
220
+
221
+ await deployHooks(worktreePath, "prime-agent");
222
+
223
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
224
+ const content = await Bun.file(outputPath).text();
225
+ const parsed = JSON.parse(content);
226
+ const sessionStart = parsed.hooks.SessionStart[0];
227
+ expect(sessionStart.hooks[0].type).toBe("command");
228
+ expect(sessionStart.hooks[0].command).toContain("ap prime --agent prime-agent");
229
+ expect(sessionStart.hooks[0].command).toContain("AGENTPLATE_AGENT_NAME");
230
+ });
231
+
232
+ test("UserPromptSubmit hook runs mail check with agent name", async () => {
233
+ const worktreePath = join(tempDir, "worktree");
234
+
235
+ await deployHooks(worktreePath, "mail-agent");
236
+
237
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
238
+ const content = await Bun.file(outputPath).text();
239
+ const parsed = JSON.parse(content);
240
+ const userPrompt = parsed.hooks.UserPromptSubmit[0];
241
+ expect(userPrompt.hooks[0].command).toContain("ap mail check --inject --agent mail-agent");
242
+ expect(userPrompt.hooks[0].command).toContain("AGENTPLATE_AGENT_NAME");
243
+ });
244
+
245
+ test("PreCompact hook runs agentplate prime with --compact flag", async () => {
246
+ const worktreePath = join(tempDir, "worktree");
247
+
248
+ await deployHooks(worktreePath, "compact-agent");
249
+
250
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
251
+ const content = await Bun.file(outputPath).text();
252
+ const parsed = JSON.parse(content);
253
+ const preCompact = parsed.hooks.PreCompact[0];
254
+ expect(preCompact.hooks[0].type).toBe("command");
255
+ expect(preCompact.hooks[0].command).toContain("ap prime --agent compact-agent --compact");
256
+ expect(preCompact.hooks[0].command).toContain("AGENTPLATE_AGENT_NAME");
257
+ });
258
+
259
+ test("PreToolUse hook pipes stdin to agentplate log with --stdin flag", async () => {
260
+ const worktreePath = join(tempDir, "worktree");
261
+
262
+ await deployHooks(worktreePath, "stdin-agent");
263
+
264
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
265
+ const content = await Bun.file(outputPath).text();
266
+ const parsed = JSON.parse(content);
267
+
268
+ // Find the base PreToolUse hook (matcher == "")
269
+ const preToolUse = parsed.hooks.PreToolUse;
270
+ const baseHook = preToolUse.find((h: { matcher: string }) => h.matcher === "");
271
+ expect(baseHook).toBeDefined();
272
+ expect(baseHook.hooks[0].command).toContain("--stdin");
273
+ expect(baseHook.hooks[0].command).toContain("ap log tool-start");
274
+ expect(baseHook.hooks[0].command).toContain("stdin-agent");
275
+ expect(baseHook.hooks[0].command).not.toContain("read -r INPUT");
276
+ });
277
+
278
+ test("PostToolUse hook pipes stdin to agentplate log with --stdin flag", async () => {
279
+ const worktreePath = join(tempDir, "worktree");
280
+
281
+ await deployHooks(worktreePath, "stdin-agent");
282
+
283
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
284
+ const content = await Bun.file(outputPath).text();
285
+ const parsed = JSON.parse(content);
286
+ const postToolUse = parsed.hooks.PostToolUse[0];
287
+ expect(postToolUse.hooks[0].command).toContain("--stdin");
288
+ expect(postToolUse.hooks[0].command).toContain("ap log tool-end");
289
+ expect(postToolUse.hooks[0].command).toContain("stdin-agent");
290
+ expect(postToolUse.hooks[0].command).not.toContain("read -r INPUT");
291
+ });
292
+
293
+ test("PostToolUse hook includes mail check with debounce", async () => {
294
+ const worktreePath = join(tempDir, "mail-debounce-wt");
295
+
296
+ await deployHooks(worktreePath, "mail-debounce-agent");
297
+
298
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
299
+ const content = await Bun.file(outputPath).text();
300
+ const parsed = JSON.parse(content);
301
+ const postToolUse = parsed.hooks.PostToolUse[0];
302
+
303
+ // Should have 2 hooks: tool-end logging + mail check
304
+ expect(postToolUse.hooks).toHaveLength(2);
305
+
306
+ // Second hook should be mail check with debounce
307
+ expect(postToolUse.hooks[1].command).toContain("ap mail check");
308
+ expect(postToolUse.hooks[1].command).toContain("--inject");
309
+ expect(postToolUse.hooks[1].command).toContain("--agent mail-debounce-agent");
310
+ expect(postToolUse.hooks[1].command).toContain("--debounce 500");
311
+ expect(postToolUse.hooks[1].command).toContain("AGENTPLATE_AGENT_NAME");
312
+ });
313
+
314
+ test("Stop hook pipes stdin to agentplate log with --stdin flag", async () => {
315
+ const worktreePath = join(tempDir, "worktree");
316
+
317
+ await deployHooks(worktreePath, "stdin-agent");
318
+
319
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
320
+ const content = await Bun.file(outputPath).text();
321
+ const parsed = JSON.parse(content);
322
+ const stop = parsed.hooks.Stop[0];
323
+ expect(stop.hooks[0].command).toContain("--stdin");
324
+ expect(stop.hooks[0].command).toContain("ap log session-end");
325
+ expect(stop.hooks[0].command).toContain("stdin-agent");
326
+ expect(stop.hooks[0].command).not.toContain("read -r INPUT");
327
+ });
328
+
329
+ test("Stop hook includes loam learn command", async () => {
330
+ const worktreePath = join(tempDir, "worktree");
331
+
332
+ await deployHooks(worktreePath, "learn-agent");
333
+
334
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
335
+ const content = await Bun.file(outputPath).text();
336
+ const parsed = JSON.parse(content);
337
+ const stop = parsed.hooks.Stop[0];
338
+ expect(stop.hooks.length).toBe(2);
339
+ expect(stop.hooks[1].command).toContain("lm learn");
340
+ expect(stop.hooks[1].command).toContain("AGENTPLATE_AGENT_NAME");
341
+ });
342
+
343
+ test("hook commands no longer use sed-based extraction", async () => {
344
+ const worktreePath = join(tempDir, "worktree");
345
+
346
+ await deployHooks(worktreePath, "no-sed-agent");
347
+
348
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
349
+ const content = await Bun.file(outputPath).text();
350
+ const parsed = JSON.parse(content);
351
+
352
+ // PreToolUse base hook should not contain sed or --tool-name
353
+ const preToolUse = parsed.hooks.PreToolUse;
354
+ const basePreHook = preToolUse.find((h: { matcher: string }) => h.matcher === "");
355
+ expect(basePreHook.hooks[0].command).not.toContain("--tool-name");
356
+ expect(basePreHook.hooks[0].command).not.toContain("TOOL_NAME=$(");
357
+
358
+ // PostToolUse should not contain sed or --tool-name
359
+ const postToolUse = parsed.hooks.PostToolUse[0];
360
+ expect(postToolUse.hooks[0].command).not.toContain("--tool-name");
361
+ expect(postToolUse.hooks[0].command).not.toContain("TOOL_NAME=$(");
362
+ });
363
+
364
+ test("creates .claude directory even if worktree already exists", async () => {
365
+ const worktreePath = join(tempDir, "existing-worktree");
366
+ const { mkdir } = await import("node:fs/promises");
367
+ await mkdir(worktreePath, { recursive: true });
368
+
369
+ await deployHooks(worktreePath, "test-agent");
370
+
371
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
372
+ const exists = await Bun.file(outputPath).exists();
373
+ expect(exists).toBe(true);
374
+ });
375
+
376
+ test("preserves non-hooks keys from existing settings.local.json", async () => {
377
+ const worktreePath = join(tempDir, "worktree");
378
+ const claudeDir = join(worktreePath, ".claude");
379
+ const { mkdir } = await import("node:fs/promises");
380
+ await mkdir(claudeDir, { recursive: true });
381
+ await Bun.write(
382
+ join(claudeDir, "settings.local.json"),
383
+ JSON.stringify({
384
+ permissions: { allow: ["Read"] },
385
+ env: { FOO: "bar" },
386
+ $schema: "https://example.com/schema.json",
387
+ }),
388
+ );
389
+
390
+ await deployHooks(worktreePath, "new-agent");
391
+
392
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
393
+ const parsed = JSON.parse(content);
394
+ expect(content).toContain("new-agent");
395
+ expect(parsed.hooks).toBeDefined();
396
+ expect(parsed.permissions).toEqual({ allow: ["Read"] });
397
+ expect(parsed.env).toEqual({ FOO: "bar" });
398
+ expect(parsed.$schema).toBe("https://example.com/schema.json");
399
+ });
400
+
401
+ test("handles agent names with special characters", async () => {
402
+ const worktreePath = join(tempDir, "worktree");
403
+
404
+ await deployHooks(worktreePath, "agent-with-dashes-123");
405
+
406
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
407
+ const content = await Bun.file(outputPath).text();
408
+ expect(content).toContain("agent-with-dashes-123");
409
+ // Should still be valid JSON
410
+ const parsed = JSON.parse(content);
411
+ expect(parsed.hooks).toBeDefined();
412
+ });
413
+
414
+ test("throws AgentError when template is missing", async () => {
415
+ // We can't easily remove the template without affecting the repo,
416
+ // but we can verify the error type by testing the module's behavior.
417
+ // The function uses getTemplatePath() internally which is not exported,
418
+ // so we test indirectly: verify that a successful call works, confirming
419
+ // the template exists. The error path is tested via the error type assertion.
420
+ const worktreePath = join(tempDir, "worktree");
421
+
422
+ // Successful deployment proves the template exists
423
+ await deployHooks(worktreePath, "template-exists");
424
+ const exists = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).exists();
425
+ expect(exists).toBe(true);
426
+ });
427
+
428
+ test("AgentError includes agent name in context", async () => {
429
+ // Verify AgentError shape by constructing one (as the function does internally)
430
+ const error = new AgentError("test error", { agentName: "failing-agent" });
431
+ expect(error.agentName).toBe("failing-agent");
432
+ expect(error.code).toBe("AGENT_ERROR");
433
+ expect(error.name).toBe("AgentError");
434
+ expect(error.message).toBe("test error");
435
+ });
436
+
437
+ test("write failure throws AgentError", async () => {
438
+ // Use a path that will fail to write (read-only parent)
439
+ const invalidPath = "/dev/null/impossible-path";
440
+
441
+ try {
442
+ await deployHooks(invalidPath, "fail-agent");
443
+ // Should not reach here
444
+ expect(true).toBe(false);
445
+ } catch (err) {
446
+ expect(err).toBeInstanceOf(AgentError);
447
+ if (err instanceof AgentError) {
448
+ expect(err.agentName).toBe("fail-agent");
449
+ expect(err.code).toBe("AGENT_ERROR");
450
+ }
451
+ }
452
+ });
453
+
454
+ test("scout capability adds path boundary + block guards for Write/Edit/NotebookEdit and Bash file guards", async () => {
455
+ const worktreePath = join(tempDir, "scout-wt");
456
+
457
+ await deployHooks(worktreePath, "scout-agent", "scout");
458
+
459
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
460
+ const content = await Bun.file(outputPath).text();
461
+ const parsed = JSON.parse(content);
462
+ const preToolUse = parsed.hooks.PreToolUse;
463
+
464
+ // Scout gets both path boundary guards AND block guards for Write/Edit/NotebookEdit
465
+ const writeGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Write");
466
+ const editGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Edit");
467
+ const notebookGuards = preToolUse.filter(
468
+ (h: { matcher: string }) => h.matcher === "NotebookEdit",
469
+ );
470
+
471
+ // 2 each: path boundary + capability block
472
+ expect(writeGuards.length).toBe(2);
473
+ expect(editGuards.length).toBe(2);
474
+ expect(notebookGuards.length).toBe(2);
475
+
476
+ // Find the capability block guard (contains "cannot modify files")
477
+ const writeBlockGuard = writeGuards.find((h: { hooks: Array<{ command: string }> }) =>
478
+ h.hooks[0]?.command?.includes("cannot modify files"),
479
+ );
480
+ expect(writeBlockGuard).toBeDefined();
481
+ expect(writeBlockGuard.hooks[0].command).toContain('"decision":"block"');
482
+
483
+ // Should have multiple Bash guards: danger guard + file guard + tracker close guard + universal push guard
484
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
485
+ expect(bashGuards.length).toBe(4); // danger guard + file guard + tracker close guard + universal push guard
486
+ });
487
+
488
+ test("reviewer capability adds same guards as scout", async () => {
489
+ const worktreePath = join(tempDir, "reviewer-wt");
490
+
491
+ await deployHooks(worktreePath, "reviewer-agent", "reviewer");
492
+
493
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
494
+ const content = await Bun.file(outputPath).text();
495
+ const parsed = JSON.parse(content);
496
+ const preToolUse = parsed.hooks.PreToolUse;
497
+
498
+ const guardMatchers = preToolUse
499
+ .filter((h: { matcher: string }) => h.matcher !== "")
500
+ .map((h: { matcher: string }) => h.matcher);
501
+
502
+ expect(guardMatchers).toContain("Bash");
503
+ expect(guardMatchers).toContain("Write");
504
+ expect(guardMatchers).toContain("Edit");
505
+ expect(guardMatchers).toContain("NotebookEdit");
506
+ });
507
+
508
+ test("lead capability gets Write/Edit/NotebookEdit guards and Bash file guards", async () => {
509
+ const worktreePath = join(tempDir, "lead-wt");
510
+
511
+ await deployHooks(worktreePath, "lead-agent", "lead");
512
+
513
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
514
+ const content = await Bun.file(outputPath).text();
515
+ const parsed = JSON.parse(content);
516
+ const preToolUse = parsed.hooks.PreToolUse;
517
+
518
+ const guardMatchers = preToolUse
519
+ .filter((h: { matcher: string }) => h.matcher !== "")
520
+ .map((h: { matcher: string }) => h.matcher);
521
+
522
+ expect(guardMatchers).toContain("Write");
523
+ expect(guardMatchers).toContain("Edit");
524
+ expect(guardMatchers).toContain("NotebookEdit");
525
+ expect(guardMatchers).toContain("Bash");
526
+
527
+ // Should have 5 Bash guards: danger guard + file guard + tracker close guard + universal push guard + lead close-gate
528
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
529
+ expect(bashGuards.length).toBe(5);
530
+
531
+ // One Bash guard is the lead close-gate (agentplate-3899)
532
+ const closeGate = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
533
+ h.hooks[0]?.command?.includes("merge_ready gate"),
534
+ );
535
+ expect(closeGate).toBeDefined();
536
+ });
537
+
538
+ test("builder capability gets path boundary + Bash danger + Bash path boundary guards + native team tool blocks", async () => {
539
+ const worktreePath = join(tempDir, "builder-wt");
540
+
541
+ await deployHooks(worktreePath, "builder-agent", "builder");
542
+
543
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
544
+ const content = await Bun.file(outputPath).text();
545
+ const parsed = JSON.parse(content);
546
+ const preToolUse = parsed.hooks.PreToolUse;
547
+
548
+ const guardMatchers = preToolUse
549
+ .filter((h: { matcher: string }) => h.matcher !== "")
550
+ .map((h: { matcher: string }) => h.matcher);
551
+
552
+ // Path boundary guards + Bash danger guard + Bash path boundary guard + 10 native team tool blocks
553
+ expect(guardMatchers).toContain("Bash");
554
+ expect(guardMatchers).toContain("Task");
555
+ expect(guardMatchers).toContain("TeamCreate");
556
+ // Builder has Write guards for path boundary (not block guards)
557
+ expect(guardMatchers).toContain("Write");
558
+ const writeGuards = preToolUse.filter(
559
+ (h: { matcher: string; hooks: Array<{ command: string }> }) => h.matcher === "Write",
560
+ );
561
+ // Path boundary guard, not a full block
562
+ expect(writeGuards[0].hooks[0].command).toContain("AGENTPLATE_WORKTREE_PATH");
563
+ expect(writeGuards[0].hooks[0].command).not.toContain("cannot modify files");
564
+
565
+ // Builder should have 4 Bash guards: danger guard + path boundary guard + tracker close guard + universal push guard
566
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
567
+ expect(bashGuards.length).toBe(4);
568
+ // One should be the danger guard (checks git push)
569
+ const dangerGuard = bashGuards.find(
570
+ (h: { hooks: Array<{ command: string }> }) =>
571
+ h.hooks[0]?.command?.includes("git") && h.hooks[0]?.command?.includes("push"),
572
+ );
573
+ expect(dangerGuard).toBeDefined();
574
+ // One should be the path boundary guard
575
+ const pathBoundaryGuard = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
576
+ h.hooks[0]?.command?.includes("Bash path boundary violation"),
577
+ );
578
+ expect(pathBoundaryGuard).toBeDefined();
579
+ });
580
+
581
+ test("merger capability gets path boundary + Bash danger guards + native team tool blocks", async () => {
582
+ const worktreePath = join(tempDir, "merger-wt");
583
+
584
+ await deployHooks(worktreePath, "merger-agent", "merger");
585
+
586
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
587
+ const content = await Bun.file(outputPath).text();
588
+ const parsed = JSON.parse(content);
589
+ const preToolUse = parsed.hooks.PreToolUse;
590
+
591
+ const guardMatchers = preToolUse
592
+ .filter((h: { matcher: string }) => h.matcher !== "")
593
+ .map((h: { matcher: string }) => h.matcher);
594
+
595
+ expect(guardMatchers).toContain("Bash");
596
+ expect(guardMatchers).toContain("Task");
597
+ // Merger has Write path boundary guards
598
+ expect(guardMatchers).toContain("Write");
599
+ });
600
+
601
+ test("default capability (no arg) gets path boundary + Bash danger guards + native team tool blocks", async () => {
602
+ const worktreePath = join(tempDir, "default-wt");
603
+
604
+ await deployHooks(worktreePath, "default-agent");
605
+
606
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
607
+ const content = await Bun.file(outputPath).text();
608
+ const parsed = JSON.parse(content);
609
+ const preToolUse = parsed.hooks.PreToolUse;
610
+
611
+ const guardMatchers = preToolUse
612
+ .filter((h: { matcher: string }) => h.matcher !== "")
613
+ .map((h: { matcher: string }) => h.matcher);
614
+
615
+ expect(guardMatchers).toContain("Bash");
616
+ expect(guardMatchers).toContain("Task");
617
+ // Default (builder) has Write path boundary guards
618
+ expect(guardMatchers).toContain("Write");
619
+ });
620
+
621
+ test("guards are prepended before base logging hook", async () => {
622
+ const worktreePath = join(tempDir, "order-wt");
623
+
624
+ await deployHooks(worktreePath, "order-agent", "scout");
625
+
626
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
627
+ const content = await Bun.file(outputPath).text();
628
+ const parsed = JSON.parse(content);
629
+ const preToolUse = parsed.hooks.PreToolUse;
630
+
631
+ // Guards (matcher != "") should come before base (matcher == "")
632
+ const baseIdx = preToolUse.findIndex((h: { matcher: string }) => h.matcher === "");
633
+ const writeIdx = preToolUse.findIndex((h: { matcher: string }) => h.matcher === "Write");
634
+
635
+ expect(writeIdx).toBeLessThan(baseIdx);
636
+ });
637
+
638
+ test("preserves user hooks alongside agentplate hooks", async () => {
639
+ const worktreePath = join(tempDir, "merge-user-hooks-wt");
640
+ const claudeDir = join(worktreePath, ".claude");
641
+ const { mkdir } = await import("node:fs/promises");
642
+ await mkdir(claudeDir, { recursive: true });
643
+ await Bun.write(
644
+ join(claudeDir, "settings.local.json"),
645
+ JSON.stringify({
646
+ hooks: {
647
+ PreToolUse: [
648
+ {
649
+ matcher: "Bash",
650
+ hooks: [{ type: "command", command: "echo user-custom-guard" }],
651
+ },
652
+ ],
653
+ SessionStart: [
654
+ {
655
+ matcher: "",
656
+ hooks: [{ type: "command", command: "echo user-session-hook" }],
657
+ },
658
+ ],
659
+ },
660
+ }),
661
+ );
662
+
663
+ await deployHooks(worktreePath, "merge-agent", "builder");
664
+
665
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
666
+ const parsed = JSON.parse(content);
667
+
668
+ // User hooks should be preserved
669
+ const preToolUse = parsed.hooks.PreToolUse;
670
+ const userBashHook = preToolUse.find(
671
+ (h: { hooks: Array<{ command: string }> }) =>
672
+ h.hooks[0]?.command === "echo user-custom-guard",
673
+ );
674
+ expect(userBashHook).toBeDefined();
675
+
676
+ const sessionStart = parsed.hooks.SessionStart;
677
+ const userSessionHook = sessionStart.find(
678
+ (h: { hooks: Array<{ command: string }> }) =>
679
+ h.hooks[0]?.command === "echo user-session-hook",
680
+ );
681
+ expect(userSessionHook).toBeDefined();
682
+
683
+ // Agentplate hooks should also be present
684
+ expect(content).toContain("merge-agent");
685
+ expect(content).toContain("ap prime");
686
+ });
687
+
688
+ test("agentplate hooks appear before user hooks per event type", async () => {
689
+ const worktreePath = join(tempDir, "order-merge-wt");
690
+ const claudeDir = join(worktreePath, ".claude");
691
+ const { mkdir } = await import("node:fs/promises");
692
+ await mkdir(claudeDir, { recursive: true });
693
+ await Bun.write(
694
+ join(claudeDir, "settings.local.json"),
695
+ JSON.stringify({
696
+ hooks: {
697
+ PreToolUse: [
698
+ {
699
+ matcher: "Bash",
700
+ hooks: [{ type: "command", command: "echo user-guard-first" }],
701
+ },
702
+ ],
703
+ },
704
+ }),
705
+ );
706
+
707
+ await deployHooks(worktreePath, "order-merge-agent", "scout");
708
+
709
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
710
+ const parsed = JSON.parse(content);
711
+ const preToolUse = parsed.hooks.PreToolUse;
712
+
713
+ // User hook should be at the end (after all agentplate hooks)
714
+ const userIdx = preToolUse.findIndex(
715
+ (h: { hooks: Array<{ command: string }> }) => h.hooks[0]?.command === "echo user-guard-first",
716
+ );
717
+ const lastAgentplateIdx = preToolUse.reduce(
718
+ (last: number, h: { hooks: Array<{ command: string }> }, i: number) => {
719
+ if (
720
+ h.hooks[0]?.command?.includes("ap ") ||
721
+ h.hooks[0]?.command?.includes("agentplate") ||
722
+ h.hooks[0]?.command?.includes("AGENTPLATE_")
723
+ ) {
724
+ return i;
725
+ }
726
+ return last;
727
+ },
728
+ -1,
729
+ );
730
+
731
+ expect(userIdx).toBeGreaterThan(lastAgentplateIdx);
732
+ });
733
+
734
+ test("strips stale agentplate entries on re-deployment", async () => {
735
+ const worktreePath = join(tempDir, "stale-strip-wt");
736
+
737
+ // First deploy
738
+ await deployHooks(worktreePath, "stale-agent", "builder");
739
+
740
+ // Read the deployed config to count agentplate entries
741
+ const firstContent = await Bun.file(
742
+ join(worktreePath, ".claude", "settings.local.json"),
743
+ ).text();
744
+ const firstParsed = JSON.parse(firstContent);
745
+ const firstPreToolUseCount = firstParsed.hooks.PreToolUse.length;
746
+
747
+ // Second deploy (should strip old agentplate entries, not accumulate)
748
+ await deployHooks(worktreePath, "stale-agent", "builder");
749
+
750
+ const secondContent = await Bun.file(
751
+ join(worktreePath, ".claude", "settings.local.json"),
752
+ ).text();
753
+ const secondParsed = JSON.parse(secondContent);
754
+ const secondPreToolUseCount = secondParsed.hooks.PreToolUse.length;
755
+
756
+ // Same count — idempotent, no accumulation
757
+ expect(secondPreToolUseCount).toBe(firstPreToolUseCount);
758
+ });
759
+
760
+ test("re-deployment is idempotent with user hooks present", async () => {
761
+ const worktreePath = join(tempDir, "idempotent-wt");
762
+ const claudeDir = join(worktreePath, ".claude");
763
+ const { mkdir } = await import("node:fs/promises");
764
+ await mkdir(claudeDir, { recursive: true });
765
+ await Bun.write(
766
+ join(claudeDir, "settings.local.json"),
767
+ JSON.stringify({
768
+ permissions: { allow: ["Read"] },
769
+ hooks: {
770
+ PreToolUse: [
771
+ {
772
+ matcher: "Bash",
773
+ hooks: [{ type: "command", command: "echo my-custom-lint" }],
774
+ },
775
+ ],
776
+ },
777
+ }),
778
+ );
779
+
780
+ // Deploy twice
781
+ await deployHooks(worktreePath, "idem-agent", "coordinator");
782
+ await deployHooks(worktreePath, "idem-agent", "coordinator");
783
+
784
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
785
+ const parsed = JSON.parse(content);
786
+
787
+ // User hook appears exactly once
788
+ const userHooks = parsed.hooks.PreToolUse.filter(
789
+ (h: { hooks: Array<{ command: string }> }) => h.hooks[0]?.command === "echo my-custom-lint",
790
+ );
791
+ expect(userHooks).toHaveLength(1);
792
+
793
+ // Non-hooks keys preserved
794
+ expect(parsed.permissions).toEqual({ allow: ["Read"] });
795
+ });
796
+
797
+ test("handles malformed existing settings.local.json gracefully", async () => {
798
+ const worktreePath = join(tempDir, "malformed-wt");
799
+ const claudeDir = join(worktreePath, ".claude");
800
+ const { mkdir } = await import("node:fs/promises");
801
+ await mkdir(claudeDir, { recursive: true });
802
+ await Bun.write(join(claudeDir, "settings.local.json"), "not valid json{{{");
803
+
804
+ // Should not throw — falls back to fresh config
805
+ await deployHooks(worktreePath, "malformed-agent");
806
+
807
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
808
+ const parsed = JSON.parse(content);
809
+ expect(parsed.hooks).toBeDefined();
810
+ expect(content).toContain("malformed-agent");
811
+ });
812
+
813
+ test("preserves user hooks in event types not in template", async () => {
814
+ const worktreePath = join(tempDir, "custom-event-wt");
815
+ const claudeDir = join(worktreePath, ".claude");
816
+ const { mkdir } = await import("node:fs/promises");
817
+ await mkdir(claudeDir, { recursive: true });
818
+ await Bun.write(
819
+ join(claudeDir, "settings.local.json"),
820
+ JSON.stringify({
821
+ hooks: {
822
+ CustomEvent: [
823
+ {
824
+ matcher: "",
825
+ hooks: [{ type: "command", command: "echo custom-event-hook" }],
826
+ },
827
+ ],
828
+ },
829
+ }),
830
+ );
831
+
832
+ await deployHooks(worktreePath, "custom-event-agent");
833
+
834
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
835
+ const parsed = JSON.parse(content);
836
+
837
+ // Custom event type should be preserved
838
+ expect(parsed.hooks.CustomEvent).toBeDefined();
839
+ expect(parsed.hooks.CustomEvent).toHaveLength(1);
840
+ expect(parsed.hooks.CustomEvent[0].hooks[0].command).toBe("echo custom-event-hook");
841
+ });
842
+
843
+ test("SessionStart hook includes mail check command with agent name", async () => {
844
+ const worktreePath = join(tempDir, "worktree");
845
+ await deployHooks(worktreePath, "my-agent");
846
+
847
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
848
+ const content = await Bun.file(outputPath).text();
849
+ const parsed = JSON.parse(content);
850
+
851
+ const sessionStart = parsed.hooks.SessionStart;
852
+ const allCommands = sessionStart.flatMap((entry: { hooks: { command: string }[] }) =>
853
+ entry.hooks.map((h) => h.command),
854
+ );
855
+ const mailCheckCmd = allCommands.find((cmd: string) =>
856
+ cmd.includes("ap mail check --inject --agent my-agent"),
857
+ );
858
+ expect(mailCheckCmd).toBeDefined();
859
+ });
860
+
861
+ test("SessionStart template entry has both prime and mail check hooks", async () => {
862
+ const worktreePath = join(tempDir, "worktree");
863
+ await deployHooks(worktreePath, "test-agent");
864
+
865
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
866
+ const content = await Bun.file(outputPath).text();
867
+ const parsed = JSON.parse(content);
868
+
869
+ const sessionStart = parsed.hooks.SessionStart;
870
+ const allCommands = sessionStart.flatMap((entry: { hooks: { command: string }[] }) =>
871
+ entry.hooks.map((h) => h.command),
872
+ );
873
+ const hasPrime = allCommands.some((cmd: string) => cmd.includes("ap prime --agent"));
874
+ const hasMailCheck = allCommands.some((cmd: string) => cmd.includes("ap mail check --inject"));
875
+ expect(hasPrime).toBe(true);
876
+ expect(hasMailCheck).toBe(true);
877
+ });
878
+
879
+ test("SessionStart mail check command includes PATH_PREFIX", async () => {
880
+ const worktreePath = join(tempDir, "worktree");
881
+ await deployHooks(worktreePath, "path-test");
882
+
883
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
884
+ const content = await Bun.file(outputPath).text();
885
+ const parsed = JSON.parse(content);
886
+
887
+ const sessionStart = parsed.hooks.SessionStart;
888
+ const allCommands = sessionStart.flatMap((entry: { hooks: { command: string }[] }) =>
889
+ entry.hooks.map((h) => h.command),
890
+ );
891
+ const mailCheckCmd = allCommands.find((cmd: string) => cmd.includes("ap mail check --inject"));
892
+ expect(mailCheckCmd).toBeDefined();
893
+ expect(mailCheckCmd).toContain(PATH_PREFIX);
894
+ });
895
+ });
896
+
897
+ describe("isAgentplateHookEntry", () => {
898
+ test("identifies entries with ap CLI commands", () => {
899
+ expect(
900
+ isAgentplateHookEntry({
901
+ matcher: "",
902
+ hooks: [{ type: "command", command: "ap prime --agent test" }],
903
+ }),
904
+ ).toBe(true);
905
+ });
906
+
907
+ test("identifies entries with AGENTPLATE_ env var references", () => {
908
+ expect(
909
+ isAgentplateHookEntry({
910
+ matcher: "Write",
911
+ hooks: [
912
+ {
913
+ type: "command",
914
+ command: '[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0; echo block',
915
+ },
916
+ ],
917
+ }),
918
+ ).toBe(true);
919
+ });
920
+
921
+ test("identifies entries with AGENTPLATE_WORKTREE_PATH", () => {
922
+ expect(
923
+ isAgentplateHookEntry({
924
+ matcher: "Bash",
925
+ hooks: [
926
+ {
927
+ type: "command",
928
+ command: '[ -z "$AGENTPLATE_WORKTREE_PATH" ] && exit 0;',
929
+ },
930
+ ],
931
+ }),
932
+ ).toBe(true);
933
+ });
934
+
935
+ test("returns false for user hooks without agentplate references", () => {
936
+ expect(
937
+ isAgentplateHookEntry({
938
+ matcher: "Bash",
939
+ hooks: [{ type: "command", command: "echo user-custom-guard" }],
940
+ }),
941
+ ).toBe(false);
942
+ });
943
+
944
+ test("returns false for empty hooks array", () => {
945
+ expect(
946
+ isAgentplateHookEntry({
947
+ matcher: "",
948
+ hooks: [],
949
+ }),
950
+ ).toBe(false);
951
+ });
952
+
953
+ test("checks all hooks in the entry (any match = agentplate)", () => {
954
+ expect(
955
+ isAgentplateHookEntry({
956
+ matcher: "",
957
+ hooks: [
958
+ { type: "command", command: "echo user-thing" },
959
+ { type: "command", command: "ap mail check" },
960
+ ],
961
+ }),
962
+ ).toBe(true);
963
+ });
964
+ });
965
+
966
+ describe("getCapabilityGuards", () => {
967
+ // 10 native team tool blocks apply to ALL capabilities
968
+ const NATIVE_TEAM_TOOL_COUNT = 10;
969
+ // 3 interactive tool blocks (AskUserQuestion, EnterPlanMode, EnterWorktree) apply to ALL capabilities
970
+ const INTERACTIVE_TOOL_COUNT = 3;
971
+
972
+ test("returns 17 guards for scout (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
973
+ const guards = getCapabilityGuards("scout");
974
+ expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
975
+ });
976
+
977
+ test("returns 17 guards for reviewer (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
978
+ const guards = getCapabilityGuards("reviewer");
979
+ expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
980
+ });
981
+
982
+ test("returns 18 guards for lead (10 team + 3 interactive + 3 tool blocks + 1 bash file guard + 1 close-gate)", () => {
983
+ const guards = getCapabilityGuards("lead");
984
+ expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 5);
985
+ });
986
+
987
+ test("returns 14 guards for builder (10 team + 3 interactive + 1 bash path boundary)", () => {
988
+ const guards = getCapabilityGuards("builder");
989
+ expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 1);
990
+ });
991
+
992
+ test("returns 14 guards for merger (10 team + 3 interactive + 1 bash path boundary)", () => {
993
+ const guards = getCapabilityGuards("merger");
994
+ expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 1);
995
+ });
996
+
997
+ test("returns 13 guards for unknown capability (10 team + 3 interactive tool blocks)", () => {
998
+ const guards = getCapabilityGuards("unknown");
999
+ expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT);
1000
+ });
1001
+
1002
+ test("builder gets Bash path boundary guard", () => {
1003
+ const guards = getCapabilityGuards("builder");
1004
+ const bashGuard = guards.find((g) => g.matcher === "Bash");
1005
+ expect(bashGuard).toBeDefined();
1006
+ expect(bashGuard?.hooks[0]?.command).toContain("AGENTPLATE_WORKTREE_PATH");
1007
+ expect(bashGuard?.hooks[0]?.command).toContain("Bash path boundary violation");
1008
+ });
1009
+
1010
+ test("merger gets Bash path boundary guard", () => {
1011
+ const guards = getCapabilityGuards("merger");
1012
+ const bashGuard = guards.find((g) => g.matcher === "Bash");
1013
+ expect(bashGuard).toBeDefined();
1014
+ expect(bashGuard?.hooks[0]?.command).toContain("AGENTPLATE_WORKTREE_PATH");
1015
+ expect(bashGuard?.hooks[0]?.command).toContain("Bash path boundary violation");
1016
+ });
1017
+
1018
+ test("scout guards include Write, Edit, NotebookEdit, and Bash matchers", () => {
1019
+ const guards = getCapabilityGuards("scout");
1020
+ const matchers = guards.map((g) => g.matcher);
1021
+ expect(matchers).toContain("Write");
1022
+ expect(matchers).toContain("Edit");
1023
+ expect(matchers).toContain("NotebookEdit");
1024
+ expect(matchers).toContain("Bash");
1025
+ });
1026
+
1027
+ test("lead guards include Write, Edit, NotebookEdit, and Bash matchers", () => {
1028
+ const guards = getCapabilityGuards("lead");
1029
+ const matchers = guards.map((g) => g.matcher);
1030
+ expect(matchers).toContain("Write");
1031
+ expect(matchers).toContain("Edit");
1032
+ expect(matchers).toContain("NotebookEdit");
1033
+ expect(matchers).toContain("Bash");
1034
+ });
1035
+
1036
+ test("tool block guards include capability name in reason", () => {
1037
+ const guards = getCapabilityGuards("scout");
1038
+ const writeGuard = guards.find((g) => g.matcher === "Write");
1039
+ expect(writeGuard).toBeDefined();
1040
+ expect(writeGuard?.hooks[0]?.command).toContain("scout");
1041
+ expect(writeGuard?.hooks[0]?.command).toContain("cannot modify files");
1042
+ });
1043
+
1044
+ test("lead tool block guards include lead in reason", () => {
1045
+ const guards = getCapabilityGuards("lead");
1046
+ const editGuard = guards.find((g) => g.matcher === "Edit");
1047
+ expect(editGuard).toBeDefined();
1048
+ expect(editGuard?.hooks[0]?.command).toContain("lead");
1049
+ expect(editGuard?.hooks[0]?.command).toContain("cannot modify files");
1050
+ });
1051
+
1052
+ test("bash file guard for scout includes capability in block message", () => {
1053
+ const guards = getCapabilityGuards("scout");
1054
+ const bashGuard = guards.find((g) => g.matcher === "Bash");
1055
+ expect(bashGuard).toBeDefined();
1056
+ expect(bashGuard?.hooks[0]?.command).toContain("scout agents cannot modify files");
1057
+ });
1058
+
1059
+ test("bash file guard for lead includes capability in block message", () => {
1060
+ const guards = getCapabilityGuards("lead");
1061
+ const bashGuard = guards.find((g) => g.matcher === "Bash");
1062
+ expect(bashGuard).toBeDefined();
1063
+ expect(bashGuard?.hooks[0]?.command).toContain("lead agents cannot modify files");
1064
+ });
1065
+
1066
+ test("all capabilities get Task tool blocked", () => {
1067
+ for (const cap of [
1068
+ "scout",
1069
+ "reviewer",
1070
+ "lead",
1071
+ "coordinator",
1072
+ "supervisor",
1073
+ "builder",
1074
+ "merger",
1075
+ ]) {
1076
+ const guards = getCapabilityGuards(cap);
1077
+ const taskGuard = guards.find((g) => g.matcher === "Task");
1078
+ expect(taskGuard).toBeDefined();
1079
+ expect(taskGuard?.hooks[0]?.command).toContain("ap sling");
1080
+ }
1081
+ });
1082
+
1083
+ test("all capabilities get TeamCreate and SendMessage blocked", () => {
1084
+ for (const cap of [
1085
+ "scout",
1086
+ "reviewer",
1087
+ "lead",
1088
+ "coordinator",
1089
+ "supervisor",
1090
+ "builder",
1091
+ "merger",
1092
+ ]) {
1093
+ const guards = getCapabilityGuards(cap);
1094
+ const matchers = guards.map((g) => g.matcher);
1095
+ expect(matchers).toContain("TeamCreate");
1096
+ expect(matchers).toContain("SendMessage");
1097
+ }
1098
+ });
1099
+
1100
+ test("block guard commands include env var guard prefix", () => {
1101
+ const guards = getCapabilityGuards("scout");
1102
+ for (const tool of ["Write", "Edit", "NotebookEdit"]) {
1103
+ const guard = guards.find((g) => g.matcher === tool);
1104
+ expect(guard).toBeDefined();
1105
+ expect(guard?.hooks[0]?.command).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
1106
+ }
1107
+ });
1108
+
1109
+ test("native team tool block guards include env var guard prefix", () => {
1110
+ const guards = getCapabilityGuards("builder");
1111
+ const taskGuard = guards.find((g) => g.matcher === "Task");
1112
+ expect(taskGuard).toBeDefined();
1113
+ expect(taskGuard?.hooks[0]?.command).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
1114
+ });
1115
+
1116
+ test("coordinator gets 17 guards (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
1117
+ const guards = getCapabilityGuards("coordinator");
1118
+ expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
1119
+ });
1120
+
1121
+ test("supervisor gets 17 guards (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
1122
+ const guards = getCapabilityGuards("supervisor");
1123
+ expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
1124
+ });
1125
+
1126
+ test("all capabilities get AskUserQuestion blocked", () => {
1127
+ for (const cap of [
1128
+ "scout",
1129
+ "reviewer",
1130
+ "lead",
1131
+ "coordinator",
1132
+ "supervisor",
1133
+ "builder",
1134
+ "merger",
1135
+ "unknown",
1136
+ ]) {
1137
+ const guards = getCapabilityGuards(cap);
1138
+ const guard = guards.find((g) => g.matcher === "AskUserQuestion");
1139
+ expect(guard).toBeDefined();
1140
+ expect(guard?.hooks[0]?.command).toContain("human interaction");
1141
+ expect(guard?.hooks[0]?.command).toContain("ap mail");
1142
+ }
1143
+ });
1144
+
1145
+ test("all capabilities get EnterPlanMode blocked", () => {
1146
+ for (const cap of [
1147
+ "scout",
1148
+ "reviewer",
1149
+ "lead",
1150
+ "coordinator",
1151
+ "supervisor",
1152
+ "builder",
1153
+ "merger",
1154
+ "unknown",
1155
+ ]) {
1156
+ const guards = getCapabilityGuards(cap);
1157
+ const guard = guards.find((g) => g.matcher === "EnterPlanMode");
1158
+ expect(guard).toBeDefined();
1159
+ expect(guard?.hooks[0]?.command).toContain("human interaction");
1160
+ expect(guard?.hooks[0]?.command).toContain("ap mail");
1161
+ }
1162
+ });
1163
+
1164
+ test("all capabilities get EnterWorktree blocked", () => {
1165
+ for (const cap of [
1166
+ "scout",
1167
+ "reviewer",
1168
+ "lead",
1169
+ "coordinator",
1170
+ "supervisor",
1171
+ "builder",
1172
+ "merger",
1173
+ "unknown",
1174
+ ]) {
1175
+ const guards = getCapabilityGuards(cap);
1176
+ const guard = guards.find((g) => g.matcher === "EnterWorktree");
1177
+ expect(guard).toBeDefined();
1178
+ expect(guard?.hooks[0]?.command).toContain("human interaction");
1179
+ expect(guard?.hooks[0]?.command).toContain("ap mail");
1180
+ }
1181
+ });
1182
+
1183
+ test("interactive guards include env var guard prefix", () => {
1184
+ const guards = getCapabilityGuards("builder");
1185
+ for (const tool of ["AskUserQuestion", "EnterPlanMode", "EnterWorktree"]) {
1186
+ const guard = guards.find((g) => g.matcher === tool);
1187
+ expect(guard).toBeDefined();
1188
+ expect(guard?.hooks[0]?.command).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
1189
+ }
1190
+ });
1191
+
1192
+ test("interactive guard block reason mentions tool name", () => {
1193
+ const guards = getCapabilityGuards("scout");
1194
+ const askGuard = guards.find((g) => g.matcher === "AskUserQuestion");
1195
+ expect(askGuard?.hooks[0]?.command).toContain("AskUserQuestion");
1196
+ const planGuard = guards.find((g) => g.matcher === "EnterPlanMode");
1197
+ expect(planGuard?.hooks[0]?.command).toContain("EnterPlanMode");
1198
+ const worktreeGuard = guards.find((g) => g.matcher === "EnterWorktree");
1199
+ expect(worktreeGuard?.hooks[0]?.command).toContain("EnterWorktree");
1200
+ });
1201
+ });
1202
+
1203
+ describe("getDangerGuards", () => {
1204
+ test("returns exactly one Bash guard entry", () => {
1205
+ const guards = getDangerGuards("test-agent");
1206
+ expect(guards).toHaveLength(1);
1207
+ expect(guards[0]?.matcher).toBe("Bash");
1208
+ });
1209
+
1210
+ test("guard command includes agent name for branch validation", () => {
1211
+ const guards = getDangerGuards("my-builder");
1212
+ const command = guards[0]?.hooks[0]?.command ?? "";
1213
+ expect(command).toContain("agentplate/my-builder/");
1214
+ });
1215
+
1216
+ test("guard command blocks all git push", () => {
1217
+ const guards = getDangerGuards("test-agent");
1218
+ const command = guards[0]?.hooks[0]?.command ?? "";
1219
+ expect(command).toContain("git");
1220
+ expect(command).toContain("push");
1221
+ expect(command).toContain("block");
1222
+ });
1223
+
1224
+ test("guard command checks for git reset --hard", () => {
1225
+ const guards = getDangerGuards("test-agent");
1226
+ const command = guards[0]?.hooks[0]?.command ?? "";
1227
+ expect(command).toContain("reset");
1228
+ expect(command).toContain("--hard");
1229
+ });
1230
+
1231
+ test("guard command checks for git checkout -b", () => {
1232
+ const guards = getDangerGuards("test-agent");
1233
+ const command = guards[0]?.hooks[0]?.command ?? "";
1234
+ expect(command).toContain("checkout");
1235
+ expect(command).toContain("-b");
1236
+ });
1237
+
1238
+ test("guard hook type is command", () => {
1239
+ const guards = getDangerGuards("test-agent");
1240
+ expect(guards[0]?.hooks[0]?.type).toBe("command");
1241
+ });
1242
+
1243
+ test("guard command includes env var guard prefix", () => {
1244
+ const guards = getDangerGuards("test-agent");
1245
+ const command = guards[0]?.hooks[0]?.command ?? "";
1246
+ expect(command).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
1247
+ });
1248
+
1249
+ test("all capabilities get Bash danger guards in deployed hooks", async () => {
1250
+ const capabilities = ["builder", "scout", "reviewer", "lead", "merger"];
1251
+ const tempDir = await import("node:fs/promises").then((fs) =>
1252
+ fs.mkdtemp(join(require("node:os").tmpdir(), "agentplate-danger-test-")),
1253
+ );
1254
+
1255
+ try {
1256
+ for (const cap of capabilities) {
1257
+ const worktreePath = join(tempDir, `${cap}-wt`);
1258
+ await deployHooks(worktreePath, `${cap}-agent`, cap);
1259
+
1260
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1261
+ const content = await Bun.file(outputPath).text();
1262
+ const parsed = JSON.parse(content);
1263
+ const preToolUse = parsed.hooks.PreToolUse;
1264
+
1265
+ const bashGuard = preToolUse.find((h: { matcher: string }) => h.matcher === "Bash");
1266
+ expect(bashGuard).toBeDefined();
1267
+ expect(bashGuard.hooks[0].command).toContain(`agentplate/${cap}-agent/`);
1268
+ }
1269
+ } finally {
1270
+ await import("node:fs/promises").then((fs) =>
1271
+ fs.rm(tempDir, { recursive: true, force: true }),
1272
+ );
1273
+ }
1274
+ });
1275
+
1276
+ test("guard ordering: path boundary → danger → capability in scout", async () => {
1277
+ const tempDir = await import("node:fs/promises").then((fs) =>
1278
+ fs.mkdtemp(join(require("node:os").tmpdir(), "agentplate-order-test-")),
1279
+ );
1280
+
1281
+ try {
1282
+ const worktreePath = join(tempDir, "scout-order-wt");
1283
+ await deployHooks(worktreePath, "scout-order", "scout");
1284
+
1285
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1286
+ const content = await Bun.file(outputPath).text();
1287
+ const parsed = JSON.parse(content);
1288
+ const preToolUse = parsed.hooks.PreToolUse;
1289
+
1290
+ // Path boundary Write guard (first) should come before Bash danger guard
1291
+ const pathBoundaryWriteIdx = preToolUse.findIndex(
1292
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1293
+ h.matcher === "Write" && h.hooks[0]?.command?.includes("AGENTPLATE_WORKTREE_PATH"),
1294
+ );
1295
+ const bashDangerIdx = preToolUse.findIndex((h: { matcher: string }) => h.matcher === "Bash");
1296
+ // Capability block Write guard should come after Bash danger guard
1297
+ const writeBlockIdx = preToolUse.findIndex(
1298
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1299
+ h.matcher === "Write" && h.hooks[0]?.command?.includes("cannot modify files"),
1300
+ );
1301
+
1302
+ expect(pathBoundaryWriteIdx).toBeLessThan(bashDangerIdx);
1303
+ expect(bashDangerIdx).toBeLessThan(writeBlockIdx);
1304
+ } finally {
1305
+ await import("node:fs/promises").then((fs) =>
1306
+ fs.rm(tempDir, { recursive: true, force: true }),
1307
+ );
1308
+ }
1309
+ });
1310
+
1311
+ test("custom quality gates appear in safe prefix list for non-implementation capabilities", () => {
1312
+ const guards = getCapabilityGuards("scout", [
1313
+ { name: "Test", command: "pytest", description: "all tests pass" },
1314
+ { name: "Lint", command: "ruff check .", description: "no lint errors" },
1315
+ ]);
1316
+ // Find the Bash guard for file modifications (last Bash entry for non-implementation)
1317
+ const bashGuards = guards.filter((g) => g.matcher === "Bash");
1318
+ const fileGuard = bashGuards.find((g) =>
1319
+ g.hooks.some((h) => h.command.includes("cannot modify files")),
1320
+ );
1321
+ expect(fileGuard).toBeDefined();
1322
+ const command = fileGuard?.hooks[0]?.command ?? "";
1323
+ expect(command).toContain("pytest");
1324
+ expect(command).toContain("ruff check .");
1325
+ // Should NOT contain default bun commands
1326
+ expect(command).not.toContain("bun test");
1327
+ });
1328
+ });
1329
+
1330
+ describe("extractQualityGatePrefixes", () => {
1331
+ test("extracts command from each gate", () => {
1332
+ const gates = [
1333
+ { name: "Test", command: "bun test", description: "all tests pass" },
1334
+ { name: "Lint", command: "bun run lint", description: "zero errors" },
1335
+ ];
1336
+ const prefixes = extractQualityGatePrefixes(gates);
1337
+ expect(prefixes).toEqual(["bun test", "bun run lint"]);
1338
+ });
1339
+
1340
+ test("returns empty array for empty gates", () => {
1341
+ expect(extractQualityGatePrefixes([])).toEqual([]);
1342
+ });
1343
+
1344
+ test("works with non-bun quality gates", () => {
1345
+ const gates = [
1346
+ { name: "Test", command: "pytest", description: "all tests pass" },
1347
+ { name: "Lint", command: "ruff check .", description: "no lint errors" },
1348
+ { name: "Type", command: "mypy src/", description: "type check" },
1349
+ ];
1350
+ const prefixes = extractQualityGatePrefixes(gates);
1351
+ expect(prefixes).toEqual(["pytest", "ruff check .", "mypy src/"]);
1352
+ });
1353
+ });
1354
+
1355
+ describe("buildBashFileGuardScript", () => {
1356
+ test("returns a string containing the capability name", () => {
1357
+ const script = buildBashFileGuardScript("scout");
1358
+ expect(script).toContain("scout agents cannot modify files");
1359
+ });
1360
+
1361
+ test("reads stdin input", () => {
1362
+ const script = buildBashFileGuardScript("scout");
1363
+ expect(script).toContain("read -r INPUT");
1364
+ });
1365
+
1366
+ test("extracts command from JSON input", () => {
1367
+ const script = buildBashFileGuardScript("reviewer");
1368
+ expect(script).toContain("CMD=$(");
1369
+ });
1370
+
1371
+ test("includes safe prefix whitelist checks", () => {
1372
+ const script = buildBashFileGuardScript("scout");
1373
+ expect(script).toContain("agentplate ");
1374
+ expect(script).toContain("bd ");
1375
+ expect(script).toContain("sr ");
1376
+ expect(script).toContain("git status");
1377
+ expect(script).toContain("git log");
1378
+ expect(script).toContain("git diff");
1379
+ expect(script).toContain("loam ");
1380
+ // Quality gate commands (bun test, bun run lint, etc.) are no longer
1381
+ // hardcoded in SAFE_BASH_PREFIXES — they come from config via
1382
+ // extractQualityGatePrefixes() and are passed as extraSafePrefixes
1383
+ // through getCapabilityGuards().
1384
+ });
1385
+
1386
+ test("includes quality gate prefixes when passed as extraSafePrefixes", () => {
1387
+ const script = buildBashFileGuardScript("scout", ["bun test", "bun run lint"]);
1388
+ expect(script).toContain("bun test");
1389
+ expect(script).toContain("bun run lint");
1390
+ });
1391
+
1392
+ test("sr commands pass bash file guard for non-implementation agents", () => {
1393
+ const script = buildBashFileGuardScript("scout");
1394
+ expect(script).toContain("sr ");
1395
+ });
1396
+
1397
+ test("includes dangerous command pattern checks", () => {
1398
+ const script = buildBashFileGuardScript("lead");
1399
+ // File modification commands
1400
+ expect(script).toContain("sed");
1401
+ expect(script).toContain("tee");
1402
+ expect(script).toContain("vim");
1403
+ expect(script).toContain("nano");
1404
+ expect(script).toContain("mv");
1405
+ expect(script).toContain("cp");
1406
+ expect(script).toContain("rm");
1407
+ expect(script).toContain("mkdir");
1408
+ expect(script).toContain("touch");
1409
+ // Git modification commands
1410
+ expect(script).toContain("git\\s+add");
1411
+ expect(script).toContain("git\\s+commit");
1412
+ expect(script).toContain("git\\s+push");
1413
+ });
1414
+
1415
+ test("blocks sed -i for all non-implementation capabilities", () => {
1416
+ for (const cap of ["scout", "reviewer", "lead"]) {
1417
+ const script = buildBashFileGuardScript(cap);
1418
+ expect(script).toContain("sed\\s+-i");
1419
+ }
1420
+ });
1421
+
1422
+ test("blocks bun install and bun add", () => {
1423
+ const script = buildBashFileGuardScript("scout");
1424
+ expect(script).toContain("bun\\s+install");
1425
+ expect(script).toContain("bun\\s+add");
1426
+ });
1427
+
1428
+ test("blocks npm install", () => {
1429
+ const script = buildBashFileGuardScript("scout");
1430
+ expect(script).toContain("npm\\s+install");
1431
+ });
1432
+
1433
+ test("blocks file permission commands", () => {
1434
+ const script = buildBashFileGuardScript("reviewer");
1435
+ expect(script).toContain("chmod");
1436
+ expect(script).toContain("chown");
1437
+ });
1438
+
1439
+ test("blocks append redirect operator", () => {
1440
+ const script = buildBashFileGuardScript("lead");
1441
+ expect(script).toContain(">>");
1442
+ });
1443
+
1444
+ test("blocks bun -e eval execution", () => {
1445
+ const script = buildBashFileGuardScript("scout");
1446
+ expect(script).toContain("bun\\s+-e");
1447
+ });
1448
+
1449
+ test("blocks node -e eval execution", () => {
1450
+ const script = buildBashFileGuardScript("scout");
1451
+ expect(script).toContain("node\\s+-e");
1452
+ });
1453
+
1454
+ test("blocks runtime eval flags (bun --eval, deno eval, python -c, perl -e, ruby -e)", () => {
1455
+ const script = buildBashFileGuardScript("scout");
1456
+ expect(script).toContain("bun\\s+--eval");
1457
+ expect(script).toContain("deno\\s+eval");
1458
+ expect(script).toContain("python3?\\s+-c");
1459
+ expect(script).toContain("perl\\s+-e");
1460
+ expect(script).toContain("ruby\\s+-e");
1461
+ });
1462
+
1463
+ test("includes env var guard prefix", () => {
1464
+ const script = buildBashFileGuardScript("scout");
1465
+ expect(script).toMatch(/^\[ -z "\$AGENTPLATE_AGENT_NAME" \] && exit 0;/);
1466
+ });
1467
+
1468
+ test("accepts extra safe prefixes for coordinator", () => {
1469
+ const script = buildBashFileGuardScript("coordinator", ["git add", "git commit"]);
1470
+ expect(script).toContain("git add");
1471
+ expect(script).toContain("git commit");
1472
+ });
1473
+
1474
+ test("default script does not whitelist git add/commit", () => {
1475
+ const script = buildBashFileGuardScript("scout");
1476
+ // git add/commit should NOT be in the safe prefix checks (only in danger patterns)
1477
+ // The safe prefixes use exit 0, danger patterns use decision:block
1478
+ const safeSection = script.split("grep -qE '")[0] ?? "";
1479
+ expect(safeSection).not.toContain("'^\\s*git add'");
1480
+ expect(safeSection).not.toContain("'^\\s*git commit'");
1481
+ });
1482
+
1483
+ test("safe prefix checks use exit 0 to allow", () => {
1484
+ const script = buildBashFileGuardScript("scout");
1485
+ // Each safe prefix should have an exit 0 to allow the command
1486
+ expect(script).toContain("exit 0; fi;");
1487
+ });
1488
+
1489
+ test("dangerous pattern check outputs block decision JSON", () => {
1490
+ const script = buildBashFileGuardScript("reviewer");
1491
+ expect(script).toContain('"decision":"block"');
1492
+ expect(script).toContain("reviewer agents cannot modify files");
1493
+ });
1494
+ });
1495
+
1496
+ describe("structural enforcement integration", () => {
1497
+ let tempDir: string;
1498
+
1499
+ beforeEach(async () => {
1500
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-structural-test-"));
1501
+ });
1502
+
1503
+ afterEach(async () => {
1504
+ await cleanupTempDir(tempDir);
1505
+ });
1506
+
1507
+ test("non-implementation agents have more guards than implementation agents", async () => {
1508
+ const scoutPath = join(tempDir, "scout-wt");
1509
+ const builderPath = join(tempDir, "builder-wt");
1510
+
1511
+ await deployHooks(scoutPath, "scout-1", "scout");
1512
+ await deployHooks(builderPath, "builder-1", "builder");
1513
+
1514
+ const scoutContent = await Bun.file(join(scoutPath, ".claude", "settings.local.json")).text();
1515
+ const builderContent = await Bun.file(
1516
+ join(builderPath, ".claude", "settings.local.json"),
1517
+ ).text();
1518
+
1519
+ const scoutPreToolUse = JSON.parse(scoutContent).hooks.PreToolUse;
1520
+ const builderPreToolUse = JSON.parse(builderContent).hooks.PreToolUse;
1521
+
1522
+ // Scout should have more PreToolUse entries than builder
1523
+ expect(scoutPreToolUse.length).toBeGreaterThan(builderPreToolUse.length);
1524
+ });
1525
+
1526
+ test("scout and reviewer have identical guard structures", async () => {
1527
+ const scoutPath = join(tempDir, "scout-wt");
1528
+ const reviewerPath = join(tempDir, "reviewer-wt");
1529
+
1530
+ await deployHooks(scoutPath, "scout-1", "scout");
1531
+ await deployHooks(reviewerPath, "reviewer-1", "reviewer");
1532
+
1533
+ const scoutContent = await Bun.file(join(scoutPath, ".claude", "settings.local.json")).text();
1534
+ const reviewerContent = await Bun.file(
1535
+ join(reviewerPath, ".claude", "settings.local.json"),
1536
+ ).text();
1537
+
1538
+ const scoutPreToolUse = JSON.parse(scoutContent).hooks.PreToolUse;
1539
+ const reviewerPreToolUse = JSON.parse(reviewerContent).hooks.PreToolUse;
1540
+
1541
+ // Same number of guards
1542
+ expect(scoutPreToolUse.length).toBe(reviewerPreToolUse.length);
1543
+
1544
+ // Same matchers (just different agent names in commands)
1545
+ const scoutMatchers = scoutPreToolUse.map((h: { matcher: string }) => h.matcher);
1546
+ const reviewerMatchers = reviewerPreToolUse.map((h: { matcher: string }) => h.matcher);
1547
+ expect(scoutMatchers).toEqual(reviewerMatchers);
1548
+ });
1549
+
1550
+ test("lead has scout/reviewer guards plus the merge_ready close-gate", async () => {
1551
+ const leadPath = join(tempDir, "lead-wt");
1552
+ const scoutPath = join(tempDir, "scout-wt");
1553
+
1554
+ await deployHooks(leadPath, "lead-1", "lead");
1555
+ await deployHooks(scoutPath, "scout-1", "scout");
1556
+
1557
+ const leadContent = await Bun.file(join(leadPath, ".claude", "settings.local.json")).text();
1558
+ const scoutContent = await Bun.file(join(scoutPath, ".claude", "settings.local.json")).text();
1559
+
1560
+ const leadPreToolUse = JSON.parse(leadContent).hooks.PreToolUse;
1561
+ const scoutPreToolUse = JSON.parse(scoutContent).hooks.PreToolUse;
1562
+
1563
+ // Lead has exactly one extra guard: the merge_ready close-gate (agentplate-3899)
1564
+ expect(leadPreToolUse.length).toBe(scoutPreToolUse.length + 1);
1565
+
1566
+ // The extra guard is a Bash matcher referencing the close-gate
1567
+ const closeGate = leadPreToolUse.find(
1568
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1569
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("merge_ready gate"),
1570
+ );
1571
+ expect(closeGate).toBeDefined();
1572
+ });
1573
+
1574
+ test("builder and merger have identical guard structures", async () => {
1575
+ const builderPath = join(tempDir, "builder-wt");
1576
+ const mergerPath = join(tempDir, "merger-wt");
1577
+
1578
+ await deployHooks(builderPath, "builder-1", "builder");
1579
+ await deployHooks(mergerPath, "merger-1", "merger");
1580
+
1581
+ const builderContent = await Bun.file(
1582
+ join(builderPath, ".claude", "settings.local.json"),
1583
+ ).text();
1584
+ const mergerContent = await Bun.file(join(mergerPath, ".claude", "settings.local.json")).text();
1585
+
1586
+ const builderPreToolUse = JSON.parse(builderContent).hooks.PreToolUse;
1587
+ const mergerPreToolUse = JSON.parse(mergerContent).hooks.PreToolUse;
1588
+
1589
+ // Same number of guards
1590
+ expect(builderPreToolUse.length).toBe(mergerPreToolUse.length);
1591
+
1592
+ // Same matchers
1593
+ const builderMatchers = builderPreToolUse.map((h: { matcher: string }) => h.matcher);
1594
+ const mergerMatchers = mergerPreToolUse.map((h: { matcher: string }) => h.matcher);
1595
+ expect(builderMatchers).toEqual(mergerMatchers);
1596
+ });
1597
+
1598
+ test("all deployed configs produce valid JSON", async () => {
1599
+ const capabilities = [
1600
+ "scout",
1601
+ "reviewer",
1602
+ "lead",
1603
+ "builder",
1604
+ "merger",
1605
+ "coordinator",
1606
+ "supervisor",
1607
+ ];
1608
+
1609
+ for (const cap of capabilities) {
1610
+ const wt = join(tempDir, `${cap}-wt`);
1611
+ await deployHooks(wt, `${cap}-agent`, cap);
1612
+
1613
+ const content = await Bun.file(join(wt, ".claude", "settings.local.json")).text();
1614
+ expect(() => JSON.parse(content)).not.toThrow();
1615
+ }
1616
+ });
1617
+
1618
+ test("coordinator bash guard whitelists git add and git commit", async () => {
1619
+ const worktreePath = join(tempDir, "coord-wt");
1620
+
1621
+ await deployHooks(worktreePath, "coordinator-agent", "coordinator");
1622
+
1623
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1624
+ const content = await Bun.file(outputPath).text();
1625
+ const parsed = JSON.parse(content);
1626
+ const preToolUse = parsed.hooks.PreToolUse;
1627
+
1628
+ // Find the bash file guard (the second Bash entry, after the danger guard)
1629
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
1630
+ expect(bashGuards.length).toBe(4);
1631
+
1632
+ // The file guard (second Bash guard) should whitelist git add/commit
1633
+ const fileGuard = bashGuards[1];
1634
+ expect(fileGuard.hooks[0].command).toContain("git add");
1635
+ expect(fileGuard.hooks[0].command).toContain("git commit");
1636
+ });
1637
+
1638
+ test("scout bash guard does NOT whitelist git add/commit", async () => {
1639
+ const worktreePath = join(tempDir, "scout-git-wt");
1640
+
1641
+ await deployHooks(worktreePath, "scout-git", "scout");
1642
+
1643
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1644
+ const content = await Bun.file(outputPath).text();
1645
+ const parsed = JSON.parse(content);
1646
+ const preToolUse = parsed.hooks.PreToolUse;
1647
+
1648
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
1649
+ const fileGuard = bashGuards[1];
1650
+
1651
+ // The safe prefix section should not include git add or git commit for scouts
1652
+ const command = fileGuard.hooks[0].command;
1653
+ const safePrefixSection = command.split("grep -qE '")[0] ?? "";
1654
+ expect(safePrefixSection).not.toContain("'^\\s*git add'");
1655
+ expect(safePrefixSection).not.toContain("'^\\s*git commit'");
1656
+ });
1657
+
1658
+ test("coordinator and supervisor have same guard structure", async () => {
1659
+ const coordPath = join(tempDir, "coord-wt");
1660
+ const supPath = join(tempDir, "sup-wt");
1661
+
1662
+ await deployHooks(coordPath, "coord-1", "coordinator");
1663
+ await deployHooks(supPath, "sup-1", "supervisor");
1664
+
1665
+ const coordContent = await Bun.file(join(coordPath, ".claude", "settings.local.json")).text();
1666
+ const supContent = await Bun.file(join(supPath, ".claude", "settings.local.json")).text();
1667
+
1668
+ const coordPreToolUse = JSON.parse(coordContent).hooks.PreToolUse;
1669
+ const supPreToolUse = JSON.parse(supContent).hooks.PreToolUse;
1670
+
1671
+ // Same number of guards
1672
+ expect(coordPreToolUse.length).toBe(supPreToolUse.length);
1673
+
1674
+ // Same matchers
1675
+ const coordMatchers = coordPreToolUse.map((h: { matcher: string }) => h.matcher);
1676
+ const supMatchers = supPreToolUse.map((h: { matcher: string }) => h.matcher);
1677
+ expect(coordMatchers).toEqual(supMatchers);
1678
+ });
1679
+
1680
+ test("all template hooks include ENV_GUARD for project root isolation", async () => {
1681
+ const worktreePath = join(tempDir, "env-guard-tmpl-wt");
1682
+
1683
+ await deployHooks(worktreePath, "env-guard-agent", "builder");
1684
+
1685
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1686
+ const content = await Bun.file(outputPath).text();
1687
+ const parsed = JSON.parse(content);
1688
+
1689
+ // All hook types from the template should include ENV_GUARD
1690
+ for (const hookType of [
1691
+ "SessionStart",
1692
+ "UserPromptSubmit",
1693
+ "PreCompact",
1694
+ "PostToolUse",
1695
+ "Stop",
1696
+ ]) {
1697
+ const hooks = parsed.hooks[hookType] as Array<{
1698
+ matcher: string;
1699
+ hooks: Array<{ command: string }>;
1700
+ }>;
1701
+ expect(hooks.length).toBeGreaterThan(0);
1702
+ const baseHook = hooks.find((h) => h.matcher === "");
1703
+ expect(baseHook).toBeDefined();
1704
+ expect(baseHook?.hooks[0]?.command).toContain("AGENTPLATE_AGENT_NAME");
1705
+ }
1706
+
1707
+ // PreToolUse base hook (matcher == "") should also have ENV_GUARD
1708
+ const preToolUse = parsed.hooks.PreToolUse as Array<{
1709
+ matcher: string;
1710
+ hooks: Array<{ command: string }>;
1711
+ }>;
1712
+ const basePreToolUse = preToolUse.find((h) => h.matcher === "");
1713
+ expect(basePreToolUse).toBeDefined();
1714
+ expect(basePreToolUse?.hooks[0]?.command).toContain("AGENTPLATE_AGENT_NAME");
1715
+ });
1716
+
1717
+ test("all deployed hook commands include env var guard for project root isolation", async () => {
1718
+ const worktreePath = join(tempDir, "coord-env-wt");
1719
+
1720
+ await deployHooks(worktreePath, "coordinator-env", "coordinator");
1721
+
1722
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1723
+ const content = await Bun.file(outputPath).text();
1724
+ const parsed = JSON.parse(content);
1725
+ const preToolUse = parsed.hooks.PreToolUse as Array<{
1726
+ matcher: string;
1727
+ hooks: Array<{ type: string; command: string }>;
1728
+ }>;
1729
+
1730
+ // All guard entries (non-empty matchers, i.e. generated by getDangerGuards/getCapabilityGuards)
1731
+ // must include the env var guard so hooks deployed to project root only activate for agents
1732
+ const guardEntries = preToolUse.filter((entry) => entry.matcher !== "");
1733
+ expect(guardEntries.length).toBeGreaterThan(0);
1734
+
1735
+ for (const entry of guardEntries) {
1736
+ for (const hook of entry.hooks) {
1737
+ if (hook.type === "command") {
1738
+ // Skip the universal git push guard which intentionally lacks ENV_GUARD
1739
+ if (
1740
+ entry.matcher === "Bash" &&
1741
+ hook.command.includes("git push is blocked") &&
1742
+ !hook.command.includes("AGENTPLATE_AGENT_NAME")
1743
+ ) {
1744
+ continue;
1745
+ }
1746
+ expect(hook.command).toContain("AGENTPLATE_AGENT_NAME");
1747
+ }
1748
+ }
1749
+ }
1750
+ });
1751
+
1752
+ test("all capabilities block Task tool for agentplate sling enforcement", async () => {
1753
+ const capabilities = [
1754
+ "scout",
1755
+ "reviewer",
1756
+ "lead",
1757
+ "builder",
1758
+ "merger",
1759
+ "coordinator",
1760
+ "supervisor",
1761
+ ];
1762
+
1763
+ for (const cap of capabilities) {
1764
+ const wt = join(tempDir, `${cap}-task-wt`);
1765
+ await deployHooks(wt, `${cap}-agent`, cap);
1766
+
1767
+ const content = await Bun.file(join(wt, ".claude", "settings.local.json")).text();
1768
+ const parsed = JSON.parse(content);
1769
+ const preToolUse = parsed.hooks.PreToolUse;
1770
+
1771
+ const taskGuard = preToolUse.find((h: { matcher: string }) => h.matcher === "Task");
1772
+ expect(taskGuard).toBeDefined();
1773
+ expect(taskGuard.hooks[0].command).toContain("ap sling");
1774
+ }
1775
+ });
1776
+
1777
+ test("all capabilities get path boundary guards in deployed hooks", async () => {
1778
+ const capabilities = [
1779
+ "scout",
1780
+ "reviewer",
1781
+ "lead",
1782
+ "builder",
1783
+ "merger",
1784
+ "coordinator",
1785
+ "supervisor",
1786
+ ];
1787
+
1788
+ for (const cap of capabilities) {
1789
+ const wt = join(tempDir, `${cap}-path-wt`);
1790
+ await deployHooks(wt, `${cap}-agent`, cap);
1791
+
1792
+ const content = await Bun.file(join(wt, ".claude", "settings.local.json")).text();
1793
+ const parsed = JSON.parse(content);
1794
+ const preToolUse = parsed.hooks.PreToolUse;
1795
+
1796
+ // Path boundary guards should be present for Write, Edit, NotebookEdit
1797
+ // They use AGENTPLATE_WORKTREE_PATH env var
1798
+ const writeGuards = preToolUse.filter(
1799
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1800
+ h.matcher === "Write" && h.hooks[0]?.command?.includes("AGENTPLATE_WORKTREE_PATH"),
1801
+ );
1802
+ const editGuards = preToolUse.filter(
1803
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1804
+ h.matcher === "Edit" && h.hooks[0]?.command?.includes("AGENTPLATE_WORKTREE_PATH"),
1805
+ );
1806
+ const notebookGuards = preToolUse.filter(
1807
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1808
+ h.matcher === "NotebookEdit" && h.hooks[0]?.command?.includes("AGENTPLATE_WORKTREE_PATH"),
1809
+ );
1810
+
1811
+ expect(writeGuards.length).toBeGreaterThanOrEqual(1);
1812
+ expect(editGuards.length).toBeGreaterThanOrEqual(1);
1813
+ expect(notebookGuards.length).toBeGreaterThanOrEqual(1);
1814
+ }
1815
+ });
1816
+
1817
+ test("path boundary guards appear before danger guards in deployed hooks", async () => {
1818
+ const worktreePath = join(tempDir, "order-path-wt");
1819
+
1820
+ await deployHooks(worktreePath, "order-path-agent", "builder");
1821
+
1822
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1823
+ const content = await Bun.file(outputPath).text();
1824
+ const parsed = JSON.parse(content);
1825
+ const preToolUse = parsed.hooks.PreToolUse;
1826
+
1827
+ // Path boundary Write guard should come before Bash danger guard
1828
+ const pathWriteIdx = preToolUse.findIndex(
1829
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1830
+ h.matcher === "Write" && h.hooks[0]?.command?.includes("AGENTPLATE_WORKTREE_PATH"),
1831
+ );
1832
+ const bashDangerIdx = preToolUse.findIndex(
1833
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1834
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("git"),
1835
+ );
1836
+
1837
+ expect(pathWriteIdx).toBeGreaterThanOrEqual(0);
1838
+ expect(bashDangerIdx).toBeGreaterThanOrEqual(0);
1839
+ expect(pathWriteIdx).toBeLessThan(bashDangerIdx);
1840
+ });
1841
+ });
1842
+
1843
+ describe("buildPathBoundaryGuardScript", () => {
1844
+ test("returns a string containing env var guard", () => {
1845
+ const script = buildPathBoundaryGuardScript("file_path");
1846
+ expect(script).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
1847
+ });
1848
+
1849
+ test("returns a string checking AGENTPLATE_WORKTREE_PATH", () => {
1850
+ const script = buildPathBoundaryGuardScript("file_path");
1851
+ expect(script).toContain('[ -z "$AGENTPLATE_WORKTREE_PATH" ] && exit 0;');
1852
+ });
1853
+
1854
+ test("reads stdin input", () => {
1855
+ const script = buildPathBoundaryGuardScript("file_path");
1856
+ expect(script).toContain("read -r INPUT");
1857
+ });
1858
+
1859
+ test("extracts the specified field name from JSON", () => {
1860
+ const script = buildPathBoundaryGuardScript("file_path");
1861
+ expect(script).toContain('"file_path"');
1862
+
1863
+ const notebookScript = buildPathBoundaryGuardScript("notebook_path");
1864
+ expect(notebookScript).toContain('"notebook_path"');
1865
+ });
1866
+
1867
+ test("resolves relative paths against cwd", () => {
1868
+ const script = buildPathBoundaryGuardScript("file_path");
1869
+ expect(script).toContain("$(pwd)");
1870
+ });
1871
+
1872
+ test("allows paths inside the worktree", () => {
1873
+ const script = buildPathBoundaryGuardScript("file_path");
1874
+ expect(script).toContain('"$AGENTPLATE_WORKTREE_PATH"/*) exit 0');
1875
+ });
1876
+
1877
+ test("blocks paths outside the worktree with decision:block", () => {
1878
+ const script = buildPathBoundaryGuardScript("file_path");
1879
+ expect(script).toContain('"decision":"block"');
1880
+ expect(script).toContain("Path boundary violation");
1881
+ });
1882
+
1883
+ test("fails open when path field is empty", () => {
1884
+ const script = buildPathBoundaryGuardScript("file_path");
1885
+ expect(script).toContain('[ -z "$FILE_PATH" ] && exit 0;');
1886
+ });
1887
+ });
1888
+
1889
+ describe("getPathBoundaryGuards", () => {
1890
+ test("returns exactly 3 guards (Write, Edit, NotebookEdit)", () => {
1891
+ const guards = getPathBoundaryGuards();
1892
+ expect(guards).toHaveLength(3);
1893
+ });
1894
+
1895
+ test("guards match Write, Edit, and NotebookEdit tools", () => {
1896
+ const guards = getPathBoundaryGuards();
1897
+ const matchers = guards.map((g) => g.matcher);
1898
+ expect(matchers).toContain("Write");
1899
+ expect(matchers).toContain("Edit");
1900
+ expect(matchers).toContain("NotebookEdit");
1901
+ });
1902
+
1903
+ test("Write and Edit guards extract file_path field", () => {
1904
+ const guards = getPathBoundaryGuards();
1905
+ const writeGuard = guards.find((g) => g.matcher === "Write");
1906
+ const editGuard = guards.find((g) => g.matcher === "Edit");
1907
+ expect(writeGuard?.hooks[0]?.command).toContain('"file_path"');
1908
+ expect(editGuard?.hooks[0]?.command).toContain('"file_path"');
1909
+ });
1910
+
1911
+ test("NotebookEdit guard extracts notebook_path field", () => {
1912
+ const guards = getPathBoundaryGuards();
1913
+ const notebookGuard = guards.find((g) => g.matcher === "NotebookEdit");
1914
+ expect(notebookGuard?.hooks[0]?.command).toContain('"notebook_path"');
1915
+ });
1916
+
1917
+ test("all guards include AGENTPLATE_WORKTREE_PATH check", () => {
1918
+ const guards = getPathBoundaryGuards();
1919
+ for (const guard of guards) {
1920
+ expect(guard.hooks[0]?.command).toContain("AGENTPLATE_WORKTREE_PATH");
1921
+ }
1922
+ });
1923
+
1924
+ test("all guards have command type hooks", () => {
1925
+ const guards = getPathBoundaryGuards();
1926
+ for (const guard of guards) {
1927
+ expect(guard.hooks[0]?.type).toBe("command");
1928
+ }
1929
+ });
1930
+ });
1931
+
1932
+ describe("buildBashPathBoundaryScript", () => {
1933
+ test("returns a string containing env var guard", () => {
1934
+ const script = buildBashPathBoundaryScript();
1935
+ expect(script).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
1936
+ });
1937
+
1938
+ test("checks AGENTPLATE_WORKTREE_PATH env var", () => {
1939
+ const script = buildBashPathBoundaryScript();
1940
+ expect(script).toContain('[ -z "$AGENTPLATE_WORKTREE_PATH" ] && exit 0;');
1941
+ });
1942
+
1943
+ test("reads stdin input", () => {
1944
+ const script = buildBashPathBoundaryScript();
1945
+ expect(script).toContain("read -r INPUT");
1946
+ });
1947
+
1948
+ test("extracts command from JSON input", () => {
1949
+ const script = buildBashPathBoundaryScript();
1950
+ expect(script).toContain("CMD=$(");
1951
+ expect(script).toContain('"command"');
1952
+ });
1953
+
1954
+ test("checks for file-modifying patterns before path extraction", () => {
1955
+ const script = buildBashPathBoundaryScript();
1956
+ // Should check for file-modifying patterns first
1957
+ expect(script).toContain("grep -qE");
1958
+ expect(script).toContain("sed\\s+-i");
1959
+ expect(script).toContain("\\bmv\\s");
1960
+ expect(script).toContain("\\bcp\\s");
1961
+ expect(script).toContain("\\brm\\s");
1962
+ expect(script).toContain("tee\\s");
1963
+ expect(script).toContain("\\brsync\\s");
1964
+ expect(script).toContain("\\binstall\\s");
1965
+ });
1966
+
1967
+ test("includes common file-modifying patterns", () => {
1968
+ const script = buildBashPathBoundaryScript();
1969
+ expect(script).toContain("sed\\s+-i");
1970
+ expect(script).toContain("sed\\s+--in-place");
1971
+ expect(script).toContain("echo\\s+.*>");
1972
+ expect(script).toContain("printf\\s+.*>");
1973
+ expect(script).toContain("cat\\s+.*>");
1974
+ expect(script).toContain("tee\\s");
1975
+ expect(script).toContain("\\bmv\\s");
1976
+ expect(script).toContain("\\bcp\\s");
1977
+ expect(script).toContain("\\brm\\s");
1978
+ expect(script).toContain("\\bmkdir\\s");
1979
+ expect(script).toContain("\\btouch\\s");
1980
+ expect(script).toContain("\\bchmod\\s");
1981
+ expect(script).toContain("\\bchown\\s");
1982
+ expect(script).toContain(">>");
1983
+ expect(script).toContain("\\binstall\\s");
1984
+ expect(script).toContain("\\brsync\\s");
1985
+ });
1986
+
1987
+ test("passes through non-file-modifying commands", () => {
1988
+ const script = buildBashPathBoundaryScript();
1989
+ // Non-modifying commands should hit the early exit
1990
+ expect(script).toContain("exit 0; fi;");
1991
+ });
1992
+
1993
+ test("extracts absolute paths from command", () => {
1994
+ const script = buildBashPathBoundaryScript();
1995
+ // Should extract tokens starting with /
1996
+ expect(script).toContain("grep '^/'");
1997
+ });
1998
+
1999
+ test("allows commands with no absolute paths (relative paths OK)", () => {
2000
+ const script = buildBashPathBoundaryScript();
2001
+ expect(script).toContain('[ -z "$PATHS" ] && exit 0;');
2002
+ });
2003
+
2004
+ test("validates paths against worktree boundary", () => {
2005
+ const script = buildBashPathBoundaryScript();
2006
+ expect(script).toContain('"$AGENTPLATE_WORKTREE_PATH"/*');
2007
+ expect(script).toContain('"$AGENTPLATE_WORKTREE_PATH")');
2008
+ });
2009
+
2010
+ test("allows /dev/* paths as safe exceptions", () => {
2011
+ const script = buildBashPathBoundaryScript();
2012
+ expect(script).toContain("/dev/*");
2013
+ });
2014
+
2015
+ test("allows /tmp/* paths as safe exceptions", () => {
2016
+ const script = buildBashPathBoundaryScript();
2017
+ expect(script).toContain("/tmp/*");
2018
+ });
2019
+
2020
+ test("blocks paths outside worktree with decision:block", () => {
2021
+ const script = buildBashPathBoundaryScript();
2022
+ expect(script).toContain('"decision":"block"');
2023
+ expect(script).toContain("Bash path boundary violation");
2024
+ expect(script).toContain("outside your worktree");
2025
+ });
2026
+
2027
+ test("iterates over extracted paths with while loop", () => {
2028
+ const script = buildBashPathBoundaryScript();
2029
+ expect(script).toContain("while IFS= read -r P; do");
2030
+ expect(script).toContain("done;");
2031
+ });
2032
+
2033
+ test("strips trailing quotes and semicolons from extracted paths", () => {
2034
+ const script = buildBashPathBoundaryScript();
2035
+ // sed should strip trailing junk from path tokens
2036
+ expect(script).toContain("sed 's/[\";>]*$//'");
2037
+ });
2038
+ });
2039
+
2040
+ describe("getBashPathBoundaryGuards", () => {
2041
+ test("returns exactly 1 Bash guard entry", () => {
2042
+ const guards = getBashPathBoundaryGuards();
2043
+ expect(guards).toHaveLength(1);
2044
+ expect(guards[0]?.matcher).toBe("Bash");
2045
+ });
2046
+
2047
+ test("guard hook type is command", () => {
2048
+ const guards = getBashPathBoundaryGuards();
2049
+ expect(guards[0]?.hooks[0]?.type).toBe("command");
2050
+ });
2051
+
2052
+ test("guard command checks AGENTPLATE_WORKTREE_PATH", () => {
2053
+ const guards = getBashPathBoundaryGuards();
2054
+ const command = guards[0]?.hooks[0]?.command ?? "";
2055
+ expect(command).toContain("AGENTPLATE_WORKTREE_PATH");
2056
+ });
2057
+
2058
+ test("guard command includes env var guard prefix", () => {
2059
+ const guards = getBashPathBoundaryGuards();
2060
+ const command = guards[0]?.hooks[0]?.command ?? "";
2061
+ expect(command).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
2062
+ });
2063
+
2064
+ test("guard blocks paths outside worktree", () => {
2065
+ const guards = getBashPathBoundaryGuards();
2066
+ const command = guards[0]?.hooks[0]?.command ?? "";
2067
+ expect(command).toContain("Bash path boundary violation");
2068
+ });
2069
+ });
2070
+
2071
+ describe("bash path boundary integration", () => {
2072
+ let tempDir: string;
2073
+
2074
+ beforeEach(async () => {
2075
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-bash-path-test-"));
2076
+ });
2077
+
2078
+ afterEach(async () => {
2079
+ await cleanupTempDir(tempDir);
2080
+ });
2081
+
2082
+ test("builder gets Bash path boundary guard in deployed hooks", async () => {
2083
+ const worktreePath = join(tempDir, "builder-bp-wt");
2084
+
2085
+ await deployHooks(worktreePath, "builder-bp", "builder");
2086
+
2087
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
2088
+ const content = await Bun.file(outputPath).text();
2089
+ const parsed = JSON.parse(content);
2090
+ const preToolUse = parsed.hooks.PreToolUse;
2091
+
2092
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
2093
+ // Should have 4 Bash guards: danger guard + path boundary guard + tracker close guard + universal push guard
2094
+ expect(bashGuards.length).toBe(4);
2095
+
2096
+ // Find the path boundary guard
2097
+ const pathGuard = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
2098
+ h.hooks[0]?.command?.includes("Bash path boundary violation"),
2099
+ );
2100
+ expect(pathGuard).toBeDefined();
2101
+ expect(pathGuard.hooks[0].command).toContain("AGENTPLATE_WORKTREE_PATH");
2102
+ });
2103
+
2104
+ test("merger gets Bash path boundary guard in deployed hooks", async () => {
2105
+ const worktreePath = join(tempDir, "merger-bp-wt");
2106
+
2107
+ await deployHooks(worktreePath, "merger-bp", "merger");
2108
+
2109
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
2110
+ const content = await Bun.file(outputPath).text();
2111
+ const parsed = JSON.parse(content);
2112
+ const preToolUse = parsed.hooks.PreToolUse;
2113
+
2114
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
2115
+ expect(bashGuards.length).toBe(4);
2116
+
2117
+ const pathGuard = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
2118
+ h.hooks[0]?.command?.includes("Bash path boundary violation"),
2119
+ );
2120
+ expect(pathGuard).toBeDefined();
2121
+ });
2122
+
2123
+ test("scout does NOT get Bash path boundary guard", async () => {
2124
+ const worktreePath = join(tempDir, "scout-bp-wt");
2125
+
2126
+ await deployHooks(worktreePath, "scout-bp", "scout");
2127
+
2128
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
2129
+ const content = await Bun.file(outputPath).text();
2130
+ const parsed = JSON.parse(content);
2131
+ const preToolUse = parsed.hooks.PreToolUse;
2132
+
2133
+ // Scout gets danger guard + file guard + tracker close guard + universal push guard (4 Bash guards), but NOT path boundary
2134
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
2135
+ expect(bashGuards.length).toBe(4);
2136
+
2137
+ const pathGuard = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
2138
+ h.hooks[0]?.command?.includes("Bash path boundary violation"),
2139
+ );
2140
+ expect(pathGuard).toBeUndefined();
2141
+ });
2142
+
2143
+ test("reviewer does NOT get Bash path boundary guard", async () => {
2144
+ const worktreePath = join(tempDir, "reviewer-bp-wt");
2145
+
2146
+ await deployHooks(worktreePath, "reviewer-bp", "reviewer");
2147
+
2148
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
2149
+ const content = await Bun.file(outputPath).text();
2150
+ const parsed = JSON.parse(content);
2151
+ const preToolUse = parsed.hooks.PreToolUse;
2152
+
2153
+ const pathGuard = preToolUse.find(
2154
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2155
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("Bash path boundary violation"),
2156
+ );
2157
+ expect(pathGuard).toBeUndefined();
2158
+ });
2159
+
2160
+ test("lead does NOT get Bash path boundary guard", async () => {
2161
+ const worktreePath = join(tempDir, "lead-bp-wt");
2162
+
2163
+ await deployHooks(worktreePath, "lead-bp", "lead");
2164
+
2165
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
2166
+ const content = await Bun.file(outputPath).text();
2167
+ const parsed = JSON.parse(content);
2168
+ const preToolUse = parsed.hooks.PreToolUse;
2169
+
2170
+ const pathGuard = preToolUse.find(
2171
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2172
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("Bash path boundary violation"),
2173
+ );
2174
+ expect(pathGuard).toBeUndefined();
2175
+ });
2176
+
2177
+ test("Bash path boundary guard appears after danger guard in builder", async () => {
2178
+ const worktreePath = join(tempDir, "builder-order-wt");
2179
+
2180
+ await deployHooks(worktreePath, "builder-order", "builder");
2181
+
2182
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
2183
+ const content = await Bun.file(outputPath).text();
2184
+ const parsed = JSON.parse(content);
2185
+ const preToolUse = parsed.hooks.PreToolUse;
2186
+
2187
+ const dangerIdx = preToolUse.findIndex(
2188
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2189
+ h.matcher === "Bash" &&
2190
+ h.hooks[0]?.command?.includes("git") &&
2191
+ h.hooks[0]?.command?.includes("push"),
2192
+ );
2193
+ const pathBoundaryIdx = preToolUse.findIndex(
2194
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2195
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("Bash path boundary violation"),
2196
+ );
2197
+
2198
+ expect(dangerIdx).toBeGreaterThanOrEqual(0);
2199
+ expect(pathBoundaryIdx).toBeGreaterThanOrEqual(0);
2200
+ // Danger guard comes from getDangerGuards, path boundary from getCapabilityGuards
2201
+ // In deployHooks: allGuards = [...pathGuards, ...dangerGuards, ...capabilityGuards]
2202
+ // So danger guard comes before path boundary guard
2203
+ expect(dangerIdx).toBeLessThan(pathBoundaryIdx);
2204
+ });
2205
+
2206
+ test("default capability (builder) gets Bash path boundary guard", async () => {
2207
+ const worktreePath = join(tempDir, "default-bp-wt");
2208
+
2209
+ await deployHooks(worktreePath, "default-bp");
2210
+
2211
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
2212
+ const content = await Bun.file(outputPath).text();
2213
+ const parsed = JSON.parse(content);
2214
+ const preToolUse = parsed.hooks.PreToolUse;
2215
+
2216
+ const pathGuard = preToolUse.find(
2217
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2218
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("Bash path boundary violation"),
2219
+ );
2220
+ expect(pathGuard).toBeDefined();
2221
+ });
2222
+
2223
+ test("deployed hooks include universal git push guard without ENV_GUARD", async () => {
2224
+ const worktreePath = join(tempDir, "universal-push-wt");
2225
+
2226
+ await deployHooks(worktreePath, "universal-push-agent", "builder");
2227
+
2228
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
2229
+ const content = await Bun.file(outputPath).text();
2230
+ const parsed = JSON.parse(content);
2231
+ const preToolUse = parsed.hooks.PreToolUse;
2232
+
2233
+ // Find the universal git push guard: Bash matcher, blocks git push, no ENV_GUARD
2234
+ const universalGuard = preToolUse.find(
2235
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2236
+ h.matcher === "Bash" &&
2237
+ h.hooks[0]?.command?.includes("git push is blocked") &&
2238
+ !h.hooks[0]?.command?.includes("AGENTPLATE_AGENT_NAME"),
2239
+ );
2240
+ expect(universalGuard).toBeDefined();
2241
+ expect(universalGuard.hooks[0].command).toContain('"decision":"block"');
2242
+ });
2243
+ });
2244
+
2245
+ describe("PATH_PREFIX", () => {
2246
+ test("PATH_PREFIX is exported and is a non-empty string", () => {
2247
+ expect(typeof PATH_PREFIX).toBe("string");
2248
+ expect(PATH_PREFIX.length).toBeGreaterThan(0);
2249
+ });
2250
+
2251
+ test("PATH_PREFIX contains ~/.bun/bin for bun-installed CLIs", () => {
2252
+ expect(PATH_PREFIX).toContain(".bun/bin");
2253
+ });
2254
+
2255
+ test("PATH_PREFIX extends PATH (not replaces it)", () => {
2256
+ // Must preserve original PATH via :$PATH
2257
+ expect(PATH_PREFIX).toContain(":$PATH");
2258
+ });
2259
+
2260
+ test("PATH_PREFIX sets PATH via export", () => {
2261
+ expect(PATH_PREFIX).toMatch(/^export PATH=/);
2262
+ });
2263
+ });
2264
+
2265
+ describe("PATH prefix in deployed hooks", () => {
2266
+ let tempDir: string;
2267
+
2268
+ beforeEach(async () => {
2269
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-path-prefix-test-"));
2270
+ });
2271
+
2272
+ afterEach(async () => {
2273
+ await cleanupTempDir(tempDir);
2274
+ });
2275
+
2276
+ test("SessionStart hook commands include PATH prefix", async () => {
2277
+ const worktreePath = join(tempDir, "path-ss-wt");
2278
+ await deployHooks(worktreePath, "path-agent");
2279
+
2280
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2281
+ const parsed = JSON.parse(content);
2282
+ for (const entry of parsed.hooks.SessionStart) {
2283
+ for (const hook of entry.hooks) {
2284
+ expect(hook.command).toContain("export PATH=");
2285
+ expect(hook.command).toContain(".bun/bin");
2286
+ }
2287
+ }
2288
+ });
2289
+
2290
+ test("UserPromptSubmit hook commands include PATH prefix", async () => {
2291
+ const worktreePath = join(tempDir, "path-ups-wt");
2292
+ await deployHooks(worktreePath, "path-agent");
2293
+
2294
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2295
+ const parsed = JSON.parse(content);
2296
+ for (const entry of parsed.hooks.UserPromptSubmit) {
2297
+ for (const hook of entry.hooks) {
2298
+ expect(hook.command).toContain("export PATH=");
2299
+ }
2300
+ }
2301
+ });
2302
+
2303
+ test("PostToolUse hook commands include PATH prefix", async () => {
2304
+ const worktreePath = join(tempDir, "path-ptu-wt");
2305
+ await deployHooks(worktreePath, "path-agent");
2306
+
2307
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2308
+ const parsed = JSON.parse(content);
2309
+ for (const entry of parsed.hooks.PostToolUse) {
2310
+ for (const hook of entry.hooks) {
2311
+ expect(hook.command).toContain("export PATH=");
2312
+ }
2313
+ }
2314
+ });
2315
+
2316
+ test("Stop hook commands include PATH prefix", async () => {
2317
+ const worktreePath = join(tempDir, "path-stop-wt");
2318
+ await deployHooks(worktreePath, "path-agent");
2319
+
2320
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2321
+ const parsed = JSON.parse(content);
2322
+ for (const entry of parsed.hooks.Stop) {
2323
+ for (const hook of entry.hooks) {
2324
+ expect(hook.command).toContain("export PATH=");
2325
+ expect(hook.command).toContain(".bun/bin");
2326
+ }
2327
+ }
2328
+ });
2329
+
2330
+ test("PreCompact hook commands include PATH prefix", async () => {
2331
+ const worktreePath = join(tempDir, "path-pc-wt");
2332
+ await deployHooks(worktreePath, "path-agent");
2333
+
2334
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2335
+ const parsed = JSON.parse(content);
2336
+ for (const entry of parsed.hooks.PreCompact) {
2337
+ for (const hook of entry.hooks) {
2338
+ expect(hook.command).toContain("export PATH=");
2339
+ }
2340
+ }
2341
+ });
2342
+
2343
+ test("PATH prefix appears before CLI command in SessionStart", async () => {
2344
+ const worktreePath = join(tempDir, "path-order-wt");
2345
+ await deployHooks(worktreePath, "path-order-agent");
2346
+
2347
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2348
+ const parsed = JSON.parse(content);
2349
+ const cmd = parsed.hooks.SessionStart[0].hooks[0].command as string;
2350
+ // PATH export must come before the CLI invocation
2351
+ const pathIdx = cmd.indexOf("export PATH=");
2352
+ const ovIdx = cmd.indexOf("ap prime");
2353
+ expect(pathIdx).toBeGreaterThanOrEqual(0);
2354
+ expect(ovIdx).toBeGreaterThan(pathIdx);
2355
+ });
2356
+
2357
+ test("PATH prefix appears before lm learn in Stop hook", async () => {
2358
+ const worktreePath = join(tempDir, "path-lm-wt");
2359
+ await deployHooks(worktreePath, "path-lm-agent");
2360
+
2361
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2362
+ const parsed = JSON.parse(content);
2363
+ const stopHooks = parsed.hooks.Stop[0].hooks;
2364
+ // Second Stop hook is "lm learn"
2365
+ const mlCmd = stopHooks[1].command as string;
2366
+ const pathIdx = mlCmd.indexOf("export PATH=");
2367
+ const mlIdx = mlCmd.indexOf("lm learn");
2368
+ expect(pathIdx).toBeGreaterThanOrEqual(0);
2369
+ expect(mlIdx).toBeGreaterThan(pathIdx);
2370
+ });
2371
+
2372
+ test("generated guard commands do NOT have PATH prefix (they use only built-ins)", async () => {
2373
+ const worktreePath = join(tempDir, "path-guards-wt");
2374
+ await deployHooks(worktreePath, "path-guards-agent", "builder");
2375
+
2376
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2377
+ const parsed = JSON.parse(content);
2378
+ const preToolUse = parsed.hooks.PreToolUse;
2379
+
2380
+ // Path boundary guards (Write/Edit/NotebookEdit) are generated — no PATH prefix
2381
+ const writeGuard = preToolUse.find(
2382
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2383
+ h.matcher === "Write" && h.hooks[0]?.command?.includes("AGENTPLATE_WORKTREE_PATH"),
2384
+ );
2385
+ expect(writeGuard).toBeDefined();
2386
+ expect(writeGuard.hooks[0].command).not.toContain("export PATH=");
2387
+
2388
+ // Danger guard (generated) — no PATH prefix
2389
+ const dangerGuard = preToolUse.find(
2390
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2391
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("git reset --hard"),
2392
+ );
2393
+ expect(dangerGuard).toBeDefined();
2394
+ expect(dangerGuard.hooks[0].command).not.toContain("export PATH=");
2395
+ });
2396
+
2397
+ test("re-deployment is idempotent: PATH prefix not duplicated", async () => {
2398
+ const worktreePath = join(tempDir, "path-idem-wt");
2399
+
2400
+ await deployHooks(worktreePath, "path-idem-agent");
2401
+ await deployHooks(worktreePath, "path-idem-agent");
2402
+
2403
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2404
+ const parsed = JSON.parse(content);
2405
+ const cmd = parsed.hooks.SessionStart[0].hooks[0].command as string;
2406
+
2407
+ // PATH prefix should appear exactly once, not doubled
2408
+ const occurrences = cmd.split("export PATH=").length - 1;
2409
+ expect(occurrences).toBe(1);
2410
+ });
2411
+
2412
+ test("PATH prefix uses $HOME expansion (not hardcoded path)", async () => {
2413
+ const worktreePath = join(tempDir, "path-home-wt");
2414
+ await deployHooks(worktreePath, "home-agent");
2415
+
2416
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2417
+ const parsed = JSON.parse(content);
2418
+ const cmd = parsed.hooks.SessionStart[0].hooks[0].command as string;
2419
+ // Should use $HOME not a hardcoded path like /Users/...
2420
+ expect(cmd).toContain("$HOME");
2421
+ });
2422
+ });
2423
+
2424
+ describe("buildTrackerCloseGuardScript", () => {
2425
+ test("returns a string containing key patterns", () => {
2426
+ const script = buildTrackerCloseGuardScript();
2427
+ expect(typeof script).toBe("string");
2428
+ expect(script.length).toBeGreaterThan(0);
2429
+ expect(script).toContain("sr");
2430
+ expect(script).toContain("bd");
2431
+ expect(script).toContain("close");
2432
+ expect(script).toContain("update");
2433
+ });
2434
+
2435
+ test("contains ENV_GUARD prefix", () => {
2436
+ const script = buildTrackerCloseGuardScript();
2437
+ expect(script).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
2438
+ });
2439
+
2440
+ test("contains AGENTPLATE_TASK_ID early-exit check", () => {
2441
+ const script = buildTrackerCloseGuardScript();
2442
+ expect(script).toContain('[ -z "$AGENTPLATE_TASK_ID" ] && exit 0;');
2443
+ });
2444
+
2445
+ test("blocks sr close with wrong ID", async () => {
2446
+ const script = buildTrackerCloseGuardScript();
2447
+ const input = JSON.stringify({ command: "sr close other-task" });
2448
+ const proc = Bun.spawn(["sh", "-c", script], {
2449
+ stdin: new TextEncoder().encode(input),
2450
+ stdout: "pipe",
2451
+ stderr: "pipe",
2452
+ env: { ...process.env, AGENTPLATE_AGENT_NAME: "test-agent", AGENTPLATE_TASK_ID: "my-task" },
2453
+ });
2454
+ const output = await new Response(proc.stdout).text();
2455
+ await proc.exited;
2456
+ const parsed = JSON.parse(output.trim());
2457
+ expect(parsed.decision).toBe("block");
2458
+ expect(parsed.reason).toContain("other-task");
2459
+ expect(parsed.reason).toContain("my-task");
2460
+ });
2461
+
2462
+ test("allows sr close with matching ID", async () => {
2463
+ const script = buildTrackerCloseGuardScript();
2464
+ const input = JSON.stringify({ command: "sr close my-task" });
2465
+ const proc = Bun.spawn(["sh", "-c", script], {
2466
+ stdin: new TextEncoder().encode(input),
2467
+ stdout: "pipe",
2468
+ stderr: "pipe",
2469
+ env: { ...process.env, AGENTPLATE_AGENT_NAME: "test-agent", AGENTPLATE_TASK_ID: "my-task" },
2470
+ });
2471
+ const output = await new Response(proc.stdout).text();
2472
+ await proc.exited;
2473
+ expect(output.trim()).toBe("");
2474
+ });
2475
+
2476
+ test("blocks bd close with wrong ID", async () => {
2477
+ const script = buildTrackerCloseGuardScript();
2478
+ const input = JSON.stringify({ command: "bd close other-task" });
2479
+ const proc = Bun.spawn(["sh", "-c", script], {
2480
+ stdin: new TextEncoder().encode(input),
2481
+ stdout: "pipe",
2482
+ stderr: "pipe",
2483
+ env: { ...process.env, AGENTPLATE_AGENT_NAME: "test-agent", AGENTPLATE_TASK_ID: "my-task" },
2484
+ });
2485
+ const output = await new Response(proc.stdout).text();
2486
+ await proc.exited;
2487
+ const parsed = JSON.parse(output.trim());
2488
+ expect(parsed.decision).toBe("block");
2489
+ expect(parsed.reason).toContain("other-task");
2490
+ });
2491
+
2492
+ test("blocks sr update --status with wrong ID", async () => {
2493
+ const script = buildTrackerCloseGuardScript();
2494
+ const input = JSON.stringify({ command: "sr update other-task --status in_progress" });
2495
+ const proc = Bun.spawn(["sh", "-c", script], {
2496
+ stdin: new TextEncoder().encode(input),
2497
+ stdout: "pipe",
2498
+ stderr: "pipe",
2499
+ env: { ...process.env, AGENTPLATE_AGENT_NAME: "test-agent", AGENTPLATE_TASK_ID: "my-task" },
2500
+ });
2501
+ const output = await new Response(proc.stdout).text();
2502
+ await proc.exited;
2503
+ const parsed = JSON.parse(output.trim());
2504
+ expect(parsed.decision).toBe("block");
2505
+ expect(parsed.reason).toContain("other-task");
2506
+ });
2507
+
2508
+ test("exits early when AGENTPLATE_TASK_ID is empty (coordinator/monitor)", async () => {
2509
+ const script = buildTrackerCloseGuardScript();
2510
+ const input = JSON.stringify({ command: "sr close coordinator-task" });
2511
+ const proc = Bun.spawn(["sh", "-c", script], {
2512
+ stdin: new TextEncoder().encode(input),
2513
+ stdout: "pipe",
2514
+ stderr: "pipe",
2515
+ env: { ...process.env, AGENTPLATE_AGENT_NAME: "coordinator", AGENTPLATE_TASK_ID: "" },
2516
+ });
2517
+ const output = await new Response(proc.stdout).text();
2518
+ await proc.exited;
2519
+ expect(output.trim()).toBe("");
2520
+ });
2521
+ });
2522
+
2523
+ describe("getTrackerCloseGuards", () => {
2524
+ test("returns exactly 1 Bash guard entry", () => {
2525
+ const guards = getTrackerCloseGuards();
2526
+ expect(guards).toHaveLength(1);
2527
+ expect(guards[0]?.matcher).toBe("Bash");
2528
+ });
2529
+
2530
+ test("guard hook type is command", () => {
2531
+ const guards = getTrackerCloseGuards();
2532
+ expect(guards[0]?.hooks[0]?.type).toBe("command");
2533
+ });
2534
+
2535
+ test("guard command contains AGENTPLATE_TASK_ID check", () => {
2536
+ const guards = getTrackerCloseGuards();
2537
+ const command = guards[0]?.hooks[0]?.command ?? "";
2538
+ expect(command).toContain("AGENTPLATE_TASK_ID");
2539
+ });
2540
+
2541
+ test("guard command includes ENV_GUARD prefix", () => {
2542
+ const guards = getTrackerCloseGuards();
2543
+ const command = guards[0]?.hooks[0]?.command ?? "";
2544
+ expect(command).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
2545
+ });
2546
+ });
2547
+
2548
+ describe("deployHooks tracker close guard integration", () => {
2549
+ let tempDir: string;
2550
+
2551
+ beforeEach(async () => {
2552
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-tracker-close-test-"));
2553
+ });
2554
+
2555
+ afterEach(async () => {
2556
+ await cleanupTempDir(tempDir);
2557
+ });
2558
+
2559
+ test("deployHooks includes tracker close guard in PreToolUse for builder", async () => {
2560
+ const worktreePath = join(tempDir, "builder-tc-wt");
2561
+ await deployHooks(worktreePath, "builder-tc", "builder");
2562
+
2563
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
2564
+ const parsed = JSON.parse(content);
2565
+ const preToolUse = parsed.hooks.PreToolUse;
2566
+
2567
+ const trackerGuard = preToolUse.find(
2568
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2569
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("AGENTPLATE_TASK_ID"),
2570
+ );
2571
+ expect(trackerGuard).toBeDefined();
2572
+ expect(trackerGuard.hooks[0].command).toContain("AGENTPLATE_TASK_ID");
2573
+ });
2574
+
2575
+ test("deployHooks includes tracker close guard in PreToolUse for all capabilities", async () => {
2576
+ const capabilities = [
2577
+ "builder",
2578
+ "scout",
2579
+ "reviewer",
2580
+ "lead",
2581
+ "merger",
2582
+ "orchestrator",
2583
+ "coordinator",
2584
+ ];
2585
+
2586
+ for (const cap of capabilities) {
2587
+ const wt = join(tempDir, `${cap}-tc-wt`);
2588
+ await deployHooks(wt, `${cap}-tc`, cap);
2589
+
2590
+ const content = await Bun.file(join(wt, ".claude", "settings.local.json")).text();
2591
+ const parsed = JSON.parse(content);
2592
+ const preToolUse = parsed.hooks.PreToolUse;
2593
+
2594
+ const trackerGuard = preToolUse.find(
2595
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
2596
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("AGENTPLATE_TASK_ID"),
2597
+ );
2598
+ expect(trackerGuard).toBeDefined();
2599
+ }
2600
+ });
2601
+ });
2602
+
2603
+ describe("buildLeadCloseGateScript (agentplate-3899, agentplate-da9b)", () => {
2604
+ test("returns a string containing key patterns", () => {
2605
+ const script = buildLeadCloseGateScript();
2606
+ expect(typeof script).toBe("string");
2607
+ expect(script.length).toBeGreaterThan(0);
2608
+ expect(script).toContain("merge_ready");
2609
+ expect(script).toContain("worker_done");
2610
+ expect(script).toContain("AGENTPLATE_TASK_ID");
2611
+ expect(script).toContain("AGENTPLATE_AGENT_NAME");
2612
+ expect(script).toContain("(sr|bd)");
2613
+ expect(script).toContain("close");
2614
+ });
2615
+
2616
+ test("contains ENV_GUARD prefix", () => {
2617
+ const script = buildLeadCloseGateScript();
2618
+ expect(script).toContain('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0;');
2619
+ });
2620
+
2621
+ test("contains AGENTPLATE_TASK_ID early-exit check", () => {
2622
+ const script = buildLeadCloseGateScript();
2623
+ expect(script).toContain('[ -z "$AGENTPLATE_TASK_ID" ] && exit 0;');
2624
+ });
2625
+
2626
+ test("contains merge-ancestor verification (agentplate-da9b)", () => {
2627
+ const script = buildLeadCloseGateScript();
2628
+ expect(script).toContain("AGENTPLATE_WORKTREE_PATH");
2629
+ expect(script).toContain("session-branch.txt");
2630
+ expect(script).toContain("merge-base --is-ancestor");
2631
+ expect(script).toContain("not yet merged");
2632
+ });
2633
+ });
2634
+
2635
+ describe("getLeadCloseGateGuards", () => {
2636
+ test("returns exactly 1 Bash guard entry", () => {
2637
+ const guards = getLeadCloseGateGuards();
2638
+ expect(guards).toHaveLength(1);
2639
+ expect(guards[0]?.matcher).toBe("Bash");
2640
+ });
2641
+
2642
+ test("guard hook type is command", () => {
2643
+ const guards = getLeadCloseGateGuards();
2644
+ expect(guards[0]?.hooks[0]?.type).toBe("command");
2645
+ });
2646
+
2647
+ test("guard command includes PATH_PREFIX (so ap resolves)", () => {
2648
+ const guards = getLeadCloseGateGuards();
2649
+ const command = guards[0]?.hooks[0]?.command ?? "";
2650
+ expect(command).toContain("$HOME/.bun/bin");
2651
+ });
2652
+
2653
+ test("guard command references merge_ready and worker_done", () => {
2654
+ const guards = getLeadCloseGateGuards();
2655
+ const command = guards[0]?.hooks[0]?.command ?? "";
2656
+ expect(command).toContain("merge_ready");
2657
+ expect(command).toContain("worker_done");
2658
+ });
2659
+ });
2660
+
2661
+ // CI runners don't have a global `ap` binary on PATH, so the lead close-gate
2662
+ // script (which calls `ap mail list ...`) can't count seeded mails. Build a
2663
+ // shim that execs the project's own src/index.ts via bun, and return its dir
2664
+ // to prepend to PATH inside spawned shells.
2665
+ async function createOvShim(parentDir: string): Promise<string> {
2666
+ const binDir = join(parentDir, ".bin");
2667
+ await mkdir(binDir, { recursive: true });
2668
+ const ovSrc = resolve(import.meta.dir, "..", "index.ts");
2669
+ const shim = `#!/bin/sh\nexec ${process.execPath} ${JSON.stringify(ovSrc)} "$@"\n`;
2670
+ const ovShim = join(binDir, "ap");
2671
+ await writeFile(ovShim, shim);
2672
+ await chmod(ovShim, 0o755);
2673
+ return binDir;
2674
+ }
2675
+
2676
+ describe("lead close-gate behavioral tests", () => {
2677
+ let tempDir: string;
2678
+ let dbPath: string;
2679
+ let ovBinDir: string;
2680
+
2681
+ beforeEach(async () => {
2682
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-lead-gate-test-"));
2683
+ await mkdir(join(tempDir, ".agentplate"), { recursive: true });
2684
+ dbPath = join(tempDir, ".agentplate", "mail.db");
2685
+ ovBinDir = await createOvShim(tempDir);
2686
+ });
2687
+
2688
+ afterEach(async () => {
2689
+ await cleanupTempDir(tempDir);
2690
+ });
2691
+
2692
+ function seedMail(messages: Array<{ from: string; to: string; type: string }>): void {
2693
+ const store = createMailStore(dbPath);
2694
+ const client = createMailClient(store);
2695
+ try {
2696
+ for (const m of messages) {
2697
+ client.send({
2698
+ from: m.from,
2699
+ to: m.to,
2700
+ subject: `${m.type}: test`,
2701
+ body: "test",
2702
+ type: m.type as Parameters<typeof client.send>[0]["type"],
2703
+ });
2704
+ }
2705
+ } finally {
2706
+ client.close();
2707
+ }
2708
+ }
2709
+
2710
+ async function runGate(opts: {
2711
+ command: string;
2712
+ agentName?: string | null;
2713
+ taskId?: string | null;
2714
+ }): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
2715
+ const guards = getLeadCloseGateGuards();
2716
+ const script = guards[0]?.hooks[0]?.command ?? "";
2717
+ const input = JSON.stringify({ command: opts.command });
2718
+ const env = { ...process.env } as Record<string, string>;
2719
+ env.PATH = `${ovBinDir}:${env.PATH ?? ""}`;
2720
+ if (opts.agentName === null) {
2721
+ delete env.AGENTPLATE_AGENT_NAME;
2722
+ } else if (opts.agentName !== undefined) {
2723
+ env.AGENTPLATE_AGENT_NAME = opts.agentName;
2724
+ }
2725
+ if (opts.taskId === null) {
2726
+ delete env.AGENTPLATE_TASK_ID;
2727
+ } else if (opts.taskId !== undefined) {
2728
+ env.AGENTPLATE_TASK_ID = opts.taskId;
2729
+ }
2730
+ const proc = Bun.spawn(["sh", "-c", script], {
2731
+ cwd: tempDir,
2732
+ stdin: new TextEncoder().encode(input),
2733
+ stdout: "pipe",
2734
+ stderr: "pipe",
2735
+ env,
2736
+ });
2737
+ const stdout = await new Response(proc.stdout).text();
2738
+ const stderr = await new Response(proc.stderr).text();
2739
+ const exitCode = await proc.exited;
2740
+ return { stdout, stderr, exitCode };
2741
+ }
2742
+
2743
+ test("exits silently when AGENTPLATE_AGENT_NAME is unset", async () => {
2744
+ const r = await runGate({
2745
+ command: "sr close my-task",
2746
+ agentName: null,
2747
+ taskId: "my-task",
2748
+ });
2749
+ expect(r.stdout.trim()).toBe("");
2750
+ });
2751
+
2752
+ test("exits silently when AGENTPLATE_TASK_ID is unset (coordinator/monitor)", async () => {
2753
+ const r = await runGate({
2754
+ command: "sr close my-task",
2755
+ agentName: "coordinator",
2756
+ taskId: null,
2757
+ });
2758
+ expect(r.stdout.trim()).toBe("");
2759
+ });
2760
+
2761
+ test("exits silently for non-close commands", async () => {
2762
+ const r = await runGate({
2763
+ command: "git status",
2764
+ agentName: "lead-x",
2765
+ taskId: "my-task",
2766
+ });
2767
+ expect(r.stdout.trim()).toBe("");
2768
+ });
2769
+
2770
+ test("exits silently when closing a foreign task (handled by tracker-close guard)", async () => {
2771
+ const r = await runGate({
2772
+ command: "sr close some-other-task",
2773
+ agentName: "lead-x",
2774
+ taskId: "my-task",
2775
+ });
2776
+ expect(r.stdout.trim()).toBe("");
2777
+ });
2778
+
2779
+ test("blocks sr close of own task when zero merge_ready sent", async () => {
2780
+ const r = await runGate({
2781
+ command: "sr close my-task",
2782
+ agentName: "lead-x",
2783
+ taskId: "my-task",
2784
+ });
2785
+ const parsed = JSON.parse(r.stdout.trim());
2786
+ expect(parsed.decision).toBe("block");
2787
+ expect(parsed.reason).toContain("merge_ready");
2788
+ expect(parsed.reason).toContain("not sent");
2789
+ });
2790
+
2791
+ test("blocks bd close of own task when zero merge_ready sent (beads tracker)", async () => {
2792
+ const r = await runGate({
2793
+ command: "bd close my-task",
2794
+ agentName: "lead-x",
2795
+ taskId: "my-task",
2796
+ });
2797
+ const parsed = JSON.parse(r.stdout.trim());
2798
+ expect(parsed.decision).toBe("block");
2799
+ expect(parsed.reason).toContain("merge_ready");
2800
+ });
2801
+
2802
+ test("allows sr close when one merge_ready sent and zero worker_done received", async () => {
2803
+ seedMail([{ from: "lead-x", to: "coordinator", type: "merge_ready" }]);
2804
+ const r = await runGate({
2805
+ command: "sr close my-task",
2806
+ agentName: "lead-x",
2807
+ taskId: "my-task",
2808
+ });
2809
+ expect(r.stdout.trim()).toBe("");
2810
+ });
2811
+
2812
+ test("blocks sr close when merge_ready count < worker_done count", async () => {
2813
+ seedMail([
2814
+ { from: "lead-x", to: "coordinator", type: "merge_ready" },
2815
+ { from: "builder-a", to: "lead-x", type: "worker_done" },
2816
+ { from: "builder-b", to: "lead-x", type: "worker_done" },
2817
+ ]);
2818
+ const r = await runGate({
2819
+ command: "sr close my-task",
2820
+ agentName: "lead-x",
2821
+ taskId: "my-task",
2822
+ });
2823
+ const parsed = JSON.parse(r.stdout.trim());
2824
+ expect(parsed.decision).toBe("block");
2825
+ expect(parsed.reason).toContain("less than worker_done");
2826
+ });
2827
+
2828
+ test("allows sr close when merge_ready count equals worker_done count", async () => {
2829
+ seedMail([
2830
+ { from: "lead-x", to: "coordinator", type: "merge_ready" },
2831
+ { from: "lead-x", to: "coordinator", type: "merge_ready" },
2832
+ { from: "builder-a", to: "lead-x", type: "worker_done" },
2833
+ { from: "builder-b", to: "lead-x", type: "worker_done" },
2834
+ ]);
2835
+ const r = await runGate({
2836
+ command: "sr close my-task",
2837
+ agentName: "lead-x",
2838
+ taskId: "my-task",
2839
+ });
2840
+ expect(r.stdout.trim()).toBe("");
2841
+ });
2842
+
2843
+ test("only counts merge_ready sent BY this agent, not by other leads", async () => {
2844
+ seedMail([
2845
+ { from: "lead-y", to: "coordinator", type: "merge_ready" },
2846
+ { from: "lead-z", to: "coordinator", type: "merge_ready" },
2847
+ ]);
2848
+ const r = await runGate({
2849
+ command: "sr close my-task",
2850
+ agentName: "lead-x",
2851
+ taskId: "my-task",
2852
+ });
2853
+ const parsed = JSON.parse(r.stdout.trim());
2854
+ expect(parsed.decision).toBe("block");
2855
+ expect(parsed.reason).toContain("not sent");
2856
+ });
2857
+ });
2858
+
2859
+ describe("lead close-gate merge-ancestor check (agentplate-da9b)", () => {
2860
+ let repoDir: string;
2861
+ let defaultBranch: string;
2862
+ let ovBinDir: string;
2863
+
2864
+ beforeEach(async () => {
2865
+ repoDir = await createTempGitRepo();
2866
+ defaultBranch = await getDefaultBranch(repoDir);
2867
+ await mkdir(join(repoDir, ".agentplate"), { recursive: true });
2868
+ ovBinDir = await createOvShim(repoDir);
2869
+ // Pre-seed merge_ready so the lead clears the count checks and the script
2870
+ // reaches the new merge-ancestor logic.
2871
+ const dbPath = join(repoDir, ".agentplate", "mail.db");
2872
+ const store = createMailStore(dbPath);
2873
+ const client = createMailClient(store);
2874
+ try {
2875
+ client.send({
2876
+ from: "lead-x",
2877
+ to: "coordinator",
2878
+ subject: "merge_ready: my-task",
2879
+ body: "test",
2880
+ type: "merge_ready",
2881
+ });
2882
+ } finally {
2883
+ client.close();
2884
+ }
2885
+ });
2886
+
2887
+ afterEach(async () => {
2888
+ await cleanupTempDir(repoDir);
2889
+ });
2890
+
2891
+ async function runMergeGate(opts: {
2892
+ worktreePath?: string | null;
2893
+ projectRoot?: string | null;
2894
+ }): Promise<{ stdout: string; exitCode: number | null }> {
2895
+ const guards = getLeadCloseGateGuards();
2896
+ const script = guards[0]?.hooks[0]?.command ?? "";
2897
+ const input = JSON.stringify({ command: "sr close my-task" });
2898
+ const env = { ...process.env } as Record<string, string>;
2899
+ env.PATH = `${ovBinDir}:${env.PATH ?? ""}`;
2900
+ env.AGENTPLATE_AGENT_NAME = "lead-x";
2901
+ env.AGENTPLATE_TASK_ID = "my-task";
2902
+ if (opts.worktreePath === null) {
2903
+ delete env.AGENTPLATE_WORKTREE_PATH;
2904
+ } else if (opts.worktreePath !== undefined) {
2905
+ env.AGENTPLATE_WORKTREE_PATH = opts.worktreePath;
2906
+ }
2907
+ if (opts.projectRoot === null) {
2908
+ delete env.AGENTPLATE_PROJECT_ROOT;
2909
+ } else if (opts.projectRoot !== undefined) {
2910
+ env.AGENTPLATE_PROJECT_ROOT = opts.projectRoot;
2911
+ }
2912
+ const proc = Bun.spawn(["sh", "-c", script], {
2913
+ cwd: repoDir,
2914
+ stdin: new TextEncoder().encode(input),
2915
+ stdout: "pipe",
2916
+ stderr: "pipe",
2917
+ env,
2918
+ });
2919
+ const stdout = await new Response(proc.stdout).text();
2920
+ const exitCode = await proc.exited;
2921
+ return { stdout, exitCode };
2922
+ }
2923
+
2924
+ test("blocks sr close when lead's branch is not yet merged into default target", async () => {
2925
+ await runGitInDir(repoDir, ["checkout", "-b", "agentplate/lead-x/my-task"]);
2926
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
2927
+ // HEAD has a commit not present on `main` — lead is unmerged.
2928
+ const r = await runMergeGate({
2929
+ worktreePath: repoDir,
2930
+ projectRoot: repoDir,
2931
+ });
2932
+ const parsed = JSON.parse(r.stdout.trim());
2933
+ expect(parsed.decision).toBe("block");
2934
+ expect(parsed.reason).toContain("not yet merged");
2935
+ });
2936
+
2937
+ test("allows sr close once the lead's branch has been merged into the default target", async () => {
2938
+ await runGitInDir(repoDir, ["checkout", "-b", "agentplate/lead-x/my-task"]);
2939
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
2940
+ // Coordinator merges lead's branch into main.
2941
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
2942
+ await runGitInDir(repoDir, [
2943
+ "merge",
2944
+ "--no-ff",
2945
+ "agentplate/lead-x/my-task",
2946
+ "-m",
2947
+ "merge lead-x",
2948
+ ]);
2949
+ // Lead's worktree HEAD remains on its branch tip; target now contains it.
2950
+ await runGitInDir(repoDir, ["checkout", "agentplate/lead-x/my-task"]);
2951
+ const r = await runMergeGate({
2952
+ worktreePath: repoDir,
2953
+ projectRoot: repoDir,
2954
+ });
2955
+ expect(r.stdout.trim()).toBe("");
2956
+ });
2957
+
2958
+ test("uses session-branch.txt as the merge target when present", async () => {
2959
+ // Create a non-default branch as the merge target.
2960
+ await runGitInDir(repoDir, ["checkout", "-b", "release/v1"]);
2961
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
2962
+ await Bun.write(join(repoDir, ".agentplate", "session-branch.txt"), "release/v1\n");
2963
+ // Lead works on its own branch with new commits; release/v1 has none of them.
2964
+ await runGitInDir(repoDir, ["checkout", "-b", "agentplate/lead-x/my-task"]);
2965
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
2966
+ const r = await runMergeGate({
2967
+ worktreePath: repoDir,
2968
+ projectRoot: repoDir,
2969
+ });
2970
+ const parsed = JSON.parse(r.stdout.trim());
2971
+ expect(parsed.decision).toBe("block");
2972
+ expect(parsed.reason).toContain("not yet merged");
2973
+
2974
+ // Now merge into release/v1; close should be allowed.
2975
+ await runGitInDir(repoDir, ["checkout", "release/v1"]);
2976
+ await runGitInDir(repoDir, [
2977
+ "merge",
2978
+ "--no-ff",
2979
+ "agentplate/lead-x/my-task",
2980
+ "-m",
2981
+ "merge lead-x to v1",
2982
+ ]);
2983
+ await runGitInDir(repoDir, ["checkout", "agentplate/lead-x/my-task"]);
2984
+ const r2 = await runMergeGate({
2985
+ worktreePath: repoDir,
2986
+ projectRoot: repoDir,
2987
+ });
2988
+ expect(r2.stdout.trim()).toBe("");
2989
+ });
2990
+
2991
+ test("fails open (allows close) when AGENTPLATE_WORKTREE_PATH is unset", async () => {
2992
+ await runGitInDir(repoDir, ["checkout", "-b", "agentplate/lead-x/my-task"]);
2993
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
2994
+ // HEAD is unmerged, but no worktree path means we can't verify — fail open.
2995
+ const r = await runMergeGate({
2996
+ worktreePath: null,
2997
+ projectRoot: repoDir,
2998
+ });
2999
+ expect(r.stdout.trim()).toBe("");
3000
+ });
3001
+
3002
+ test("fails open when the resolved target ref does not exist locally", async () => {
3003
+ await Bun.write(
3004
+ join(repoDir, ".agentplate", "session-branch.txt"),
3005
+ "branch-that-does-not-exist\n",
3006
+ );
3007
+ await runGitInDir(repoDir, ["checkout", "-b", "agentplate/lead-x/my-task"]);
3008
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
3009
+ const r = await runMergeGate({
3010
+ worktreePath: repoDir,
3011
+ projectRoot: repoDir,
3012
+ });
3013
+ // Target ref unresolvable → can't make a definitive claim → don't block.
3014
+ expect(r.stdout.trim()).toBe("");
3015
+ });
3016
+
3017
+ test("falls back to 'main' when session-branch.txt is missing", async () => {
3018
+ // No session-branch.txt — must fall back to "main".
3019
+ await runGitInDir(repoDir, ["checkout", "-b", "agentplate/lead-x/my-task"]);
3020
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
3021
+ // Merge into main.
3022
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
3023
+ await runGitInDir(repoDir, [
3024
+ "merge",
3025
+ "--no-ff",
3026
+ "agentplate/lead-x/my-task",
3027
+ "-m",
3028
+ "merge lead-x",
3029
+ ]);
3030
+ await runGitInDir(repoDir, ["checkout", "agentplate/lead-x/my-task"]);
3031
+ const r = await runMergeGate({
3032
+ worktreePath: repoDir,
3033
+ projectRoot: repoDir,
3034
+ });
3035
+ expect(r.stdout.trim()).toBe("");
3036
+ });
3037
+ });
3038
+
3039
+ describe("deployHooks lead close-gate integration", () => {
3040
+ let tempDir: string;
3041
+
3042
+ beforeEach(async () => {
3043
+ tempDir = await mkdtemp(join(tmpdir(), "agentplate-lead-gate-deploy-"));
3044
+ });
3045
+
3046
+ afterEach(async () => {
3047
+ await cleanupTempDir(tempDir);
3048
+ });
3049
+
3050
+ test("lead capability gets the close-gate guard in PreToolUse", async () => {
3051
+ const worktreePath = join(tempDir, "lead-wt");
3052
+ await deployHooks(worktreePath, "lead-agent", "lead");
3053
+
3054
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
3055
+ const parsed = JSON.parse(content);
3056
+ const preToolUse = parsed.hooks.PreToolUse;
3057
+
3058
+ const closeGate = preToolUse.find(
3059
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
3060
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("merge_ready gate"),
3061
+ );
3062
+ expect(closeGate).toBeDefined();
3063
+ });
3064
+
3065
+ test("non-lead capabilities do NOT get the close-gate guard", async () => {
3066
+ const capabilities = ["builder", "scout", "reviewer", "merger", "coordinator", "orchestrator"];
3067
+
3068
+ for (const cap of capabilities) {
3069
+ const wt = join(tempDir, `${cap}-wt`);
3070
+ await deployHooks(wt, `${cap}-agent`, cap);
3071
+
3072
+ const content = await Bun.file(join(wt, ".claude", "settings.local.json")).text();
3073
+ const parsed = JSON.parse(content);
3074
+ const preToolUse = parsed.hooks.PreToolUse;
3075
+
3076
+ const closeGate = preToolUse.find(
3077
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
3078
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("merge_ready gate"),
3079
+ );
3080
+ expect(closeGate).toBeUndefined();
3081
+ }
3082
+ });
3083
+ });
3084
+
3085
+ describe("escapeForSingleQuotedShell", () => {
3086
+ test("no single quotes: string passes through unchanged", () => {
3087
+ expect(escapeForSingleQuotedShell("hello world")).toBe("hello world");
3088
+ });
3089
+
3090
+ test("single quotes escaped: it's becomes it'\\''s", () => {
3091
+ expect(escapeForSingleQuotedShell("it's")).toBe("it'\\''s");
3092
+ });
3093
+
3094
+ test("multiple single quotes: each one is escaped independently", () => {
3095
+ expect(escapeForSingleQuotedShell("can't won't")).toBe("can'\\''t won'\\''t");
3096
+ });
3097
+
3098
+ test("empty string: returns empty string", () => {
3099
+ expect(escapeForSingleQuotedShell("")).toBe("");
3100
+ });
3101
+
3102
+ test("blockGuard shell command outputs valid JSON when executed", async () => {
3103
+ const guards = getCapabilityGuards("builder");
3104
+ const taskGuard = guards.find((g) => g.matcher === "Task");
3105
+ expect(taskGuard).toBeDefined();
3106
+ const cmd = taskGuard?.hooks[0]?.command ?? "";
3107
+ const echoCmd = cmd.replace('[ -z "$AGENTPLATE_AGENT_NAME" ] && exit 0; ', "");
3108
+ const proc = Bun.spawn(["sh", "-c", echoCmd], {
3109
+ stdout: "pipe",
3110
+ stderr: "pipe",
3111
+ env: { ...process.env, AGENTPLATE_AGENT_NAME: "test-agent" },
3112
+ });
3113
+ const output = await new Response(proc.stdout).text();
3114
+ await proc.exited;
3115
+ const parsed = JSON.parse(output.trim());
3116
+ expect(parsed.decision).toBe("block");
3117
+ expect(parsed.reason).toContain("ap sling");
3118
+ });
3119
+ });