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,198 @@
1
+ import { LIFECYCLE_HOOK_TIMEOUT_MS } from "#app/lifecycle";
2
+ import { log } from "#app/utils/cli";
3
+ import {
4
+ SPAWN_ACTIVITY_TIMEOUT_CODE,
5
+ SPAWN_TIMEOUT_CODE,
6
+ SpawnTimeoutError,
7
+ spawn,
8
+ } from "#app/utils/subprocess";
9
+
10
+ export interface ExecuteLifecycleHookParams {
11
+ event: string;
12
+ script: string | null;
13
+ /**
14
+ * when true, after the hook runs (success or failure), discard tracked-file
15
+ * mods so the agent doesn't see hook-generated drift (e.g. `pnpm install`
16
+ * rewriting a lockfile). untracked files are preserved — hooks that
17
+ * intentionally materialize files (e.g. a `.env` from a template) stay
18
+ * visible to the agent. skipped (with a warning) if the tree had
19
+ * pre-existing tracked changes before the hook ran, so we never clobber
20
+ * pre-existing work; pre-existing untracked files are ignored for this
21
+ * gate because `git restore --staged --worktree .` doesn't touch them
22
+ * anyway. no-op when no script was configured.
23
+ */
24
+ normalizeWorkingTreeAfter?: boolean;
25
+ }
26
+
27
+ /** structured failure info — `output` on the `exit` variant is trimmed
28
+ * stderr, falling back to stdout when stderr is empty. */
29
+ export type LifecycleHookFailure =
30
+ | { kind: "exit"; exitCode: number; output: string }
31
+ | { kind: "timeout" }
32
+ | { kind: "spawn"; spawnError: string };
33
+
34
+ /** one-line, agent-facing description of a hook failure. empty string when
35
+ * there was no failure, so callers can pass the result straight through to a
36
+ * prompt section that omits itself on empty. */
37
+ export function describeSetupFailure(failure: LifecycleHookFailure | undefined): string {
38
+ if (!failure) return "";
39
+ switch (failure.kind) {
40
+ case "exit":
41
+ return `It exited with code ${failure.exitCode}. Output:\n\n${failure.output || "(empty)"}`;
42
+ case "timeout":
43
+ return "It timed out and was killed before completing.";
44
+ case "spawn":
45
+ return `It failed to start: ${failure.spawnError}`;
46
+ default: {
47
+ const _exhaustive: never = failure;
48
+ return _exhaustive satisfies never;
49
+ }
50
+ }
51
+ }
52
+
53
+ export interface LifecycleHookResult {
54
+ /**
55
+ * human-readable warning when the hook failed. includes retry guidance:
56
+ * transient spawn/exit errors are worth retrying, timeouts and
57
+ * persistent failures are not. absent when the hook succeeded or was
58
+ * skipped. setup/post-checkout callers surface this verbatim; prepush
59
+ * builds its own message from `failure` instead.
60
+ */
61
+ warning?: string;
62
+ /**
63
+ * structured failure info — undefined when the hook succeeded or was
64
+ * skipped. lets callers compose their own messaging without parsing the
65
+ * `warning` string.
66
+ */
67
+ failure?: LifecycleHookFailure;
68
+ }
69
+
70
+ /**
71
+ * execute a lifecycle hook script if one is configured.
72
+ *
73
+ * soft-fails: instead of throwing on hook errors, returns a warning string
74
+ * (and structured failure info) so callers can choose how to surface it
75
+ * (mcp tools relay it to the agent; setup logs it and adds a prompt banner).
76
+ * timeouts are flagged as non-retryable in the warning text.
77
+ */
78
+ export async function executeLifecycleHook(
79
+ params: ExecuteLifecycleHookParams,
80
+ ): Promise<LifecycleHookResult> {
81
+ if (!params.script) return {};
82
+
83
+ log.info(`» executing ${params.event} lifecycle hook...`);
84
+
85
+ // snapshot tracked-file mods BEFORE the hook runs so we can distinguish
86
+ // hook-generated drift from pre-existing work. both hook windows should
87
+ // start clean in normal operation (setup runs before any working-tree
88
+ // writes; checkout_pr refuses to run with a dirty tree), but if that
89
+ // invariant breaks we'd rather warn than discard whatever was there.
90
+ // pre-existing untracked files don't matter here — `git restore --staged
91
+ // --worktree .` never touches untracked files, so they're never at risk.
92
+ const preHookTrackedCount = params.normalizeWorkingTreeAfter
93
+ ? (await runGitLines(["diff", "--name-only", "HEAD"])).length
94
+ : 0;
95
+
96
+ // single try/finally so normalization fires on success AND failure paths.
97
+ // a hook that fails partway through (e.g. `pnpm install` updates the
98
+ // lockfile then explodes on a peer-dep conflict) leaves the same kind of
99
+ // drift a successful run does, and the agent will see it next regardless
100
+ // of which path we took. failure-mode messaging is unchanged; the only
101
+ // delta is that we don't return tracked drift to the agent.
102
+ let result: LifecycleHookResult;
103
+ try {
104
+ try {
105
+ const spawnResult = await spawn({
106
+ cmd: "bash",
107
+ args: ["-c", params.script],
108
+ env: process.env,
109
+ timeout: LIFECYCLE_HOOK_TIMEOUT_MS,
110
+ activityTimeout: 0,
111
+ onStdout: (chunk) => process.stdout.write(chunk),
112
+ onStderr: (chunk) => process.stderr.write(chunk),
113
+ });
114
+
115
+ if (spawnResult.exitCode !== 0) {
116
+ const output = (spawnResult.stderr || spawnResult.stdout).trim();
117
+ result = {
118
+ failure: { kind: "exit", output, exitCode: spawnResult.exitCode },
119
+ warning:
120
+ `lifecycle hook '${params.event}' failed with exit code ${spawnResult.exitCode}. ` +
121
+ `output: ${output || "(empty)"}. ` +
122
+ `retry the operation if the failure looks flaky (network blips, transient rate limits). ` +
123
+ `do NOT retry if the script is broken (missing commands, syntax errors) or the error is persistent.`,
124
+ };
125
+ } else {
126
+ log.info(`» ${params.event} lifecycle hook completed successfully`);
127
+ result = {};
128
+ }
129
+ } catch (err) {
130
+ const isTimeout =
131
+ err instanceof SpawnTimeoutError &&
132
+ (err.code === SPAWN_TIMEOUT_CODE || err.code === SPAWN_ACTIVITY_TIMEOUT_CODE);
133
+ if (isTimeout) {
134
+ const minutes = Math.round(LIFECYCLE_HOOK_TIMEOUT_MS / 60000);
135
+ result = {
136
+ failure: { kind: "timeout" },
137
+ warning:
138
+ `lifecycle hook '${params.event}' timed out after ${minutes}min. ` +
139
+ `do NOT retry — the script is likely hung or doing too much work. ` +
140
+ `ask the repo owner to simplify the hook (e.g. move long-running work out of the hook, add caching, or split it).`,
141
+ };
142
+ } else {
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ result = {
145
+ failure: { kind: "spawn", spawnError: msg },
146
+ warning:
147
+ `lifecycle hook '${params.event}' failed to spawn: ${msg}. ` +
148
+ `this is likely a transient failure — retry the operation.`,
149
+ };
150
+ }
151
+ }
152
+ } finally {
153
+ if (params.normalizeWorkingTreeAfter) {
154
+ await normalizeWorkingTreeAfterHook({ event: params.event, preHookTrackedCount });
155
+ }
156
+ }
157
+ return result;
158
+ }
159
+
160
+ /**
161
+ * discard tracked-file mods left by a lifecycle hook so the agent's next
162
+ * `git status` matches the pre-hook state. untracked files (e.g. a `.env`
163
+ * the hook materialized from a template) are left alone — the agent decides
164
+ * what to do with them. skipped (with a warning) when the tree had
165
+ * pre-existing tracked changes before the hook ran, so pre-existing work
166
+ * is never clobbered. idempotent: a second call on a clean tree is a no-op
167
+ * and stays quiet.
168
+ */
169
+ async function normalizeWorkingTreeAfterHook(params: {
170
+ event: string;
171
+ preHookTrackedCount: number;
172
+ }): Promise<void> {
173
+ if (params.preHookTrackedCount > 0) {
174
+ log.warning(
175
+ `» working tree had ${params.preHookTrackedCount} pre-existing tracked changes before ${params.event} hook; ` +
176
+ `skipping post-hook normalization to avoid clobbering pre-existing work`,
177
+ );
178
+ return;
179
+ }
180
+ const trackedCount = (await runGitLines(["diff", "--name-only", "HEAD"])).length;
181
+ if (trackedCount === 0) return;
182
+ await runGit(["restore", "--staged", "--worktree", "."]);
183
+ log.info(`» discarded ${trackedCount} tracked changes from ${params.event} hook`);
184
+ }
185
+
186
+ async function runGit(args: string[]): Promise<string> {
187
+ const result = await spawn({ cmd: "git", args, env: process.env, activityTimeout: 0 });
188
+ if (result.exitCode !== 0) {
189
+ throw new Error(
190
+ `git ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr.trim() || "(no stderr)"}`,
191
+ );
192
+ }
193
+ return result.stdout;
194
+ }
195
+
196
+ async function runGitLines(args: string[]): Promise<string[]> {
197
+ return (await runGit(args)).split("\n").filter(Boolean);
198
+ }
@@ -0,0 +1,402 @@
1
+ import * as core from "@actions/core";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { AgentUsage } from "#app/agents/shared";
4
+ import {
5
+ formatIndentedField,
6
+ formatJsonValue,
7
+ formatUsageSummary,
8
+ log,
9
+ withLogPrefix,
10
+ writeSummary,
11
+ } from "#app/utils/log";
12
+
13
+ const coreMock = vi.hoisted(() => {
14
+ const summary = {
15
+ addRaw: vi.fn(),
16
+ write: vi.fn(),
17
+ };
18
+ summary.addRaw.mockReturnValue(summary);
19
+ summary.write.mockResolvedValue(summary);
20
+ return {
21
+ info: vi.fn(),
22
+ warning: vi.fn(),
23
+ error: vi.fn(),
24
+ debug: vi.fn(),
25
+ notice: vi.fn(),
26
+ isDebug: vi.fn(() => false),
27
+ startGroup: vi.fn(),
28
+ endGroup: vi.fn(),
29
+ summary,
30
+ };
31
+ });
32
+
33
+ vi.mock("@actions/core", () => coreMock);
34
+
35
+ const globalsState = vi.hoisted(() => ({ isGitHubActions: false, isInsideDocker: false }));
36
+
37
+ vi.mock("#app/utils/globals", () => ({
38
+ get isGitHubActions() {
39
+ return globalsState.isGitHubActions;
40
+ },
41
+ get isInsideDocker() {
42
+ return globalsState.isInsideDocker;
43
+ },
44
+ }));
45
+
46
+ function lastInfoMessage(): string {
47
+ const call = vi.mocked(core.info).mock.calls.at(-1);
48
+ if (!call) throw new Error("expected core.info to have been called");
49
+ return call[0];
50
+ }
51
+
52
+ afterEach(() => {
53
+ vi.clearAllMocks();
54
+ // clearAllMocks keeps mockReturnValue overrides — restore the default
55
+ coreMock.isDebug.mockReturnValue(false);
56
+ vi.unstubAllEnvs();
57
+ globalsState.isGitHubActions = false;
58
+ globalsState.isInsideDocker = false;
59
+ });
60
+
61
+ describe("log.info / warning / error / success", () => {
62
+ it("joins string, object, and Error arguments into one line", () => {
63
+ vi.stubEnv("LOG_LEVEL", "");
64
+ vi.stubEnv("ACTIONS_STEP_DEBUG", "");
65
+ const error = new Error("kaboom");
66
+ log.info("hello", { a: 1 }, error);
67
+
68
+ const message = lastInfoMessage();
69
+ expect(message).toContain("hello");
70
+ expect(message).toContain('{"a":1}');
71
+ expect(message).toContain("kaboom");
72
+ expect(message).toContain(`${error.stack}`);
73
+ });
74
+
75
+ it("routes warnings and errors to the matching core methods", () => {
76
+ vi.stubEnv("LOG_LEVEL", "");
77
+ vi.stubEnv("ACTIONS_STEP_DEBUG", "");
78
+ log.warning("careful");
79
+ log.error("broken");
80
+ expect(core.warning).toHaveBeenCalledWith("careful");
81
+ expect(core.error).toHaveBeenCalledWith("broken");
82
+ });
83
+
84
+ it("prefixes success messages with a chevron", () => {
85
+ vi.stubEnv("LOG_LEVEL", "");
86
+ vi.stubEnv("ACTIONS_STEP_DEBUG", "");
87
+ log.success("done");
88
+ expect(lastInfoMessage()).toBe("» done");
89
+ });
90
+
91
+ it("adds an ISO timestamp when debug mode is enabled", () => {
92
+ vi.stubEnv("LOG_LEVEL", "debug");
93
+ log.info("timed");
94
+ expect(lastInfoMessage()).toMatch(/^\[\d{4}-\d{2}-\d{2}T.*\] timed$/);
95
+ });
96
+ });
97
+
98
+ describe("withLogPrefix", () => {
99
+ it("prefixes every line of a multi-line message in magenta", async () => {
100
+ vi.stubEnv("LOG_LEVEL", "");
101
+ vi.stubEnv("ACTIONS_STEP_DEBUG", "");
102
+ await withLogPrefix("[task]", async () => {
103
+ log.info("line one\nline two");
104
+ });
105
+
106
+ const message = lastInfoMessage();
107
+ const lines = message.split("\n");
108
+ expect(lines).toHaveLength(2);
109
+ for (const line of lines) {
110
+ expect(line).toContain("\x1b[35m[task]\x1b[0m ");
111
+ }
112
+ });
113
+
114
+ it("does not prefix messages logged outside the context", () => {
115
+ vi.stubEnv("LOG_LEVEL", "");
116
+ vi.stubEnv("ACTIONS_STEP_DEBUG", "");
117
+ log.info("plain");
118
+ expect(lastInfoMessage()).toBe("plain");
119
+ });
120
+ });
121
+
122
+ describe("log.debug", () => {
123
+ it("uses core.debug when the runner debug flag is on", () => {
124
+ vi.mocked(core.isDebug).mockReturnValue(true);
125
+ log.debug("runner-debug");
126
+ expect(core.debug).toHaveBeenCalledWith("runner-debug");
127
+ expect(core.info).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("falls back to core.info with a [DEBUG] marker when LOG_LEVEL=debug", () => {
131
+ vi.stubEnv("LOG_LEVEL", "debug");
132
+ log.debug("local-debug");
133
+ expect(core.debug).not.toHaveBeenCalled();
134
+ expect(lastInfoMessage()).toMatch(/\[DEBUG\] local-debug$/);
135
+ });
136
+
137
+ it("also honors ACTIONS_STEP_DEBUG=true for local debug", () => {
138
+ vi.stubEnv("LOG_LEVEL", "");
139
+ vi.stubEnv("ACTIONS_STEP_DEBUG", "true");
140
+ log.debug("step-debug");
141
+ expect(lastInfoMessage()).toMatch(/\[DEBUG\] step-debug$/);
142
+ });
143
+
144
+ it("is silent when no debug mode is enabled", () => {
145
+ vi.stubEnv("LOG_LEVEL", "");
146
+ vi.stubEnv("ACTIONS_STEP_DEBUG", "");
147
+ log.debug("quiet");
148
+ expect(core.debug).not.toHaveBeenCalled();
149
+ expect(core.info).not.toHaveBeenCalled();
150
+ });
151
+ });
152
+
153
+ describe("groups", () => {
154
+ it("uses core groups in GitHub Actions, with the plain prefix", async () => {
155
+ globalsState.isGitHubActions = true;
156
+ await withLogPrefix("[task]", async () => {
157
+ log.startGroup("setup");
158
+ });
159
+ log.endGroup();
160
+ expect(core.startGroup).toHaveBeenCalledWith("[task] setup");
161
+ expect(core.endGroup).toHaveBeenCalled();
162
+ });
163
+
164
+ it("uses console groups locally", () => {
165
+ const groupSpy = vi.spyOn(console, "group").mockImplementation(() => {});
166
+ const groupEndSpy = vi.spyOn(console, "groupEnd").mockImplementation(() => {});
167
+
168
+ log.startGroup("local");
169
+ log.endGroup();
170
+
171
+ expect(groupSpy).toHaveBeenCalledWith("local");
172
+ expect(groupEndSpy).toHaveBeenCalled();
173
+ groupSpy.mockRestore();
174
+ groupEndSpy.mockRestore();
175
+ });
176
+
177
+ it("log.group runs the callback between start and end", () => {
178
+ globalsState.isGitHubActions = true;
179
+ const fn = vi.fn(() => {
180
+ expect(core.startGroup).toHaveBeenCalled();
181
+ expect(core.endGroup).not.toHaveBeenCalled();
182
+ });
183
+ log.group("wrapped", fn);
184
+ expect(fn).toHaveBeenCalled();
185
+ expect(core.endGroup).toHaveBeenCalled();
186
+ });
187
+ });
188
+
189
+ describe("log.box", () => {
190
+ it("draws a box with a title line", () => {
191
+ log.box("hello", { title: "Greeting" });
192
+ const message = lastInfoMessage();
193
+ expect(message).toContain("┌ Greeting ");
194
+ expect(message).toContain("│ hello");
195
+ expect(message).toContain("└");
196
+ });
197
+
198
+ it("draws a box without a title", () => {
199
+ log.box("hello");
200
+ const message = lastInfoMessage();
201
+ expect(message).toMatch(/┌─+┐/);
202
+ expect(message).toContain("│ hello │");
203
+ });
204
+
205
+ it("wraps long lines at maxWidth", () => {
206
+ log.box("alpha beta gamma delta", { maxWidth: 14 });
207
+ const message = lastInfoMessage();
208
+ const contentLines = message.split("\n").filter((line) => line.startsWith("│"));
209
+ expect(contentLines.length).toBeGreaterThan(1);
210
+ for (const line of contentLines) {
211
+ expect(line.length).toBeLessThanOrEqual(14 + 2);
212
+ }
213
+ });
214
+
215
+ it("breaks words longer than the box width into chunks", () => {
216
+ log.box("abcdefghijklmnopqrstuvwxyz", { maxWidth: 12 });
217
+ const message = lastInfoMessage();
218
+ expect(message).toContain("abcdefghij");
219
+ expect(message).toContain("klmnopqrst");
220
+ expect(message).toContain("uvwxyz");
221
+ });
222
+
223
+ it("handles a word that splits into exact chunks with no remainder", () => {
224
+ // 20 chars with maxWidth 12 (padding 1) → two exact 10-char chunks
225
+ log.box("abcdefghijklmnopqrst", { maxWidth: 12 });
226
+ const message = lastInfoMessage();
227
+ const contentLines = message.split("\n").filter((line) => line.startsWith("│"));
228
+ expect(contentLines).toHaveLength(2);
229
+ expect(message).toContain("abcdefghij");
230
+ expect(message).toContain("klmnopqrst");
231
+ });
232
+
233
+ it("drops a trailing empty word left over after wrapping", () => {
234
+ // the inner line ends with a space: the final empty word forces a wrap
235
+ // that leaves nothing to flush after the loop
236
+ log.box("abcdefghij \nnext", { maxWidth: 12 });
237
+ const message = lastInfoMessage();
238
+ const contentLines = message.split("\n").filter((line) => line.startsWith("│"));
239
+ expect(contentLines).toHaveLength(2);
240
+ expect(message).toContain("abcdefghij");
241
+ expect(message).toContain("next");
242
+ });
243
+ });
244
+
245
+ describe("log.table", () => {
246
+ it("renders header objects and plain string cells", () => {
247
+ log.table([
248
+ [{ data: "Name", header: true }, "Value"],
249
+ ["tokens", "42"],
250
+ ]);
251
+ const message = lastInfoMessage();
252
+ expect(message).toContain("Name");
253
+ expect(message).toContain("tokens");
254
+ expect(message).toContain("42");
255
+ });
256
+
257
+ it("prints the title before the table when provided", () => {
258
+ log.table([["only"]], { title: "Usage" });
259
+ const calls = vi.mocked(core.info).mock.calls.map((call) => call[0]);
260
+ expect(calls.some((message) => message.includes("Usage"))).toBe(true);
261
+ expect(calls).toHaveLength(2);
262
+ });
263
+ });
264
+
265
+ describe("log.separator", () => {
266
+ it("prints 50 dashes by default", () => {
267
+ log.separator();
268
+ expect(lastInfoMessage()).toBe("─".repeat(50));
269
+ });
270
+
271
+ it("honors a custom length", () => {
272
+ log.separator(3);
273
+ expect(lastInfoMessage()).toBe("───");
274
+ });
275
+ });
276
+
277
+ describe("log.toolCall", () => {
278
+ it("renders empty input as a bare call", () => {
279
+ vi.stubEnv("LOG_LEVEL", "");
280
+ vi.stubEnv("ACTIONS_STEP_DEBUG", "");
281
+ log.toolCall({ toolName: "Read", input: {} });
282
+ expect(lastInfoMessage()).toBe("» Read()");
283
+ });
284
+
285
+ it("renders compact JSON input inline", () => {
286
+ vi.stubEnv("LOG_LEVEL", "");
287
+ vi.stubEnv("ACTIONS_STEP_DEBUG", "");
288
+ log.toolCall({ toolName: "Read", input: { file: "a.ts" } });
289
+ expect(lastInfoMessage()).toBe('» Read({"file":"a.ts"})');
290
+ });
291
+ });
292
+
293
+ describe("writeSummary", () => {
294
+ it("does nothing outside GitHub Actions", async () => {
295
+ await writeSummary("text");
296
+ expect(coreMock.summary.addRaw).not.toHaveBeenCalled();
297
+ });
298
+
299
+ it("does nothing inside Docker even in GitHub Actions", async () => {
300
+ globalsState.isGitHubActions = true;
301
+ globalsState.isInsideDocker = true;
302
+ await writeSummary("text");
303
+ expect(coreMock.summary.addRaw).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it("does nothing when GITHUB_STEP_SUMMARY is unset", async () => {
307
+ globalsState.isGitHubActions = true;
308
+ vi.stubEnv("GITHUB_STEP_SUMMARY", "");
309
+ await writeSummary("text");
310
+ expect(coreMock.summary.addRaw).not.toHaveBeenCalled();
311
+ });
312
+
313
+ it("overwrites the job summary when fully configured", async () => {
314
+ globalsState.isGitHubActions = true;
315
+ vi.stubEnv("GITHUB_STEP_SUMMARY", "/tmp/summary.md");
316
+ await writeSummary("# report");
317
+ expect(coreMock.summary.addRaw).toHaveBeenCalledWith("# report");
318
+ expect(coreMock.summary.write).toHaveBeenCalledWith({ overwrite: true });
319
+ });
320
+ });
321
+
322
+ describe("formatJsonValue", () => {
323
+ it("uses compact JSON for short values", () => {
324
+ expect(formatJsonValue({ a: 1 })).toBe('{"a":1}');
325
+ });
326
+
327
+ it("pretty-prints values whose compact form exceeds 80 chars", () => {
328
+ const value = { key: "x".repeat(100) };
329
+ expect(formatJsonValue(value)).toBe(JSON.stringify(value, null, 2));
330
+ });
331
+ });
332
+
333
+ describe("formatIndentedField", () => {
334
+ it("renders single-line content inline", () => {
335
+ expect(formatIndentedField("label", "value")).toBe(" label: value\n");
336
+ });
337
+
338
+ it("indents continuation lines by four spaces", () => {
339
+ expect(formatIndentedField("label", "first\nsecond\nthird")).toBe(
340
+ " label: first\n second\n third\n",
341
+ );
342
+ });
343
+ });
344
+
345
+ describe("formatUsageSummary", () => {
346
+ it("returns an empty string for no entries", () => {
347
+ expect(formatUsageSummary([])).toBe("");
348
+ });
349
+
350
+ it("renders a single row without a totals row, recovering non-cached input", () => {
351
+ const entries: AgentUsage[] = [
352
+ {
353
+ agent: "claude",
354
+ inputTokens: 1500,
355
+ outputTokens: 200,
356
+ cacheReadTokens: 400,
357
+ cacheWriteTokens: 100,
358
+ costUsd: 0.5,
359
+ },
360
+ ];
361
+ const summary = formatUsageSummary(entries);
362
+ expect(summary).toContain("| claude | 1,000 | 400 | 100 | 200 | 1,700 | 0.5000 |");
363
+ expect(summary).not.toContain("**Total**");
364
+ expect(summary).toContain("<details>");
365
+ });
366
+
367
+ it("shows an em dash for missing or zero cost and clamps negative non-cached input", () => {
368
+ const entries: AgentUsage[] = [
369
+ // cache fields exceed inputTokens — non-cached input must clamp to 0
370
+ { agent: "weird", inputTokens: 100, outputTokens: 10, cacheReadTokens: 300 },
371
+ ];
372
+ const summary = formatUsageSummary(entries);
373
+ expect(summary).toContain("| weird | 0 | 300 | 0 | 10 | 310 | — |");
374
+ });
375
+
376
+ it("adds a bold totals row when there are multiple entries", () => {
377
+ const entries: AgentUsage[] = [
378
+ { agent: "a", inputTokens: 1000, outputTokens: 100, costUsd: 0.25 },
379
+ {
380
+ agent: "b",
381
+ inputTokens: 2000,
382
+ outputTokens: 200,
383
+ cacheReadTokens: 500,
384
+ cacheWriteTokens: 250,
385
+ costUsd: 0.75,
386
+ },
387
+ ];
388
+ const summary = formatUsageSummary(entries);
389
+ expect(summary).toContain(
390
+ "| **Total** | **2,250** | **500** | **250** | **300** | **3,300** | **1.0000** |",
391
+ );
392
+ });
393
+
394
+ it("shows an em dash in the totals row when no entry has a cost", () => {
395
+ const entries: AgentUsage[] = [
396
+ { agent: "a", inputTokens: 10, outputTokens: 1 },
397
+ { agent: "b", inputTokens: 20, outputTokens: 2 },
398
+ ];
399
+ const summary = formatUsageSummary(entries);
400
+ expect(summary).toContain("| **Total** | **30** | **0** | **0** | **3** | **33** | — |");
401
+ });
402
+ });