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,87 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ LEARNINGS_FILE_NAME,
7
+ learningsFilePath,
8
+ readLearningsFile,
9
+ seedLearningsFile,
10
+ } from "#app/utils/learnings";
11
+
12
+ describe("learnings tmpfile round-trip", () => {
13
+ let dir: string;
14
+
15
+ beforeEach(async () => {
16
+ dir = await mkdtemp(join(tmpdir(), "terramend-learnings-test-"));
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await rm(dir, { recursive: true, force: true });
21
+ });
22
+
23
+ it("writes the verbatim DB body to disk and reads it back unchanged", async () => {
24
+ const current = [
25
+ "## Build & test",
26
+ "",
27
+ "- run tests with `pnpm -r test`",
28
+ "",
29
+ "## Architecture",
30
+ "",
31
+ "- workers in `worker/`",
32
+ ].join("\n");
33
+ const path = await seedLearningsFile({ tmpdir: dir, current });
34
+ expect(path).toBe(learningsFilePath(dir));
35
+ expect(path.endsWith(LEARNINGS_FILE_NAME)).toBe(true);
36
+
37
+ expect(await readFile(path, "utf8")).toBe(current);
38
+ expect(await readLearningsFile(path)).toBe(current);
39
+ });
40
+
41
+ it("seeds an empty file when the repo has no learnings yet, round-trip is empty string", async () => {
42
+ const path = await seedLearningsFile({ tmpdir: dir, current: null });
43
+ expect(await readFile(path, "utf8")).toBe("");
44
+ expect(await readLearningsFile(path)).toBe("");
45
+ });
46
+
47
+ it("returns null when the file is missing (treated as no-change by persist)", async () => {
48
+ expect(await readLearningsFile(learningsFilePath(dir))).toBeNull();
49
+ });
50
+
51
+ it("trims trailing whitespace so editor newlines never trigger a spurious PATCH", async () => {
52
+ const current = "## Build & test\n\n- one fact";
53
+ const path = await seedLearningsFile({ tmpdir: dir, current });
54
+ await writeFile(path, `${current}\n\n `, "utf8");
55
+ expect(await readLearningsFile(path)).toBe(current);
56
+ });
57
+
58
+ it("truncates over-cap bodies at the last newline boundary so the next-seed TOC parse stays clean", async () => {
59
+ const padding = `${"x".repeat(80)}\n`.repeat(1300);
60
+ const oversized = `## Build & test\n\n${padding}`;
61
+ const path = await seedLearningsFile({ tmpdir: dir, current: null });
62
+ await writeFile(path, oversized, "utf8");
63
+ const read = await readLearningsFile(path);
64
+ expect(read).toBeTruthy();
65
+ expect(read?.length).toBeLessThanOrEqual(100_000);
66
+ const tailLine = read?.split("\n").pop() ?? "";
67
+ expect(/^x+$/.test(tailLine)).toBe(true);
68
+ expect(tailLine.length).toBe(80);
69
+ });
70
+
71
+ it("falls back to a hard truncate when the only newline is far above the cap (giant single line)", async () => {
72
+ const oversized = `## Build & test\n${"x".repeat(110_000)}`;
73
+ const path = await seedLearningsFile({ tmpdir: dir, current: null });
74
+ await writeFile(path, oversized, "utf8");
75
+ const read = await readLearningsFile(path);
76
+ expect(read).toBeTruthy();
77
+ expect(read?.length).toBe(100_000);
78
+ expect(read?.startsWith("## Build & test\n")).toBe(true);
79
+ });
80
+
81
+ it("preserves legacy free-text without scaffolding or wrapping", async () => {
82
+ const legacy = "- this is some old free-text bullet\n- another one";
83
+ const path = await seedLearningsFile({ tmpdir: dir, current: legacy });
84
+ expect(await readFile(path, "utf8")).toBe(legacy);
85
+ expect(await readLearningsFile(path)).toBe(legacy);
86
+ });
87
+ });
@@ -0,0 +1,138 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { ToolContext } from "#app/mcp/server";
4
+ import { apiFetch } from "#app/utils/apiFetch";
5
+ import { isBackendConfigured } from "#app/utils/apiUrl";
6
+ import { log } from "#app/utils/cli";
7
+ import { MAX_LEARNINGS_LENGTH, truncateAtLineBoundary } from "#app/utils/learningsTruncate";
8
+
9
+ export { MAX_LEARNINGS_LENGTH, truncateAtLineBoundary };
10
+
11
+ /**
12
+ * Repo-level learnings — operational facts about a repo (setup steps, test
13
+ * commands, conventions, gotchas) that accumulate across agent runs and feed
14
+ * back into future runs as durable context. Modeled on the PR-summary tmpfile
15
+ * pattern (see action/utils/prSummary.ts):
16
+ *
17
+ * 1. server seeds `terramend-learnings.md` with the verbatim body of
18
+ * `Repo.learnings` (or empty for fresh repos), and parses headings
19
+ * server-side (`utils/learningsToc.ts`) — the parsed TOC is rendered
20
+ * into the LEARNINGS prompt section, not into the file
21
+ * 2. the agent reads the TOC in the prompt and uses listed line ranges
22
+ * to read just the sections relevant to the current task — file can
23
+ * grow large, but only targeted ranges hit the agent's context
24
+ * 3. agent edits the file in place at end-of-run during the reflection
25
+ * turn (see action/agents/postRun.ts buildLearningsReflectionPrompt)
26
+ * 4. main.ts reads the file back at end-of-run and PATCHes
27
+ * `/api/repo/[owner]/[repo]/learnings` if the body changed
28
+ *
29
+ * Edit-in-place avoids stuffing the entire learnings list into both the
30
+ * prompt context and an `update_learnings` MCP tool call (which previously
31
+ * required passing the FULL merged list as a string parameter — an
32
+ * output-token tax that grew linearly with the learnings size).
33
+ *
34
+ * Section structure is agent-curated. The reflection prompt teaches
35
+ * hierarchy + a soft 300-line-per-section cap to keep TOC ranges
36
+ * agent-targetable on long-lived repos; there is no fixed taxonomy.
37
+ */
38
+
39
+ export const LEARNINGS_FILE_NAME = "terramend-learnings.md";
40
+
41
+ export function learningsFilePath(tmpdir: string): string {
42
+ return join(tmpdir, LEARNINGS_FILE_NAME);
43
+ }
44
+
45
+ /** seed the rolling learnings tmpfile with the verbatim DB body (or empty
46
+ * string for fresh repos). returns the absolute path. the parsed TOC is
47
+ * carried separately via `RepoSettings.learningsHeadings` and rendered
48
+ * into the prompt by `resolveInstructions`, so the file on disk is just
49
+ * the body — no markers, no scaffold, no in-file TOC. */
50
+ export async function seedLearningsFile(params: {
51
+ tmpdir: string;
52
+ current: string | null;
53
+ }): Promise<string> {
54
+ const path = learningsFilePath(params.tmpdir);
55
+ await mkdir(dirname(path), { recursive: true });
56
+ await writeFile(path, params.current ?? "", "utf8");
57
+ return path;
58
+ }
59
+
60
+ /** read the agent-edited learnings file. returns null when the file is
61
+ * missing or unreadable (treated as "no change"). caps content at the
62
+ * server's max length to avoid a 400 round-trip. */
63
+ export async function readLearningsFile(path: string): Promise<string | null> {
64
+ let raw: string;
65
+ try {
66
+ raw = await readFile(path, "utf8");
67
+ } catch {
68
+ return null;
69
+ }
70
+ return truncateAtLineBoundary(raw.trim(), MAX_LEARNINGS_LENGTH);
71
+ }
72
+
73
+ /**
74
+ * Read the agent-edited repo-level learnings tmpfile and PATCH it to
75
+ * `Repo.learnings`.
76
+ *
77
+ * Best-effort: any failure is logged and does not affect the run's success
78
+ * status. Skips the PATCH when the file is byte-trim-identical to its seed —
79
+ * the agent didn't touch it, so writing the same content back would just
80
+ * burn a `LearningsRevision` row and an API round-trip.
81
+ *
82
+ * `ctx.toolState.model` is forwarded so `LearningsRevision.model` keeps
83
+ * populating; it powers the per-revision attribution badge in the UI
84
+ * history view.
85
+ *
86
+ * `learningsPersistAttempted` guards against double-execution between the
87
+ * normal end-of-run path and the SIGINT/SIGTERM handler.
88
+ */
89
+ export async function persistLearnings(ctx: ToolContext): Promise<void> {
90
+ const filePath = ctx.toolState.learningsFilePath;
91
+ if (!filePath) return;
92
+ if (ctx.toolState.learningsPersistAttempted) return;
93
+ ctx.toolState.learningsPersistAttempted = true;
94
+ // dormant open-core seam: with no backend configured (standalone BYOK), there
95
+ // is nowhere to persist repo learnings — no-op silently instead of PATCHing
96
+ // the default marketing host and warning on its 404.
97
+ if (!isBackendConfigured()) {
98
+ log.debug("no backend configured (API_URL unset) — skipping learnings persist");
99
+ return;
100
+ }
101
+ const current = await readLearningsFile(filePath);
102
+ if (current === null) {
103
+ log.debug(`learnings tmpfile missing or unreadable at ${filePath} — skipping persist`);
104
+ return;
105
+ }
106
+ const seed = ctx.toolState.learningsSeed?.trim() ?? "";
107
+ if (current === seed) {
108
+ log.debug("learnings tmpfile unchanged from seed — skipping persist");
109
+ return;
110
+ }
111
+ try {
112
+ const response = await apiFetch({
113
+ path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
114
+ method: "PATCH",
115
+ headers: {
116
+ authorization: `Bearer ${ctx.apiToken}`,
117
+ "content-type": "application/json",
118
+ },
119
+ body: JSON.stringify({
120
+ learnings: current,
121
+ model: ctx.toolState.model,
122
+ }),
123
+ signal: AbortSignal.timeout(10_000),
124
+ });
125
+ if (!response.ok) {
126
+ const error = await response.text().catch(() => "(no body)");
127
+ // promoted from debug → warning: this path means the agent edited the
128
+ // file (we already short-circuited the unchanged-from-seed case above)
129
+ // but the PATCH dropped it on the floor. silently losing real work is
130
+ // worse than the noise of a CI warning.
131
+ log.warning(`learnings persist failed (${response.status}): ${error}`);
132
+ return;
133
+ }
134
+ log.info("» learnings updated");
135
+ } catch (err) {
136
+ log.warning(`learnings persist failed: ${err instanceof Error ? err.message : String(err)}`);
137
+ }
138
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildLearningsSection, renderLearningsToc } from "#app/utils/instructions";
3
+ import type { LearningsHeading } from "#app/utils/runContext";
4
+
5
+ const h = (depth: 1 | 2 | 3 | 4 | 5 | 6, title: string, startLine: number, endLine: number) => ({
6
+ depth,
7
+ title,
8
+ startLine,
9
+ endLine,
10
+ });
11
+
12
+ describe("renderLearningsToc", () => {
13
+ it("renders flat h2 list with parenthesized ranges, no hashes or backticks", () => {
14
+ const headings: LearningsHeading[] = [
15
+ h(2, "Build & test", 1, 18),
16
+ h(2, "Architecture", 19, 60),
17
+ ];
18
+ expect(renderLearningsToc(headings)).toBe(
19
+ `- Build & test (L1-L18)
20
+ - Architecture (L19-L60)`,
21
+ );
22
+ });
23
+
24
+ it("indents deeper headings 2 spaces per depth level past the shallowest", () => {
25
+ const headings: LearningsHeading[] = [
26
+ h(2, "Build & test", 1, 42),
27
+ h(3, "Local", 3, 18),
28
+ h(3, "CI", 19, 42),
29
+ h(2, "Architecture", 43, 210),
30
+ h(3, "Background workers", 80, 210),
31
+ ];
32
+ expect(renderLearningsToc(headings)).toBe(
33
+ `- Build & test (L1-L42)
34
+ - Local (L3-L18)
35
+ - CI (L19-L42)
36
+ - Architecture (L43-L210)
37
+ - Background workers (L80-L210)`,
38
+ );
39
+ });
40
+
41
+ it("treats the shallowest depth as the root column when no h2 is present", () => {
42
+ const headings: LearningsHeading[] = [h(3, "Only h3", 1, 5), h(4, "Sub h4", 2, 5)];
43
+ expect(renderLearningsToc(headings)).toBe(
44
+ `- Only h3 (L1-L5)
45
+ - Sub h4 (L2-L5)`,
46
+ );
47
+ });
48
+
49
+ it("supports depths up through h6 with stable 2-space indent steps", () => {
50
+ const headings: LearningsHeading[] = [
51
+ h(2, "Two", 1, 10),
52
+ h(3, "Three", 2, 10),
53
+ h(4, "Four", 3, 10),
54
+ h(5, "Five", 4, 10),
55
+ h(6, "Six", 5, 10),
56
+ ];
57
+ expect(renderLearningsToc(headings)).toBe(
58
+ `- Two (L1-L10)
59
+ - Three (L2-L10)
60
+ - Four (L3-L10)
61
+ - Five (L4-L10)
62
+ - Six (L5-L10)`,
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("buildLearningsSection", () => {
68
+ it("returns empty string when no file path (seed step failed)", () => {
69
+ expect(buildLearningsSection({ filePath: null, headings: [] })).toBe("");
70
+ });
71
+
72
+ it("renders the no-headings affordance when the body has no structure", () => {
73
+ const out = buildLearningsSection({
74
+ filePath: "/tmp/run-1/terramend-learnings.md",
75
+ headings: [],
76
+ });
77
+ expect(out).toContain("************* LEARNINGS *************");
78
+ expect(out).toContain("/tmp/run-1/terramend-learnings.md");
79
+ expect(out).toContain("no headings yet");
80
+ expect(out).toContain("structure it with");
81
+ // does not include a TOC list when there are no headings
82
+ expect(out).not.toMatch(/\(L\d+-L\d+\)/);
83
+ });
84
+
85
+ it("intro phrasing does not assert prior runs — works for fresh empty repos too", () => {
86
+ const out = buildLearningsSection({
87
+ filePath: "/tmp/run-1/terramend-learnings.md",
88
+ headings: [],
89
+ });
90
+ // load-bearing: fresh repos have zero previous runs. the prior copy
91
+ // ("accumulated by previous agent runs") was a lie in that case.
92
+ expect(out).not.toContain("accumulated by previous agent runs");
93
+ expect(out).toContain("maintained across runs");
94
+ });
95
+
96
+ it("renders the TOC inline with the file path and heading guidance", () => {
97
+ const out = buildLearningsSection({
98
+ filePath: "/tmp/run-1/terramend-learnings.md",
99
+ headings: [h(2, "Build & test", 1, 18), h(2, "Architecture", 19, 60)],
100
+ });
101
+ expect(out).toContain("************* LEARNINGS *************");
102
+ expect(out).toContain("/tmp/run-1/terramend-learnings.md");
103
+ expect(out).toContain("- Build & test (L1-L18)");
104
+ expect(out).toContain("- Architecture (L19-L60)");
105
+ expect(out).toContain("Each range starts at the section heading line");
106
+ // re-read affordance: ranges reflect the run-start snapshot, so the
107
+ // agent needs an explicit nudge to re-read after any mid-run edits.
108
+ // mid-run edits shift the line numbers of every later section, not
109
+ // just the edited one — wording is explicit about that.
110
+ expect(out).toContain("run-start snapshot");
111
+ expect(out).toContain("any edit shifts the line numbers of every later section");
112
+ // explicit "no hashes, no backticks" in the rendered list
113
+ expect(out).not.toContain("- `## Build");
114
+ expect(out).not.toContain("`## Build");
115
+ });
116
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { MAX_LEARNINGS_LENGTH, truncateAtLineBoundary } from "#app/utils/learningsTruncate";
3
+
4
+ describe("truncateAtLineBoundary", () => {
5
+ it("returns the body unchanged when within the cap", () => {
6
+ expect(truncateAtLineBoundary("short body", 100)).toBe("short body");
7
+ expect(truncateAtLineBoundary("exact", 5)).toBe("exact");
8
+ });
9
+
10
+ it("truncates at the last newline before the cap", () => {
11
+ const body = "line one\nline two\nline three tail beyond";
12
+ const result = truncateAtLineBoundary(body, 25);
13
+ expect(result).toBe("line one\nline two");
14
+ });
15
+
16
+ it("hard-slices when the head contains no newline", () => {
17
+ const body = "x".repeat(50);
18
+ expect(truncateAtLineBoundary(body, 10)).toBe("x".repeat(10));
19
+ });
20
+
21
+ it("hard-slices when the head starts with a newline (lastNewline at 0)", () => {
22
+ const body = `\n${"y".repeat(50)}`;
23
+ expect(truncateAtLineBoundary(body, 10)).toBe(`\n${"y".repeat(9)}`);
24
+ });
25
+
26
+ it("hard-slices instead of discarding a giant trailing line", () => {
27
+ // one short line, then a single line far longer than the 4096 tolerance
28
+ const body = `header\n${"z".repeat(10_000)}`;
29
+ const cap = 6000;
30
+ const result = truncateAtLineBoundary(body, cap);
31
+ // keeping only "header" would discard ~6KB; the hard slice is preferred
32
+ expect(result).toHaveLength(cap);
33
+ expect(result.startsWith("header\n")).toBe(true);
34
+ });
35
+
36
+ it("exports a six-figure cap for the learnings body", () => {
37
+ expect(MAX_LEARNINGS_LENGTH).toBe(100_000);
38
+ });
39
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * pure string helpers for capping and line-boundary-truncating the
3
+ * `Repo.learnings` body. lives in its own module (vs alongside
4
+ * `learnings.ts`) so the proprietary root app can re-export it through
5
+ * `action/internal/index.ts` without dragging the entire MCP type graph
6
+ * along — `learnings.ts` imports `ToolContext` for its runtime helpers,
7
+ * and pulling that into the SDK-facing `internal` barrel expands the
8
+ * type graph reachable from root `tsc` and `cf-worker-indexing` to every
9
+ * tool module under `action/mcp/`. keeping these helpers MCP-free is the
10
+ * cheap structural fix.
11
+ *
12
+ * see `action/utils/learnings.ts` for the full learnings-file lifecycle.
13
+ */
14
+
15
+ /** maximum size of `Repo.learnings` body in chars. action truncates the
16
+ * read-back BEFORE the PATCH to avoid sending an oversized payload; the
17
+ * server applies the same truncation as a defense-in-depth backstop (any
18
+ * caller that misses the client-side step would otherwise persist a
19
+ * mid-line tail, breaking the next-run TOC parse).
20
+ *
21
+ * raised from 10k → 100k once the TOC affordance landed: with line-range
22
+ * reads via the server-parsed TOC the agent doesn't ingest the whole
23
+ * file, so the cap is governed by curation discipline rather than a
24
+ * tight byte ceiling. 100k holds ~400-500 short bullets. */
25
+ export const MAX_LEARNINGS_LENGTH = 100_000;
26
+
27
+ /** truncate at the last newline boundary before `cap` so we don't leave
28
+ * a partial line at the tail (a half-truncated `## Headi` confuses the
29
+ * server's next-seed TOC parse and shrinks visible structure). falls
30
+ * back to a hard `slice` when the line boundary would discard a large
31
+ * run of content — i.e. when the tail of `head` is one giant line (rare:
32
+ * minified pastes, fenced log dumps). losing a partial last line is
33
+ * preferable to losing kilobytes of body. */
34
+ const TRUNCATION_LINE_BOUNDARY_TOLERANCE = 4096;
35
+ export function truncateAtLineBoundary(body: string, cap: number): string {
36
+ if (body.length <= cap) return body;
37
+ const head = body.slice(0, cap);
38
+ const lastNewline = head.lastIndexOf("\n");
39
+ if (lastNewline <= 0) return head;
40
+ if (cap - lastNewline > TRUNCATION_LINE_BOUNDARY_TOLERANCE) return head;
41
+ return head.slice(0, lastNewline);
42
+ }
@@ -0,0 +1,195 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { LIFECYCLE_HOOK_TIMEOUT_MS } from "#app/lifecycle";
3
+ import { describeSetupFailure, executeLifecycleHook } from "#app/utils/lifecycle";
4
+ import {
5
+ SPAWN_ACTIVITY_TIMEOUT_CODE,
6
+ SPAWN_TIMEOUT_CODE,
7
+ SpawnTimeoutError,
8
+ spawn,
9
+ } from "#app/utils/subprocess";
10
+
11
+ // executeLifecycleHook shells out (bash for the hook, git for the
12
+ // normalize-after pass) — mock spawn so the tests stay subprocess-free and
13
+ // OS-independent. error classes / codes stay real.
14
+ vi.mock("#app/utils/subprocess", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("#app/utils/subprocess")>();
16
+ return { ...actual, spawn: vi.fn() };
17
+ });
18
+
19
+ const spawnMock = vi.mocked(spawn);
20
+
21
+ function ok(stdout = "", stderr = "", exitCode = 0) {
22
+ return { stdout, stderr, exitCode, durationMs: 1 };
23
+ }
24
+
25
+ beforeEach(() => {
26
+ spawnMock.mockReset();
27
+ spawnMock.mockResolvedValue(ok());
28
+ });
29
+
30
+ describe("LIFECYCLE_HOOK_TIMEOUT_MS", () => {
31
+ it("is ten minutes", () => {
32
+ expect(LIFECYCLE_HOOK_TIMEOUT_MS).toBe(600_000);
33
+ });
34
+ });
35
+
36
+ describe("describeSetupFailure", () => {
37
+ it("returns an empty string when there was no failure", () => {
38
+ expect(describeSetupFailure(undefined)).toBe("");
39
+ });
40
+
41
+ it("describes a non-zero exit with its output", () => {
42
+ const text = describeSetupFailure({ kind: "exit", exitCode: 3, output: "pnpm install died" });
43
+ expect(text).toContain("exited with code 3");
44
+ expect(text).toContain("pnpm install died");
45
+ });
46
+
47
+ it("falls back to (empty) when the exit produced no output", () => {
48
+ expect(describeSetupFailure({ kind: "exit", exitCode: 1, output: "" })).toContain("(empty)");
49
+ });
50
+
51
+ it("describes a timeout", () => {
52
+ expect(describeSetupFailure({ kind: "timeout" })).toContain("timed out");
53
+ });
54
+
55
+ it("describes a spawn failure with the spawn error", () => {
56
+ expect(describeSetupFailure({ kind: "spawn", spawnError: "ENOENT bash" })).toContain(
57
+ "failed to start: ENOENT bash",
58
+ );
59
+ });
60
+ });
61
+
62
+ describe("executeLifecycleHook", () => {
63
+ it("is a no-op when no script is configured", async () => {
64
+ const result = await executeLifecycleHook({ event: "setup", script: null });
65
+ expect(result).toEqual({});
66
+ expect(spawnMock).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it("returns no warning when the hook exits 0", async () => {
70
+ const result = await executeLifecycleHook({ event: "setup", script: "true" });
71
+ expect(result).toEqual({});
72
+ expect(spawnMock).toHaveBeenCalledTimes(1);
73
+ const opts = spawnMock.mock.calls[0]?.[0];
74
+ expect(opts?.cmd).toBe("bash");
75
+ expect(opts?.args).toEqual(["-c", "true"]);
76
+ expect(opts?.timeout).toBe(LIFECYCLE_HOOK_TIMEOUT_MS);
77
+ });
78
+
79
+ it("returns a structured exit failure with stderr preferred over stdout", async () => {
80
+ spawnMock.mockResolvedValue(ok("stdout text", "stderr text\n", 2));
81
+ const result = await executeLifecycleHook({ event: "setup", script: "exit 2" });
82
+ expect(result.failure).toEqual({ kind: "exit", exitCode: 2, output: "stderr text" });
83
+ expect(result.warning).toContain("failed with exit code 2");
84
+ expect(result.warning).toContain("stderr text");
85
+ });
86
+
87
+ it("falls back to stdout when stderr is empty", async () => {
88
+ spawnMock.mockResolvedValue(ok("only stdout\n", "", 1));
89
+ const result = await executeLifecycleHook({ event: "post-checkout", script: "exit 1" });
90
+ expect(result.failure).toEqual({ kind: "exit", exitCode: 1, output: "only stdout" });
91
+ });
92
+
93
+ it("classifies a spawn timeout as a non-retryable timeout failure", async () => {
94
+ spawnMock.mockRejectedValue(new SpawnTimeoutError("hook timed out", SPAWN_TIMEOUT_CODE));
95
+ const result = await executeLifecycleHook({ event: "setup", script: "sleep 9999" });
96
+ expect(result.failure).toEqual({ kind: "timeout" });
97
+ expect(result.warning).toContain("timed out after 10min");
98
+ expect(result.warning).toContain("do NOT retry");
99
+ });
100
+
101
+ it("classifies an activity timeout as a timeout failure too", async () => {
102
+ spawnMock.mockRejectedValue(new SpawnTimeoutError("idle", SPAWN_ACTIVITY_TIMEOUT_CODE));
103
+ const result = await executeLifecycleHook({ event: "setup", script: "sleep 9999" });
104
+ expect(result.failure).toEqual({ kind: "timeout" });
105
+ });
106
+
107
+ it("classifies any other throw as a retryable spawn failure", async () => {
108
+ spawnMock.mockRejectedValue(new Error("ENOENT: bash not found"));
109
+ const result = await executeLifecycleHook({ event: "setup", script: "true" });
110
+ expect(result.failure).toEqual({ kind: "spawn", spawnError: "ENOENT: bash not found" });
111
+ expect(result.warning).toContain("failed to spawn");
112
+ expect(result.warning).toContain("retry the operation");
113
+ });
114
+
115
+ describe("normalizeWorkingTreeAfter", () => {
116
+ function gitCalls(): string[][] {
117
+ return spawnMock.mock.calls
118
+ .map((c) => c[0])
119
+ .filter((opts) => opts?.cmd === "git")
120
+ .map((opts) => opts?.args ?? []);
121
+ }
122
+
123
+ it("discards tracked drift the hook introduced on a previously clean tree", async () => {
124
+ // call order: git diff (pre) → bash hook → git diff (post) → git restore
125
+ let diffCalls = 0;
126
+ spawnMock.mockImplementation(async (opts) => {
127
+ if (opts.cmd === "git" && opts.args?.[0] === "diff") {
128
+ diffCalls += 1;
129
+ return ok(diffCalls === 1 ? "" : "pnpm-lock.yaml\npackage.json\n");
130
+ }
131
+ return ok();
132
+ });
133
+ const result = await executeLifecycleHook({
134
+ event: "setup",
135
+ script: "pnpm install",
136
+ normalizeWorkingTreeAfter: true,
137
+ });
138
+ expect(result).toEqual({});
139
+ expect(gitCalls()).toEqual([
140
+ ["diff", "--name-only", "HEAD"],
141
+ ["diff", "--name-only", "HEAD"],
142
+ ["restore", "--staged", "--worktree", "."],
143
+ ]);
144
+ });
145
+
146
+ it("skips normalization when the tree had pre-existing tracked changes", async () => {
147
+ spawnMock.mockImplementation(async (opts) => {
148
+ if (opts.cmd === "git" && opts.args?.[0] === "diff") return ok("preexisting.ts\n");
149
+ return ok();
150
+ });
151
+ await executeLifecycleHook({
152
+ event: "setup",
153
+ script: "pnpm install",
154
+ normalizeWorkingTreeAfter: true,
155
+ });
156
+ // only the pre-hook snapshot ran; no post-hook diff, no restore.
157
+ expect(gitCalls()).toEqual([["diff", "--name-only", "HEAD"]]);
158
+ });
159
+
160
+ it("does nothing when the hook leaves the tree clean", async () => {
161
+ await executeLifecycleHook({
162
+ event: "setup",
163
+ script: "true",
164
+ normalizeWorkingTreeAfter: true,
165
+ });
166
+ expect(gitCalls()).toEqual([
167
+ ["diff", "--name-only", "HEAD"],
168
+ ["diff", "--name-only", "HEAD"],
169
+ ]);
170
+ });
171
+
172
+ it("still normalizes when the hook itself failed", async () => {
173
+ let diffCalls = 0;
174
+ spawnMock.mockImplementation(async (opts) => {
175
+ if (opts.cmd === "git" && opts.args?.[0] === "diff") {
176
+ diffCalls += 1;
177
+ return ok(diffCalls === 1 ? "" : "pnpm-lock.yaml\n");
178
+ }
179
+ if (opts.cmd === "bash") return ok("", "boom", 1);
180
+ return ok();
181
+ });
182
+ const result = await executeLifecycleHook({
183
+ event: "setup",
184
+ script: "pnpm install && exit 1",
185
+ normalizeWorkingTreeAfter: true,
186
+ });
187
+ expect(result.failure).toEqual({ kind: "exit", exitCode: 1, output: "boom" });
188
+ expect(gitCalls()).toEqual([
189
+ ["diff", "--name-only", "HEAD"],
190
+ ["diff", "--name-only", "HEAD"],
191
+ ["restore", "--staged", "--worktree", "."],
192
+ ]);
193
+ });
194
+ });
195
+ });