terramend 0.2.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 (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,210 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { SelectModeTool } from "#app/mcp/selectMode";
3
+ import type { ToolContext } from "#app/mcp/server";
4
+ import type { Mode } from "#app/modes";
5
+ import type { ToolState } from "#app/toolState";
6
+ import { apiFetch } from "#app/utils/apiFetch";
7
+
8
+ vi.mock("#app/utils/apiFetch", () => ({
9
+ apiFetch: vi.fn(),
10
+ }));
11
+
12
+ const apiFetchMock = vi.mocked(apiFetch);
13
+
14
+ type ToolResultShape = { content: [{ type: "text"; text: string }]; isError?: boolean };
15
+
16
+ async function runTool(t: { execute?: unknown }, params: unknown): Promise<ToolResultShape> {
17
+ const exec = t.execute as (p: unknown, c: unknown) => Promise<ToolResultShape>;
18
+ return exec(params, {});
19
+ }
20
+
21
+ const MODES: Mode[] = [
22
+ { name: "Build", description: "implement changes", prompt: "BUILD-PROMPT" },
23
+ { name: "Plan", description: "plan work", prompt: "PLAN-PROMPT" },
24
+ { name: "Review", description: "review a PR", prompt: "REVIEW-PROMPT" },
25
+ { name: "IncrementalReview", description: "review the delta", prompt: "INCR-PROMPT" },
26
+ { name: "Fix", description: "fix CI", prompt: undefined },
27
+ ];
28
+
29
+ function makeCtx(overrides?: {
30
+ toolState?: Partial<ToolState>;
31
+ modeInstructions?: Record<string, string>;
32
+ issueNumber?: number;
33
+ githubInstallationToken?: string;
34
+ }): { ctx: ToolContext; toolState: ToolState } {
35
+ const toolState = { ...overrides?.toolState } as ToolState;
36
+ const ctx = {
37
+ agentId: "claude",
38
+ repo: { owner: "octo", name: "repo" },
39
+ payload: { event: { issue_number: overrides?.issueNumber } },
40
+ modes: MODES,
41
+ modeInstructions: overrides?.modeInstructions ?? {},
42
+ toolState,
43
+ githubInstallationToken: overrides?.githubInstallationToken ?? "ghs_token",
44
+ } as unknown as ToolContext;
45
+ return { ctx, toolState };
46
+ }
47
+
48
+ function planCommentResponse(
49
+ body: { commentId: number; body: string } | { error: string },
50
+ ok = true,
51
+ ) {
52
+ return { ok, json: async () => body } as unknown as Response;
53
+ }
54
+
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ apiFetchMock.mockResolvedValue(planCommentResponse({ error: "not found" }, false));
58
+ });
59
+
60
+ describe("SelectModeTool", () => {
61
+ it("rejects a second selection once a mode is chosen", async () => {
62
+ const { ctx } = makeCtx({ toolState: { selectedMode: "Build" } });
63
+ const result = await runTool(SelectModeTool(ctx), { mode: "Review" });
64
+
65
+ expect(result.content[0].text).toContain("mode already selected");
66
+ expect(result.content[0].text).toContain("Build");
67
+ });
68
+
69
+ it("lists the available modes when the requested one is unknown", async () => {
70
+ const { ctx, toolState } = makeCtx();
71
+ const result = await runTool(SelectModeTool(ctx), { mode: "Nonsense" });
72
+
73
+ expect(result.content[0].text).toContain("Nonsense");
74
+ expect(result.content[0].text).toContain("not found. available modes:");
75
+ expect(result.content[0].text).toContain("Build, Plan, Review, IncrementalReview, Fix");
76
+ expect(toolState.selectedMode).toBeUndefined();
77
+ });
78
+
79
+ it("resolves the mode case-insensitively and records the selection", async () => {
80
+ const { ctx, toolState } = makeCtx();
81
+ const result = await runTool(SelectModeTool(ctx), { mode: "review" });
82
+
83
+ expect(result.isError).toBeUndefined();
84
+ expect(toolState.selectedMode).toBe("Review");
85
+ expect(result.content[0].text).toContain("REVIEW-PROMPT");
86
+ });
87
+
88
+ it("joins the hardcoded prompt with the user's mode instructions", async () => {
89
+ const { ctx } = makeCtx({ modeInstructions: { Build: "USER-BUILD-RULES" } });
90
+ const result = await runTool(SelectModeTool(ctx), { mode: "Build" });
91
+
92
+ expect(result.content[0].text).toContain("BUILD-PROMPT");
93
+ expect(result.content[0].text).toContain("USER-BUILD-RULES");
94
+ });
95
+
96
+ it("Fix inherits Build's user instructions (no prompt of its own)", async () => {
97
+ const { ctx } = makeCtx({ modeInstructions: { Build: "USER-BUILD-RULES" } });
98
+ const result = await runTool(SelectModeTool(ctx), { mode: "Fix" });
99
+
100
+ expect(result.content[0].text).toContain("modeName: Fix");
101
+ expect(result.content[0].text).toContain("USER-BUILD-RULES");
102
+ expect(result.content[0].text).not.toContain("BUILD-PROMPT");
103
+ });
104
+
105
+ it("IncrementalReview inherits Review's user instructions", async () => {
106
+ const { ctx } = makeCtx({ modeInstructions: { Review: "USER-REVIEW-RULES" } });
107
+ const result = await runTool(SelectModeTool(ctx), { mode: "IncrementalReview" });
108
+
109
+ expect(result.content[0].text).toContain("INCR-PROMPT");
110
+ expect(result.content[0].text).toContain("USER-REVIEW-RULES");
111
+ });
112
+
113
+ describe("Plan mode with an existing plan comment", () => {
114
+ it("returns PlanEdit guidance plus the previous plan body", async () => {
115
+ apiFetchMock.mockResolvedValue(planCommentResponse({ commentId: 88, body: "OLD-PLAN-BODY" }));
116
+ const { ctx, toolState } = makeCtx();
117
+ const result = await runTool(SelectModeTool(ctx), { mode: "Plan", issue_number: 42 });
118
+
119
+ expect(apiFetchMock).toHaveBeenCalledWith(
120
+ expect.objectContaining({
121
+ path: "/api/repo/octo/repo/issue/42/plan-comment",
122
+ headers: { authorization: "Bearer ghs_token" },
123
+ }),
124
+ );
125
+ expect(toolState.existingPlanCommentId).toBe(88);
126
+ expect(toolState.previousPlanBody).toBe("OLD-PLAN-BODY");
127
+ expect(result.content[0].text).toContain("editing existing plan");
128
+ expect(result.content[0].text).toContain("OLD-PLAN-BODY");
129
+ expect(result.content[0].text).toContain("mcp__"); // claude-formatted tool refs
130
+ });
131
+
132
+ it("falls back to the event's issue number when none is passed", async () => {
133
+ apiFetchMock.mockResolvedValue(planCommentResponse({ commentId: 9, body: "B" }));
134
+ const { ctx } = makeCtx({ issueNumber: 7 });
135
+ await runTool(SelectModeTool(ctx), { mode: "Plan" });
136
+
137
+ expect(apiFetchMock).toHaveBeenCalledWith(
138
+ expect.objectContaining({ path: "/api/repo/octo/repo/issue/7/plan-comment" }),
139
+ );
140
+ });
141
+
142
+ it("returns plain Plan guidance when the API has no plan comment", async () => {
143
+ const { ctx, toolState } = makeCtx();
144
+ const result = await runTool(SelectModeTool(ctx), { mode: "Plan", issue_number: 42 });
145
+
146
+ expect(toolState.existingPlanCommentId).toBeUndefined();
147
+ expect(result.content[0].text).toContain("PLAN-PROMPT");
148
+ expect(result.content[0].text).not.toContain("editing existing plan");
149
+ });
150
+
151
+ it("ignores an OK response whose body is an error payload", async () => {
152
+ apiFetchMock.mockResolvedValue(planCommentResponse({ error: "no plan yet" }, true));
153
+ const { ctx, toolState } = makeCtx();
154
+ const result = await runTool(SelectModeTool(ctx), { mode: "Plan", issue_number: 42 });
155
+
156
+ expect(toolState.existingPlanCommentId).toBeUndefined();
157
+ expect(result.content[0].text).toContain("PLAN-PROMPT");
158
+ });
159
+
160
+ it("treats an API error as no existing plan comment", async () => {
161
+ apiFetchMock.mockRejectedValue(new Error("network down"));
162
+ const { ctx } = makeCtx();
163
+ const result = await runTool(SelectModeTool(ctx), { mode: "Plan", issue_number: 42 });
164
+
165
+ expect(result.isError).toBeUndefined();
166
+ expect(result.content[0].text).toContain("PLAN-PROMPT");
167
+ });
168
+
169
+ it("skips the lookup without a GitHub installation token", async () => {
170
+ const { ctx } = makeCtx({ githubInstallationToken: "" });
171
+ const result = await runTool(SelectModeTool(ctx), { mode: "Plan", issue_number: 42 });
172
+
173
+ expect(apiFetchMock).not.toHaveBeenCalled();
174
+ expect(result.content[0].text).toContain("PLAN-PROMPT");
175
+ });
176
+
177
+ it("skips the lookup when no issue number is available at all", async () => {
178
+ const { ctx } = makeCtx();
179
+ const result = await runTool(SelectModeTool(ctx), { mode: "Plan" });
180
+
181
+ expect(apiFetchMock).not.toHaveBeenCalled();
182
+ expect(result.content[0].text).toContain("PLAN-PROMPT");
183
+ });
184
+ });
185
+
186
+ describe("PR summary snapshot addendum", () => {
187
+ it("appends the snapshot step for Review when a summary file is set", async () => {
188
+ const { ctx } = makeCtx({ toolState: { summaryFilePath: "/tmp/summary.md" } });
189
+ const result = await runTool(SelectModeTool(ctx), { mode: "Review" });
190
+
191
+ expect(result.content[0].text).toContain("PR summary snapshot");
192
+ expect(result.content[0].text).toContain("/tmp/summary.md");
193
+ expect(result.content[0].text).toContain("REVIEW-PROMPT");
194
+ });
195
+
196
+ it("omits the addendum for Review without a summary file", async () => {
197
+ const { ctx } = makeCtx();
198
+ const result = await runTool(SelectModeTool(ctx), { mode: "Review" });
199
+
200
+ expect(result.content[0].text).not.toContain("PR summary snapshot");
201
+ });
202
+
203
+ it("never appends the addendum for non-summary modes", async () => {
204
+ const { ctx } = makeCtx({ toolState: { summaryFilePath: "/tmp/summary.md" } });
205
+ const result = await runTool(SelectModeTool(ctx), { mode: "Build" });
206
+
207
+ expect(result.content[0].text).not.toContain("PR summary snapshot");
208
+ });
209
+ });
210
+ });
@@ -0,0 +1,181 @@
1
+ import { type } from "arktype";
2
+ import { formatMcpToolRef } from "#app/external";
3
+ import type { ToolContext } from "#app/mcp/server";
4
+ import { execute, tool } from "#app/mcp/shared";
5
+ import type { Mode } from "#app/modes";
6
+ import { apiFetch } from "#app/utils/apiFetch";
7
+
8
+ export const SelectModeParams = type({
9
+ mode: type.string.describe(
10
+ "the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')",
11
+ ),
12
+ "issue_number?": type("number").describe(
13
+ "optional issue number; when provided with Plan mode, used to look up an existing plan comment for this issue (edit vs create)",
14
+ ),
15
+ });
16
+
17
+ function resolveMode(modes: Mode[], modeName: string): Mode | null {
18
+ return modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()) ?? null;
19
+ }
20
+
21
+ function buildModeOverrides(t: (name: string) => string): Record<string, string> {
22
+ return {
23
+ PlanEdit: `### Checklist (editing existing plan)
24
+
25
+ An existing plan comment was found for this issue. Update that comment with the revised plan — do not create a new plan comment.
26
+
27
+ 1. **task list**: create your task list for this run as your first action.
28
+ 2. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
29
+ 3. Revise the plan based on the user's request:
30
+ - incorporate the current plan (\`previousPlanBody\`) and the user's revision request
31
+ - gather relevant codebase context (file paths, architecture notes from AGENTS.md)
32
+ - produce a structured plan with clear milestones
33
+ 4. Call \`${t("report_progress")}\` with the full revised plan text and \`{ target_plan_comment: true }\` so it updates the existing plan comment (not the progress comment).
34
+ 5. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${t("report_progress")}\` so it is not left as "Leaping...".`,
35
+ };
36
+ }
37
+
38
+ type OrchestratorGuidance = {
39
+ modeName: string;
40
+ description: string;
41
+ orchestratorGuidance: string;
42
+ };
43
+
44
+ // IncrementalReview inherits Review's user instructions, Fix inherits Build's
45
+ const modeInstructionParent: Record<string, string> = {
46
+ IncrementalReview: "Review",
47
+ Fix: "Build",
48
+ };
49
+
50
+ function buildOrchestratorGuidance(
51
+ ctx: ToolContext,
52
+ mode: Mode,
53
+ overrideGuidance?: string,
54
+ ): OrchestratorGuidance {
55
+ const hardcoded = overrideGuidance ?? mode.prompt ?? "";
56
+ const lookupKey = modeInstructionParent[mode.name] ?? mode.name;
57
+ const userInstructions = ctx.modeInstructions[lookupKey] ?? "";
58
+ const guidance = [hardcoded, userInstructions].filter(Boolean).join("\n\n");
59
+ return {
60
+ modeName: mode.name,
61
+ description: mode.description,
62
+ orchestratorGuidance: guidance,
63
+ };
64
+ }
65
+
66
+ // matches the API response for /repo/[owner]/[repo]/issue/[issueNumber]/plan-comment
67
+ export type PlanCommentResponsePayload = { error: string } | { commentId: number; body: string };
68
+
69
+ // IMPORTANT: this route authenticates via GitHub installation token (getEnrichedRepo),
70
+ // NOT the Terramend API JWT (ctx.apiToken). use ctx.githubInstallationToken here.
71
+ // see wiki/api-auth.md for the two auth patterns.
72
+ async function fetchExistingPlanComment(
73
+ ctx: ToolContext,
74
+ issueNumber: number,
75
+ ): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> {
76
+ if (!ctx.githubInstallationToken) return null;
77
+ try {
78
+ const response = await apiFetch({
79
+ path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
80
+ method: "GET",
81
+ headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
82
+ signal: AbortSignal.timeout(10_000),
83
+ });
84
+ const data = (await response.json()) as PlanCommentResponsePayload;
85
+ return response.ok && "commentId" in data ? data : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ const SUMMARY_MODES = new Set(["Review", "IncrementalReview", "Task"]);
92
+
93
+ /** modes that gain the PR summary edit step when toolState.summaryFilePath is set.
94
+ *
95
+ * NOTE: this snapshot is an internal artifact consumed by future agent runs. it is
96
+ * deliberately NOT shaped by user-supplied summary instructions — those would warp
97
+ * the durable agent context. user-facing summarization (e.g. the review body's
98
+ * "Reviewed changes" section) is governed by review-mode prompts and review
99
+ * instructions, separately from this snapshot. */
100
+ function buildSummaryAddendum(t: (name: string) => string, ctx: ToolContext): string {
101
+ const filePath = ctx.toolState.summaryFilePath;
102
+ if (!filePath) return "";
103
+ return `### PR summary snapshot — required step
104
+
105
+ A rolling PR summary lives at \`${filePath}\`. It is your durable cross-run agent context — a functional summary of what this PR does, the subsystems and files it touches, the material behavior of its changes, and any risks or open questions worth carrying forward. It is NOT a chronological log of past review runs; commit-level history can already be reconstructed from \`${t("list_pull_request_reviews")}\`.
106
+
107
+ How to use it:
108
+
109
+ - read \`${filePath}\` at the START of the run, alongside the diff. it represents what previous agent runs already understood about this PR — absorb it before picking lenses or crafting subagent dispatch prompts. if it's a fresh seed (file is one or two lines), this is a first review and you'll be filling it in from the diff.
110
+ - let the snapshot inform triage and dispatch. when it already tracks a risk, your lens prompts to subagents are stronger when they reference that context (e.g. "the JSDoc explicitly scopes to code points — do not flag grapheme-cluster issues" if the snapshot already documents that contract). when something the snapshot tracks is now resolved by new commits, note that. when new commits introduce something the snapshot doesn't yet describe, that's exactly where your fan-out should focus.
111
+ - update the file in place to reflect the PR's CURRENT state. revise stale claims, drop resolved risks, add new behavior or risks. accuracy over breadth — every claim must be grounded in the diff. write for the next agent run, not for a human.
112
+ - structure however serves THIS PR. there is no required section template. a refactor might organize by renamed export and call-site impact; a feature by capability; a billing change by money path. a compact note of which commit ranges have been reviewed should always be present so future runs scope correctly, but the rest is your call. when the structure works across runs, keep it stable so range-diffs are clean; when the PR's character changes (e.g. scope expands), reshape.
113
+
114
+ Do NOT call \`${t("create_issue_comment")}\` for the summary — the server reads this file at end-of-run and persists it. The file edit is mandatory regardless of whether a review is submitted; the snapshot feeds the next run.`;
115
+ }
116
+
117
+ export function SelectModeTool(ctx: ToolContext) {
118
+ const t = (name: string) => formatMcpToolRef(ctx.agentId, name);
119
+ const overrides = buildModeOverrides(t);
120
+
121
+ return tool({
122
+ name: "select_mode",
123
+ description:
124
+ "Select a mode and receive step-by-step guidance on how to handle the task. Call this to understand the best workflow for the current mode. " +
125
+ 'Example: `select_mode({ mode: "Review" })` or `select_mode({ mode: "Plan", issue_number: 1234 })`.',
126
+ parameters: SelectModeParams,
127
+ execute: execute(async (params) => {
128
+ if (ctx.toolState.selectedMode) {
129
+ return {
130
+ error: `mode already selected: "${ctx.toolState.selectedMode}". mode selection is final and cannot be changed. complete your current workflow within this mode.`,
131
+ };
132
+ }
133
+
134
+ const modeName = params.mode;
135
+
136
+ const selectedMode = resolveMode(ctx.modes, modeName);
137
+
138
+ if (!selectedMode) {
139
+ const availableModes = ctx.modes.map((m) => m.name).join(", ");
140
+ return {
141
+ error: `mode "${modeName}" not found. available modes: ${availableModes}`,
142
+ availableModes: ctx.modes.map((m) => ({
143
+ name: m.name,
144
+ description: m.description,
145
+ })),
146
+ };
147
+ }
148
+
149
+ ctx.toolState.selectedMode = selectedMode.name;
150
+
151
+ if (selectedMode.name === "Plan") {
152
+ const issueNumber = params.issue_number ?? ctx.payload.event.issue_number;
153
+ if (issueNumber !== undefined) {
154
+ const existing = await fetchExistingPlanComment(ctx, issueNumber);
155
+ if (existing !== null) {
156
+ ctx.toolState.existingPlanCommentId = existing.commentId;
157
+ ctx.toolState.previousPlanBody = existing.body;
158
+ return {
159
+ ...buildOrchestratorGuidance(ctx, selectedMode, overrides.PlanEdit),
160
+ previousPlanBody: existing.body,
161
+ };
162
+ }
163
+ }
164
+ }
165
+
166
+ const summaryAddendum = SUMMARY_MODES.has(selectedMode.name)
167
+ ? buildSummaryAddendum(t, ctx)
168
+ : "";
169
+
170
+ const base = buildOrchestratorGuidance(ctx, selectedMode);
171
+ if (summaryAddendum.length > 0) {
172
+ return {
173
+ ...base,
174
+ orchestratorGuidance: `${base.orchestratorGuidance}\n\n${summaryAddendum}`,
175
+ summaryFilePath: ctx.toolState.summaryFilePath,
176
+ };
177
+ }
178
+ return base;
179
+ }),
180
+ });
181
+ }
@@ -0,0 +1,292 @@
1
+ import { createServer, type Server } from "node:net";
2
+ import { tmpdir } from "node:os";
3
+ import { FastMCP } from "fastmcp";
4
+ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
5
+ import { startMcpHttpServer, type ToolContext } from "#app/mcp/server";
6
+ import { initToolState } from "#app/toolState";
7
+
8
+ type McpHandle = Awaited<ReturnType<typeof startMcpHttpServer>>;
9
+
10
+ type CtxOverrides = {
11
+ trigger?: string;
12
+ shell?: "restricted" | "enabled" | "disabled";
13
+ };
14
+
15
+ function makeCtx(overrides: CtxOverrides = {}): ToolContext {
16
+ const ctx = {
17
+ agentId: "claude",
18
+ repo: {
19
+ owner: "octo",
20
+ name: "repo",
21
+ fullName: "octo/repo",
22
+ data: { default_branch: "main" },
23
+ },
24
+ payload: {
25
+ event: { trigger: overrides.trigger ?? "unknown" },
26
+ shell: overrides.shell ?? "restricted",
27
+ push: "enabled",
28
+ model: "anthropic/claude-opus-4",
29
+ },
30
+ octokit: {},
31
+ githubInstallationToken: "ghs_installation",
32
+ gitToken: "ghs_git",
33
+ apiToken: "api_jwt",
34
+ modes: [],
35
+ postCheckoutScript: null,
36
+ prepushScript: null,
37
+ prApproveEnabled: false,
38
+ modeInstructions: {},
39
+ toolState: initToolState({ progressComment: undefined }),
40
+ runId: undefined,
41
+ mcpServerUrl: "",
42
+ mcpServerToken: "",
43
+ tmpdir: tmpdir(),
44
+ oss: false,
45
+ plan: "none",
46
+ resolvedModel: "anthropic/claude-opus-4",
47
+ };
48
+ return ctx as unknown as ToolContext;
49
+ }
50
+
51
+ function occupyPort(): Promise<{ server: Server; port: number }> {
52
+ return new Promise((resolve, reject) => {
53
+ const server = createServer();
54
+ server.once("error", reject);
55
+ server.listen(0, "127.0.0.1", () => {
56
+ const addr = server.address();
57
+ if (!addr || typeof addr === "string") {
58
+ reject(new Error("failed to bind blocker port"));
59
+ return;
60
+ }
61
+ resolve({ server, port: addr.port });
62
+ });
63
+ });
64
+ }
65
+
66
+ function closeServer(server: Server): Promise<void> {
67
+ return new Promise((resolve) => server.close(() => resolve()));
68
+ }
69
+
70
+ let handle: McpHandle | undefined;
71
+ let addToolSpy: MockInstance<FastMCP["addTool"]>;
72
+
73
+ function registeredToolNames(): string[] {
74
+ return addToolSpy.mock.calls.map((call) => (call[0] as { name: string }).name);
75
+ }
76
+
77
+ beforeEach(() => {
78
+ addToolSpy = vi.spyOn(FastMCP.prototype, "addTool");
79
+ });
80
+
81
+ afterEach(async () => {
82
+ if (handle) {
83
+ await handle[Symbol.asyncDispose]();
84
+ handle = undefined;
85
+ }
86
+ vi.restoreAllMocks();
87
+ vi.unstubAllEnvs();
88
+ });
89
+
90
+ describe("startMcpHttpServer", () => {
91
+ it("starts on a loopback port and serves the /mcp endpoint", async () => {
92
+ handle = await startMcpHttpServer(makeCtx());
93
+ expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
94
+
95
+ // the port is actually listening — a GET reaches the http-stream transport
96
+ const res = await fetch(handle.url);
97
+ expect(res.status).toBeGreaterThanOrEqual(200);
98
+ });
99
+
100
+ it("requires the per-run bearer token to open a session", async () => {
101
+ handle = await startMcpHttpServer(makeCtx());
102
+ expect(handle.token).toMatch(/[0-9a-f-]{16,}/i);
103
+
104
+ const initBody = JSON.stringify({
105
+ jsonrpc: "2.0",
106
+ id: 1,
107
+ method: "initialize",
108
+ params: {
109
+ protocolVersion: "2025-06-18",
110
+ capabilities: {},
111
+ clientInfo: { name: "test", version: "0" },
112
+ },
113
+ });
114
+ const baseHeaders = {
115
+ "content-type": "application/json",
116
+ accept: "application/json, text/event-stream",
117
+ };
118
+
119
+ // no Authorization header → the transport rejects before a session opens.
120
+ const unauth = await fetch(handle.url, {
121
+ method: "POST",
122
+ headers: baseHeaders,
123
+ body: initBody,
124
+ });
125
+ expect(unauth.status).toBe(401);
126
+
127
+ // wrong token → still rejected.
128
+ const wrong = await fetch(handle.url, {
129
+ method: "POST",
130
+ headers: { ...baseHeaders, authorization: "Bearer not-the-token" },
131
+ body: initBody,
132
+ });
133
+ expect(wrong.status).toBe(401);
134
+
135
+ // correct token → not rejected with 401 (the session proceeds).
136
+ const authed = await fetch(handle.url, {
137
+ method: "POST",
138
+ headers: { ...baseHeaders, authorization: `Bearer ${handle.token}` },
139
+ body: initBody,
140
+ });
141
+ expect(authed.status).not.toBe(401);
142
+ });
143
+
144
+ it("registers orchestrator tools including push/PR and standalone set_output", async () => {
145
+ handle = await startMcpHttpServer(makeCtx({ trigger: "unknown", shell: "restricted" }));
146
+
147
+ const names = registeredToolNames();
148
+ expect(names).toContain("git");
149
+ expect(names).toContain("push_branch");
150
+ expect(names).toContain("create_pull_request");
151
+ expect(names).toContain("select_mode");
152
+ expect(names).toContain("report_progress");
153
+ expect(names).toContain("terraform_scan");
154
+ // standalone trigger → set_output available even without an output schema
155
+ expect(names).toContain("set_output");
156
+ // restricted shell → MCP shell + kill tools
157
+ expect(names).toContain("shell");
158
+ });
159
+
160
+ it("omits set_output and shell tools when not standalone and shell is enabled", async () => {
161
+ handle = await startMcpHttpServer(makeCtx({ trigger: "issue_comment", shell: "enabled" }));
162
+
163
+ const names = registeredToolNames();
164
+ expect(names).not.toContain("set_output");
165
+ expect(names).not.toContain("shell");
166
+ expect(names).not.toContain("kill_background");
167
+ });
168
+
169
+ it("registers set_output for event runs when an output schema is provided", async () => {
170
+ handle = await startMcpHttpServer(makeCtx({ trigger: "issue_comment", shell: "enabled" }), {
171
+ outputSchema: {
172
+ type: "object",
173
+ properties: { verdict: { type: "string" } },
174
+ required: ["verdict"],
175
+ },
176
+ });
177
+
178
+ expect(registeredToolNames()).toContain("set_output");
179
+ });
180
+ });
181
+
182
+ describe("port selection", () => {
183
+ it("honors TERRAMEND_MCP_PORT when the port is free", async () => {
184
+ // find a free port, then release it for the server to claim
185
+ const { server, port } = await occupyPort();
186
+ await closeServer(server);
187
+
188
+ vi.stubEnv("TERRAMEND_MCP_PORT", String(port));
189
+ handle = await startMcpHttpServer(makeCtx());
190
+ expect(handle.url).toBe(`http://127.0.0.1:${port}/mcp`);
191
+ });
192
+
193
+ it("falls back to scanning when TERRAMEND_MCP_PORT is occupied", async () => {
194
+ const { server, port } = await occupyPort();
195
+ try {
196
+ vi.stubEnv("TERRAMEND_MCP_PORT", String(port));
197
+ handle = await startMcpHttpServer(makeCtx());
198
+ expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
199
+ expect(handle.url).not.toContain(`:${port}/`);
200
+ } finally {
201
+ await closeServer(server);
202
+ }
203
+ });
204
+
205
+ it("rejects an invalid TERRAMEND_MCP_PORT", async () => {
206
+ vi.stubEnv("TERRAMEND_MCP_PORT", "not-a-port");
207
+ await expect(startMcpHttpServer(makeCtx())).rejects.toThrow(
208
+ "invalid TERRAMEND_MCP_PORT: not-a-port",
209
+ );
210
+ });
211
+
212
+ it("rejects an out-of-range TERRAMEND_MCP_PORT", async () => {
213
+ vi.stubEnv("TERRAMEND_MCP_PORT", "70000");
214
+ await expect(startMcpHttpServer(makeCtx())).rejects.toThrow(
215
+ "invalid TERRAMEND_MCP_PORT: 70000",
216
+ );
217
+ });
218
+
219
+ it("retries the scan when a start loses the bind race (EADDRINUSE)", async () => {
220
+ const startSpy = vi.spyOn(FastMCP.prototype, "start");
221
+ startSpy.mockRejectedValueOnce(new Error("listen EADDRINUSE: address already in use"));
222
+
223
+ handle = await startMcpHttpServer(makeCtx());
224
+ expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
225
+ expect(startSpy.mock.calls.length).toBeGreaterThanOrEqual(2);
226
+ });
227
+
228
+ it("fails with a diagnostic when every candidate port loses the bind race", async () => {
229
+ // every start attempt loses the race: isPortAvailable says free, but the
230
+ // transport bind fails with EADDRINUSE. exhausts the whole scan range.
231
+ const startSpy = vi.spyOn(FastMCP.prototype, "start");
232
+ startSpy.mockRejectedValue(new Error("listen EADDRINUSE: address already in use"));
233
+
234
+ await expect(startMcpHttpServer(makeCtx())).rejects.toThrow(
235
+ /could not find available mcp port starting at 3764/,
236
+ );
237
+ }, 30_000);
238
+
239
+ it("rethrows non-EADDRINUSE start failures immediately", async () => {
240
+ const startSpy = vi.spyOn(FastMCP.prototype, "start");
241
+ startSpy.mockRejectedValue(new Error("transport exploded"));
242
+
243
+ await expect(startMcpHttpServer(makeCtx())).rejects.toThrow("transport exploded");
244
+ });
245
+ });
246
+
247
+ describe("disposal", () => {
248
+ it("stops the server and is idempotent on repeated dispose", async () => {
249
+ const localHandle = await startMcpHttpServer(makeCtx());
250
+ const url = localHandle.url;
251
+
252
+ await localHandle[Symbol.asyncDispose]();
253
+ const err = await fetch(url).catch((e: unknown) => e);
254
+ expect(err).toBeInstanceOf(Error);
255
+
256
+ // second dispose is a no-op, not a crash
257
+ await expect(localHandle[Symbol.asyncDispose]()).resolves.toBeUndefined();
258
+ });
259
+
260
+ it("SIGTERMs then SIGKILLs tracked background process groups on dispose", async () => {
261
+ const ctx = makeCtx();
262
+ ctx.toolState.backgroundProcesses.set("bg-1", {
263
+ pid: 424242,
264
+ outputPath: "/tmp/out.log",
265
+ pidPath: "/tmp/out.pid",
266
+ });
267
+
268
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
269
+ const localHandle = await startMcpHttpServer(ctx);
270
+ await localHandle[Symbol.asyncDispose]();
271
+
272
+ expect(killSpy).toHaveBeenCalledWith(-424242, "SIGTERM");
273
+ expect(killSpy).toHaveBeenCalledWith(-424242, "SIGKILL");
274
+ expect(ctx.toolState.backgroundProcesses.size).toBe(0);
275
+ });
276
+
277
+ it("survives kill() throwing for already-dead processes", async () => {
278
+ const ctx = makeCtx();
279
+ ctx.toolState.backgroundProcesses.set("bg-dead", {
280
+ pid: 434343,
281
+ outputPath: "/tmp/out.log",
282
+ pidPath: "/tmp/out.pid",
283
+ });
284
+
285
+ vi.spyOn(process, "kill").mockImplementation(() => {
286
+ throw new Error("ESRCH");
287
+ });
288
+ const localHandle = await startMcpHttpServer(ctx);
289
+ await expect(localHandle[Symbol.asyncDispose]()).resolves.toBeUndefined();
290
+ expect(ctx.toolState.backgroundProcesses.size).toBe(0);
291
+ });
292
+ });