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,83 @@
1
+ /**
2
+ * HashiCorp terraform-mcp-server integration (P2.2).
3
+ *
4
+ * When the `terraform_mcp` input is on, the agent harness registers a SECOND
5
+ * MCP server next to terramend's: HashiCorp's terraform-mcp-server, run as a
6
+ * Docker container over stdio. It gives the fixing agent live Terraform
7
+ * Registry knowledge — current module versions, provider argument shapes —
8
+ * which directly powers module-source-aware fixes and generation.
9
+ *
10
+ * Security posture:
11
+ * - the image is VERSION-PINNED (`TERRAFORM_MCP_IMAGE`); bump deliberately.
12
+ * (P4's SHA-pinning sweep will move this to a digest pin.)
13
+ * - only the read-only `registry` toolset is enabled — no TFE operations,
14
+ * and no TFE_TOKEN is ever passed.
15
+ * - degrades green: docker absent → a log note, never a failed run.
16
+ */
17
+
18
+ import { spawnSync } from "node:child_process";
19
+ import type { ResolvedPayload } from "#app/utils/payload";
20
+
21
+ /** pinned release of hashicorp/terraform-mcp-server. Bump deliberately. */
22
+ export const TERRAFORM_MCP_IMAGE = "hashicorp/terraform-mcp-server:0.5.2";
23
+
24
+ /** the registry name the server is registered under in agent MCP configs —
25
+ * matches HashiCorp's own client-config examples, so agent guidance written
26
+ * against "the terraform MCP server" finds it under the expected key. */
27
+ export const TERRAFORM_MCP_SERVER_NAME = "terraform";
28
+
29
+ /** stdio invocation, registry toolset ONLY (module/provider knowledge — the
30
+ * agent must never get TFE workspace operations from this surface). */
31
+ const TERRAFORM_MCP_DOCKER_ARGS = [
32
+ "run",
33
+ "-i",
34
+ "--rm",
35
+ TERRAFORM_MCP_IMAGE,
36
+ "--toolsets=registry",
37
+ ] as const;
38
+
39
+ export type TerraformMcpResolution =
40
+ | { kind: "disabled" }
41
+ | { kind: "docker_missing"; note: string }
42
+ | { kind: "available"; command: "docker"; args: string[] };
43
+
44
+ let cachedDockerAvailable: boolean | undefined;
45
+
46
+ /** test hook — the docker probe is cached per process. */
47
+ export function _clearDockerProbeCache(): void {
48
+ cachedDockerAvailable = undefined;
49
+ }
50
+
51
+ function dockerAvailable(): boolean {
52
+ if (cachedDockerAvailable === undefined) {
53
+ const probe = spawnSync("docker", ["--version"], {
54
+ stdio: "pipe",
55
+ encoding: "utf-8",
56
+ timeout: 10_000,
57
+ });
58
+ cachedDockerAvailable = !probe.error && probe.status === 0;
59
+ }
60
+ return cachedDockerAvailable;
61
+ }
62
+
63
+ /**
64
+ * Decide whether the run gets the terraform-mcp-server, as a discriminated
65
+ * union so each harness handles all three outcomes explicitly: register the
66
+ * server (`available`), log the degrade-green note (`docker_missing`), or do
67
+ * nothing (`disabled`).
68
+ */
69
+ export function resolveTerraformMcp(
70
+ payload: Pick<ResolvedPayload, "terraformMcp">,
71
+ ): TerraformMcpResolution {
72
+ if (!payload.terraformMcp) return { kind: "disabled" };
73
+ if (!dockerAvailable()) {
74
+ return {
75
+ kind: "docker_missing",
76
+ note:
77
+ "terraform_mcp requested but docker is not available on this runner — " +
78
+ "continuing without Terraform Registry MCP (module/provider knowledge falls " +
79
+ "back to the registry HTTP lookups in terraform_version_currency)",
80
+ };
81
+ }
82
+ return { kind: "available", command: "docker", args: [...TERRAFORM_MCP_DOCKER_ARGS] };
83
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isValidTimeString, parseTimeString, resolveTimeoutMs } from "#app/utils/time";
3
+
4
+ describe("parseTimeString", () => {
5
+ it.each([
6
+ ["10m", 600000], // 10 minutes
7
+ ["1h", 3600000], // 1 hour
8
+ ["30s", 30000], // 30 seconds
9
+ ["1h30m", 5400000], // 1 hour 30 minutes
10
+ ["10m12s", 612000], // 10 minutes 12 seconds
11
+ ["1h30m45s", 5445000], // 1 hour 30 minutes 45 seconds
12
+ ["2h", 7200000], // 2 hours
13
+ ["90m", 5400000], // 90 minutes
14
+ ["0m", 0], // 0 minutes (edge case)
15
+ ["0s", 0], // 0 seconds (edge case)
16
+ ])("parses '%s' to %d ms", (input, expected) => {
17
+ expect(parseTimeString(input)).toBe(expected);
18
+ });
19
+
20
+ it.each([
21
+ [""], // empty string
22
+ ["abc"], // no numbers
23
+ ["10"], // no unit
24
+ ["10x"], // invalid unit
25
+ ["h10m"], // hours without number
26
+ ["m10"], // units before number
27
+ ["10 m"], // space between number and unit
28
+ ["-10m"], // negative number
29
+ ["10.5m"], // decimal
30
+ ["10m 30s"], // space between components
31
+ ])("returns null for invalid input '%s'", (input) => {
32
+ expect(parseTimeString(input)).toBeNull();
33
+ });
34
+ });
35
+
36
+ describe("isValidTimeString", () => {
37
+ it.each([
38
+ "10m",
39
+ "1h",
40
+ "30s",
41
+ "1h30m",
42
+ "10m12s",
43
+ "1h30m45s",
44
+ ])("returns true for valid '%s'", (input) => {
45
+ expect(isValidTimeString(input)).toBe(true);
46
+ });
47
+
48
+ it.each(["", "abc", "10", "10x", "-10m", "10.5m"])("returns false for invalid '%s'", (input) => {
49
+ expect(isValidTimeString(input)).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe("resolveTimeoutMs", () => {
54
+ it.each([
55
+ ["1h", 3_600_000],
56
+ ["10m", 600_000],
57
+ ["1h30m", 5_400_000],
58
+ ])("returns ms for valid '%s'", (input, expected) => {
59
+ expect(resolveTimeoutMs(input)).toBe(expected);
60
+ });
61
+
62
+ it("returns null for undefined input (no timeout configured)", () => {
63
+ expect(resolveTimeoutMs(undefined)).toBeNull();
64
+ });
65
+
66
+ it.each([
67
+ ["0m"],
68
+ ["0s"],
69
+ ["0h"],
70
+ ["0h0m0s"],
71
+ ])("returns null for zero-value '%s' so the caller doesn't insta-timeout", (input) => {
72
+ // 0ms setTimeout fires in the same tick — without this guard, a user
73
+ // typo like "0m" rejected the run as "timed out after 0m" the instant
74
+ // it started. see also the matching payload.timeout handling in main.ts.
75
+ expect(resolveTimeoutMs(input)).toBeNull();
76
+ });
77
+
78
+ it.each([
79
+ ["abc"],
80
+ ["10"],
81
+ ["10x"],
82
+ ["-10m"],
83
+ ["10.5m"],
84
+ [""],
85
+ ])("returns null for unparseable input '%s'", (input) => {
86
+ expect(resolveTimeoutMs(input)).toBeNull();
87
+ });
88
+
89
+ it("returns null for values past node's setTimeout ceiling (~24.8 days)", () => {
90
+ // 2^31 - 1 ms = 2147483647 ms = 596h31m23s647ms. node silently clamps any
91
+ // delay above that down to 1ms — a user asking for "999h" would have the
92
+ // run terminate with "timed out after 999h" within a single tick. reject
93
+ // here so the caller's warn + fallback kicks in instead.
94
+ expect(resolveTimeoutMs("999h")).toBeNull();
95
+ // 600h = 2_160_000_000 ms, safely past the cap.
96
+ expect(resolveTimeoutMs("600h")).toBeNull();
97
+ });
98
+
99
+ it("accepts the largest value that setTimeout can still honor", () => {
100
+ // 596h31m23s = 2_147_483_000 ms — just under 2^31-1. this must remain
101
+ // usable so the "reject over-max" rule doesn't accidentally reject the
102
+ // boundary itself.
103
+ expect(resolveTimeoutMs("596h31m23s")).toBe(2_147_483_000);
104
+ });
105
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * time string parsing utilities for timeout configuration.
3
+ * supports formats like "10m", "1h30m", "10m12s", "30s".
4
+ */
5
+
6
+ // special value indicating timeout is explicitly disabled via --notimeout flag
7
+ export const TIMEOUT_DISABLED = "none";
8
+
9
+ // time string regex: supports formats like "10m", "1h30m", "10m12s", "30s"
10
+ // at least one component (hours, minutes, or seconds) is required
11
+ const TIME_STRING_REGEX = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/;
12
+
13
+ /**
14
+ * parse a time string like "10m", "1h30m", "10m12s" into milliseconds.
15
+ * returns null if the string is not a valid time format.
16
+ */
17
+ export function parseTimeString(input: string): number | null {
18
+ const match = input.match(TIME_STRING_REGEX);
19
+ if (!match || (!match[1] && !match[2] && !match[3])) return null;
20
+
21
+ const hours = parseInt(match[1] || "0", 10);
22
+ const minutes = parseInt(match[2] || "0", 10);
23
+ const seconds = parseInt(match[3] || "0", 10);
24
+
25
+ return (hours * 3600 + minutes * 60 + seconds) * 1000;
26
+ }
27
+
28
+ /**
29
+ * check if a string is a valid time format.
30
+ */
31
+ export function isValidTimeString(input: string): boolean {
32
+ return parseTimeString(input) !== null;
33
+ }
34
+
35
+ /**
36
+ * resolve a user-supplied timeout string into a setTimeout-safe number of
37
+ * milliseconds, returning null when the input is unusable.
38
+ *
39
+ * "unusable" covers three cases that all cause setTimeout to misbehave if
40
+ * passed through naively:
41
+ * - unparseable ("abc", "10x") — parseTimeString returns null.
42
+ * - zero ("0m", "0s") — setTimeout fires immediately, so the run would
43
+ * look like an insta-fail with the confusing message "timed out after 0m".
44
+ * - overflow (e.g. "999h") — node clamps any delay above 2^31-1 ms
45
+ * (~24.8 days) to 1 ms, so a user who asked for "596h" or more would
46
+ * get a timeout in a single tick instead of the multi-day window they
47
+ * requested. user almost certainly meant --notimeout.
48
+ *
49
+ * the caller should warn and fall back to its own default when this returns
50
+ * null; the reason is always "the input can't be honored" regardless of
51
+ * which branch triggered it.
52
+ */
53
+ const TIMEOUT_MAX_MS = 2_147_483_647;
54
+ export function resolveTimeoutMs(input: string | undefined): number | null {
55
+ if (!input) return null;
56
+ const parsed = parseTimeString(input);
57
+ if (parsed === null || parsed <= 0 || parsed > TIMEOUT_MAX_MS) return null;
58
+ return parsed;
59
+ }
@@ -0,0 +1,91 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ThinkingTimer, Timer } from "#app/utils/timer";
4
+
5
+ vi.mock("#app/utils/cli", () => ({
6
+ log: {
7
+ info: vi.fn(),
8
+ debug: vi.fn(),
9
+ warning: vi.fn(),
10
+ error: vi.fn(),
11
+ success: vi.fn(),
12
+ },
13
+ }));
14
+
15
+ import { log } from "#app/utils/cli";
16
+
17
+ const debugMock = vi.mocked(log.debug);
18
+ const infoMock = vi.mocked(log.info);
19
+
20
+ let nowSpy: ReturnType<typeof vi.spyOn>;
21
+ let now = 0;
22
+
23
+ function setNow(value: number): void {
24
+ now = value;
25
+ }
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ now = 0;
30
+ nowSpy = vi.spyOn(performance, "now").mockImplementation(() => now);
31
+ });
32
+
33
+ afterEach(() => {
34
+ nowSpy.mockRestore();
35
+ });
36
+
37
+ describe("Timer", () => {
38
+ it("measures the first checkpoint from construction", () => {
39
+ setNow(100);
40
+ const timer = new Timer();
41
+ setNow(350);
42
+ timer.checkpoint("startup");
43
+ expect(debugMock).toHaveBeenCalledWith("» startup: 250ms");
44
+ });
45
+
46
+ it("measures subsequent checkpoints from the previous one", () => {
47
+ setNow(0);
48
+ const timer = new Timer();
49
+ setNow(50);
50
+ timer.checkpoint("first");
51
+ setNow(80);
52
+ timer.checkpoint("second");
53
+ expect(debugMock).toHaveBeenLastCalledWith("» second: 30ms");
54
+ });
55
+ });
56
+
57
+ describe("ThinkingTimer", () => {
58
+ it("does nothing on a tool call without a prior tool result", () => {
59
+ const timer = new ThinkingTimer();
60
+ setNow(10_000);
61
+ timer.markToolCall();
62
+ expect(infoMock).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it("stays silent below the thinking threshold", () => {
66
+ const timer = new ThinkingTimer();
67
+ setNow(0);
68
+ timer.markToolResult();
69
+ setNow(2999);
70
+ timer.markToolCall();
71
+ expect(infoMock).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("logs the thinking duration once the threshold is crossed", () => {
75
+ const timer = new ThinkingTimer();
76
+ setNow(0);
77
+ timer.markToolResult();
78
+ setNow(4500);
79
+ timer.markToolCall();
80
+ expect(infoMock).toHaveBeenCalledWith("» thought for 4.5 seconds");
81
+ });
82
+
83
+ it("prefixes output with the caller-provided line formatter", () => {
84
+ const timer = new ThinkingTimer((line) => `[subagent] ${line}`);
85
+ setNow(0);
86
+ timer.markToolResult();
87
+ setNow(10_000);
88
+ timer.markToolCall();
89
+ expect(infoMock).toHaveBeenCalledWith("[subagent] » thought for 10 seconds");
90
+ });
91
+ });
@@ -0,0 +1,72 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { log } from "#app/utils/cli";
3
+
4
+ export class Timer {
5
+ private initialTimestamp: number;
6
+ private lastCheckpointTimestamp: number | null = null;
7
+
8
+ constructor() {
9
+ this.initialTimestamp = performance.now();
10
+ }
11
+
12
+ checkpoint(name: string): void {
13
+ const now = performance.now();
14
+ const duration = this.lastCheckpointTimestamp
15
+ ? now - this.lastCheckpointTimestamp
16
+ : now - this.initialTimestamp;
17
+
18
+ log.debug(`» ${name}: ${duration}ms`);
19
+ this.lastCheckpointTimestamp = now;
20
+ }
21
+ }
22
+
23
+ const THINKING_THRESHOLD = 3000; // ms
24
+
25
+ /**
26
+ * Measures wall-clock gap between the last tool_result and the next tool_call,
27
+ * surfacing it as a "thought for Xs" log when over `THINKING_THRESHOLD`.
28
+ *
29
+ * Use one instance per logical session (orchestrator, each subagent) — sharing
30
+ * a single timer across sessions conflates cross-session interleaving as
31
+ * thinking time. The optional `formatLine` lets the caller prefix output with
32
+ * a session label so attribution is visible in the merged log stream.
33
+ */
34
+ export class ThinkingTimer {
35
+ private readonly durationFormatter = new Intl.NumberFormat("en-US", {
36
+ style: "unit",
37
+ unit: "second",
38
+ unitDisplay: "long",
39
+ minimumFractionDigits: 0,
40
+ maximumFractionDigits: 1,
41
+ });
42
+
43
+ private lastToolResultTimestamp: number | null = null;
44
+ private readonly formatLine: (line: string) => string;
45
+
46
+ // node's native TS strip-only mode does not support parameter properties,
47
+ // so the formatter is declared as a field and assigned in the body.
48
+ constructor(formatLine: (line: string) => string = (l) => l) {
49
+ this.formatLine = formatLine;
50
+ }
51
+
52
+ markToolResult(): void {
53
+ this.lastToolResultTimestamp = performance.now();
54
+ log.debug(
55
+ this.formatLine(`» thinking timer: markToolResult at ${this.lastToolResultTimestamp}`),
56
+ );
57
+ }
58
+
59
+ markToolCall(): void {
60
+ const now = performance.now();
61
+ log.debug(
62
+ this.formatLine(
63
+ `» thinking timer: markToolCall at ${now}, lastToolResult=${this.lastToolResultTimestamp}`,
64
+ ),
65
+ );
66
+ if (this.lastToolResultTimestamp === null) return;
67
+ const elapsed = now - this.lastToolResultTimestamp;
68
+ if (elapsed < THINKING_THRESHOLD) return;
69
+ const seconds = elapsed / 1000;
70
+ log.info(this.formatLine(`» thought for ${this.durationFormatter.format(seconds)}`));
71
+ }
72
+ }
@@ -0,0 +1,223 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createTodoTracker } from "#app/utils/todoTracking";
3
+
4
+ vi.mock("#app/utils/log", () => ({
5
+ log: {
6
+ info: vi.fn(),
7
+ debug: vi.fn(),
8
+ warning: vi.fn(),
9
+ error: vi.fn(),
10
+ success: vi.fn(),
11
+ },
12
+ }));
13
+
14
+ const DEBOUNCE_MS = 2000;
15
+
16
+ function todos(...items: { content: string; status?: string; id?: string }[]): unknown {
17
+ return { todos: items };
18
+ }
19
+
20
+ describe("createTodoTracker", () => {
21
+ beforeEach(() => {
22
+ vi.useFakeTimers();
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.useRealTimers();
27
+ });
28
+
29
+ it("publishes rendered markdown after the debounce window", async () => {
30
+ const onUpdate = vi.fn(async (_body: string) => undefined);
31
+ const tracker = createTodoTracker(onUpdate);
32
+
33
+ tracker.update(
34
+ todos(
35
+ { content: "done item", status: "completed" },
36
+ { content: "dropped item", status: "cancelled" },
37
+ { content: "active item", status: "in_progress" },
38
+ { content: "queued item", status: "pending" },
39
+ ),
40
+ );
41
+
42
+ expect(onUpdate).not.toHaveBeenCalled();
43
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
44
+ await tracker.settled();
45
+
46
+ expect(onUpdate).toHaveBeenCalledTimes(1);
47
+ const body = onUpdate.mock.calls[0]?.[0];
48
+ expect(body).toContain("- [x] done item");
49
+ expect(body).toContain("- ~~dropped item~~");
50
+ expect(body).toContain("active item");
51
+ expect(body).toContain("- [ ] queued item");
52
+ expect(tracker.hasPublished).toBe(true);
53
+ });
54
+
55
+ it("ignores inputs that are not valid todowrite payloads", async () => {
56
+ const onUpdate = vi.fn(async () => undefined);
57
+ const tracker = createTodoTracker(onUpdate);
58
+
59
+ tracker.update(undefined);
60
+ tracker.update("nope");
61
+ tracker.update({ noTodos: true });
62
+ tracker.update({ todos: "not-an-array" });
63
+
64
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS * 2);
65
+ await tracker.settled();
66
+ expect(onUpdate).not.toHaveBeenCalled();
67
+ expect(tracker.hasPublished).toBe(false);
68
+ });
69
+
70
+ it("skips malformed entries and defaults id/status", async () => {
71
+ const onUpdate = vi.fn(async (_body: string) => undefined);
72
+ const tracker = createTodoTracker(onUpdate);
73
+
74
+ tracker.update({
75
+ todos: [
76
+ null,
77
+ "string entry",
78
+ { noContent: true },
79
+ { content: "no status entry" },
80
+ { content: "bad status entry", status: "exploded" },
81
+ { content: "with id", id: "custom", status: "completed" },
82
+ ],
83
+ });
84
+
85
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
86
+ await tracker.settled();
87
+
88
+ const body = onUpdate.mock.calls[0]?.[0];
89
+ expect(body).toBe("- [ ] no status entry\n- [ ] bad status entry\n- [x] with id");
90
+ });
91
+
92
+ it("replaces state by default and merges when merge=true", () => {
93
+ const onUpdate = vi.fn(async () => undefined);
94
+ const tracker = createTodoTracker(onUpdate);
95
+
96
+ tracker.update({ todos: [{ content: "first", id: "a" }] });
97
+ tracker.update({ todos: [{ content: "second", id: "b" }] });
98
+ expect(tracker.renderCollapsible()).toContain("second");
99
+ expect(tracker.renderCollapsible()).not.toContain("first");
100
+
101
+ tracker.update({
102
+ todos: [{ content: "merged", id: "a", status: "completed" }],
103
+ merge: true,
104
+ });
105
+ const rendered = tracker.renderCollapsible();
106
+ expect(rendered).toContain("merged");
107
+ expect(rendered).toContain("second");
108
+ });
109
+
110
+ it("does not publish when the debounce fires with an empty state", async () => {
111
+ const onUpdate = vi.fn(async () => undefined);
112
+ const tracker = createTodoTracker(onUpdate);
113
+
114
+ tracker.update({ todos: [] });
115
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
116
+ await tracker.settled();
117
+ expect(onUpdate).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it("flush publishes immediately and clears the pending debounce", async () => {
121
+ const onUpdate = vi.fn(async () => undefined);
122
+ const tracker = createTodoTracker(onUpdate);
123
+
124
+ tracker.update(todos({ content: "task" }));
125
+ await tracker.flush();
126
+
127
+ expect(onUpdate).toHaveBeenCalledTimes(1);
128
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS * 2);
129
+ await tracker.settled();
130
+ // the pending debounce was cleared — no second publish
131
+ expect(onUpdate).toHaveBeenCalledTimes(1);
132
+ });
133
+
134
+ it("flush is a no-op when state is empty", async () => {
135
+ const onUpdate = vi.fn(async () => undefined);
136
+ const tracker = createTodoTracker(onUpdate);
137
+ await tracker.flush();
138
+ expect(onUpdate).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("swallows onUpdate failures on flush and stays unpublished", async () => {
142
+ const onUpdate = vi.fn(async () => {
143
+ throw new Error("boom");
144
+ });
145
+ const tracker = createTodoTracker(onUpdate);
146
+
147
+ tracker.update(todos({ content: "task" }));
148
+ await tracker.flush();
149
+
150
+ expect(onUpdate).toHaveBeenCalledTimes(1);
151
+ expect(tracker.hasPublished).toBe(false);
152
+ });
153
+
154
+ it("swallows onUpdate failures from the debounced path", async () => {
155
+ const onUpdate = vi.fn(async () => {
156
+ throw new Error("boom");
157
+ });
158
+ const tracker = createTodoTracker(onUpdate);
159
+
160
+ tracker.update(todos({ content: "task" }));
161
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
162
+ await tracker.settled();
163
+
164
+ expect(onUpdate).toHaveBeenCalledTimes(1);
165
+ expect(tracker.hasPublished).toBe(false);
166
+ });
167
+
168
+ it("cancel disables the tracker and clears the pending debounce", async () => {
169
+ const onUpdate = vi.fn(async () => undefined);
170
+ const tracker = createTodoTracker(onUpdate);
171
+
172
+ tracker.update(todos({ content: "task" }));
173
+ expect(tracker.enabled).toBe(true);
174
+ tracker.cancel();
175
+ expect(tracker.enabled).toBe(false);
176
+
177
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS * 2);
178
+ await tracker.settled();
179
+ expect(onUpdate).not.toHaveBeenCalled();
180
+
181
+ // updates and flushes after cancel are no-ops
182
+ tracker.update(todos({ content: "late" }));
183
+ await tracker.flush();
184
+ expect(onUpdate).not.toHaveBeenCalled();
185
+
186
+ // cancel again exercises the no-pending-timer path
187
+ tracker.cancel();
188
+ });
189
+
190
+ it("completeInProgress flips in_progress items to completed in state", async () => {
191
+ const onUpdate = vi.fn(async () => undefined);
192
+ const tracker = createTodoTracker(onUpdate);
193
+
194
+ tracker.update(
195
+ todos({ content: "active", status: "in_progress" }, { content: "queued", status: "pending" }),
196
+ );
197
+ tracker.completeInProgress();
198
+
199
+ const rendered = tracker.renderCollapsible();
200
+ expect(rendered).toContain("- [x] active");
201
+ expect(rendered).toContain("- [ ] queued");
202
+ });
203
+
204
+ it("renderCollapsible returns empty string with no state", () => {
205
+ const tracker = createTodoTracker(vi.fn(async () => undefined));
206
+ expect(tracker.renderCollapsible()).toBe("");
207
+ });
208
+
209
+ it("renderCollapsible can complete in-progress items without mutating state", () => {
210
+ const tracker = createTodoTracker(vi.fn(async () => undefined));
211
+ tracker.update(
212
+ todos({ content: "active", status: "in_progress" }, { content: "done", status: "completed" }),
213
+ );
214
+
215
+ const completed = tracker.renderCollapsible({ completeInProgress: true });
216
+ expect(completed).toContain("Task list (2/2 completed)");
217
+ expect(completed).toContain("- [x] active");
218
+
219
+ // state itself stays in_progress
220
+ const plain = tracker.renderCollapsible();
221
+ expect(plain).toContain("Task list (1/2 completed)");
222
+ });
223
+ });