@stigmer/runner 3.0.9-dev.20260615153829 → 3.1.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 (329) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/__test-utils__/approval-contract/types.d.ts +174 -0
  3. package/dist/__test-utils__/approval-contract/types.js +24 -0
  4. package/dist/__test-utils__/approval-contract/types.js.map +1 -0
  5. package/dist/activities/call-agent-status.d.ts +19 -1
  6. package/dist/activities/call-agent-status.js +64 -5
  7. package/dist/activities/call-agent-status.js.map +1 -1
  8. package/dist/activities/call-llm.js +19 -53
  9. package/dist/activities/call-llm.js.map +1 -1
  10. package/dist/activities/classify-tool-approvals.d.ts +24 -0
  11. package/dist/activities/classify-tool-approvals.js +69 -17
  12. package/dist/activities/classify-tool-approvals.js.map +1 -1
  13. package/dist/activities/discover-mcp-server.d.ts +7 -0
  14. package/dist/activities/discover-mcp-server.js +11 -1
  15. package/dist/activities/discover-mcp-server.js.map +1 -1
  16. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +140 -0
  17. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +130 -0
  18. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -0
  19. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.d.ts +18 -0
  20. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js +123 -0
  21. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js.map +1 -0
  22. package/dist/activities/execute-cursor/approval-policy.d.ts +17 -61
  23. package/dist/activities/execute-cursor/approval-policy.js +8 -120
  24. package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
  25. package/dist/activities/execute-cursor/approval-state.d.ts +219 -9
  26. package/dist/activities/execute-cursor/approval-state.js +233 -17
  27. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  28. package/dist/activities/execute-cursor/capture-flow.d.ts +127 -0
  29. package/dist/activities/execute-cursor/capture-flow.js +234 -0
  30. package/dist/activities/execute-cursor/capture-flow.js.map +1 -0
  31. package/dist/activities/execute-cursor/cas-observations.d.ts +96 -0
  32. package/dist/activities/execute-cursor/cas-observations.js +184 -0
  33. package/dist/activities/execute-cursor/cas-observations.js.map +1 -0
  34. package/dist/activities/execute-cursor/command-provenance.d.ts +62 -0
  35. package/dist/activities/execute-cursor/command-provenance.js +128 -0
  36. package/dist/activities/execute-cursor/command-provenance.js.map +1 -0
  37. package/dist/activities/execute-cursor/exact-apply.d.ts +110 -0
  38. package/dist/activities/execute-cursor/exact-apply.js +204 -0
  39. package/dist/activities/execute-cursor/exact-apply.js.map +1 -0
  40. package/dist/activities/execute-cursor/hook-script.d.ts +53 -24
  41. package/dist/activities/execute-cursor/hook-script.js +310 -47
  42. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  43. package/dist/activities/execute-cursor/index.d.ts +8 -1
  44. package/dist/activities/execute-cursor/index.js +500 -64
  45. package/dist/activities/execute-cursor/index.js.map +1 -1
  46. package/dist/activities/execute-cursor/message-translator.d.ts +258 -11
  47. package/dist/activities/execute-cursor/message-translator.js +836 -90
  48. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  49. package/dist/activities/execute-cursor/prompt-builder.d.ts +20 -23
  50. package/dist/activities/execute-cursor/prompt-builder.js +74 -15
  51. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  52. package/dist/activities/execute-cursor/workspace-setup.d.ts +17 -0
  53. package/dist/activities/execute-cursor/workspace-setup.js +212 -33
  54. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.d.ts +19 -0
  56. package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js +143 -0
  57. package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js.map +1 -0
  58. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +88 -0
  59. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +81 -0
  60. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -0
  61. package/dist/activities/execute-deep-agent/approval-file-change.d.ts +47 -0
  62. package/dist/activities/execute-deep-agent/approval-file-change.js +68 -0
  63. package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -0
  64. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +8 -1
  65. package/dist/activities/execute-deep-agent/attachment-injector.js +7 -7
  66. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  67. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +42 -0
  68. package/dist/activities/execute-deep-agent/cas-capture-backend.js +47 -0
  69. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -0
  70. package/dist/activities/execute-deep-agent/cas-capture-observer.d.ts +79 -0
  71. package/dist/activities/execute-deep-agent/cas-capture-observer.js +112 -0
  72. package/dist/activities/execute-deep-agent/cas-capture-observer.js.map +1 -0
  73. package/dist/activities/execute-deep-agent/hitl.d.ts +16 -3
  74. package/dist/activities/execute-deep-agent/hitl.js +11 -3
  75. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  76. package/dist/activities/execute-deep-agent/index.d.ts +2 -1
  77. package/dist/activities/execute-deep-agent/index.js +393 -33
  78. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  79. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +7 -1
  80. package/dist/activities/execute-deep-agent/inline-publisher.js +23 -2
  81. package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
  82. package/dist/activities/execute-deep-agent/setup.d.ts +53 -2
  83. package/dist/activities/execute-deep-agent/setup.js +149 -92
  84. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  85. package/dist/activities/execute-deep-agent/stamp-flowed-rows.d.ts +36 -0
  86. package/dist/activities/execute-deep-agent/stamp-flowed-rows.js +56 -0
  87. package/dist/activities/execute-deep-agent/stamp-flowed-rows.js.map +1 -0
  88. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +34 -1
  89. package/dist/activities/execute-deep-agent/status-builder-shared.js +26 -25
  90. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  91. package/dist/activities/execute-deep-agent/status-builder.d.ts +17 -5
  92. package/dist/activities/execute-deep-agent/status-builder.js +44 -3
  93. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  94. package/dist/activities/execute-deep-agent/streaming-side-effects.js +2 -19
  95. package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
  96. package/dist/activities/execute-deep-agent/streaming.js +3 -15
  97. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  98. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +25 -7
  99. package/dist/activities/execute-deep-agent/subagent-transformer.js +23 -7
  100. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  101. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +30 -3
  102. package/dist/activities/execute-deep-agent/subagent-wiring.js +29 -3
  103. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  104. package/dist/activities/execute-deep-agent/v3-status-builder.js +32 -2
  105. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  106. package/dist/claimcheck/payload-codec.js +9 -5
  107. package/dist/claimcheck/payload-codec.js.map +1 -1
  108. package/dist/client/stigmer-client.d.ts +2 -0
  109. package/dist/client/stigmer-client.js +2 -0
  110. package/dist/client/stigmer-client.js.map +1 -1
  111. package/dist/middleware/approval-gate.d.ts +85 -4
  112. package/dist/middleware/approval-gate.js +165 -38
  113. package/dist/middleware/approval-gate.js.map +1 -1
  114. package/dist/middleware/types.d.ts +2 -5
  115. package/dist/shared/activity-input.d.ts +43 -0
  116. package/dist/shared/activity-input.js +17 -0
  117. package/dist/shared/activity-input.js.map +1 -0
  118. package/dist/shared/approval-canonicalize.d.ts +19 -0
  119. package/dist/shared/approval-canonicalize.js +119 -0
  120. package/dist/shared/approval-canonicalize.js.map +1 -0
  121. package/dist/shared/approval-fingerprint.d.ts +106 -0
  122. package/dist/shared/approval-fingerprint.js +113 -0
  123. package/dist/shared/approval-fingerprint.js.map +1 -0
  124. package/dist/shared/approval-policy.d.ts +182 -12
  125. package/dist/shared/approval-policy.js +213 -27
  126. package/dist/shared/approval-policy.js.map +1 -1
  127. package/dist/shared/args-preview.d.ts +52 -0
  128. package/dist/shared/args-preview.js +93 -0
  129. package/dist/shared/args-preview.js.map +1 -0
  130. package/dist/shared/artifact-storage.d.ts +19 -1
  131. package/dist/shared/artifact-storage.js +48 -11
  132. package/dist/shared/artifact-storage.js.map +1 -1
  133. package/dist/shared/file-change.d.ts +44 -0
  134. package/dist/shared/file-change.js +57 -0
  135. package/dist/shared/file-change.js.map +1 -0
  136. package/dist/shared/file-tools.d.ts +107 -0
  137. package/dist/shared/file-tools.js +168 -0
  138. package/dist/shared/file-tools.js.map +1 -0
  139. package/dist/shared/filereview/capture.d.ts +202 -0
  140. package/dist/shared/filereview/capture.js +498 -0
  141. package/dist/shared/filereview/capture.js.map +1 -0
  142. package/dist/shared/filereview/cas-substrate.d.ts +190 -0
  143. package/dist/shared/filereview/cas-substrate.js +284 -0
  144. package/dist/shared/filereview/cas-substrate.js.map +1 -0
  145. package/dist/shared/filereview/digest.d.ts +40 -0
  146. package/dist/shared/filereview/digest.js +66 -0
  147. package/dist/shared/filereview/digest.js.map +1 -0
  148. package/dist/shared/filereview/events.d.ts +170 -0
  149. package/dist/shared/filereview/events.js +298 -0
  150. package/dist/shared/filereview/events.js.map +1 -0
  151. package/dist/shared/filereview/git-substrate.d.ts +175 -0
  152. package/dist/shared/filereview/git-substrate.js +439 -0
  153. package/dist/shared/filereview/git-substrate.js.map +1 -0
  154. package/dist/shared/filereview/index.d.ts +11 -0
  155. package/dist/shared/filereview/index.js +12 -0
  156. package/dist/shared/filereview/index.js.map +1 -0
  157. package/dist/shared/filereview/secret-paths.d.ts +63 -0
  158. package/dist/shared/filereview/secret-paths.js +105 -0
  159. package/dist/shared/filereview/secret-paths.js.map +1 -0
  160. package/dist/shared/fingerprint-secret.d.ts +26 -0
  161. package/dist/shared/fingerprint-secret.js +47 -0
  162. package/dist/shared/fingerprint-secret.js.map +1 -0
  163. package/dist/shared/model-client.d.ts +51 -0
  164. package/dist/shared/model-client.js +77 -0
  165. package/dist/shared/model-client.js.map +1 -0
  166. package/dist/shared/plan-artifact.js +0 -2
  167. package/dist/shared/plan-artifact.js.map +1 -1
  168. package/dist/shared/status-offload.d.ts +83 -9
  169. package/dist/shared/status-offload.js +399 -79
  170. package/dist/shared/status-offload.js.map +1 -1
  171. package/dist/shared/status.js +14 -1
  172. package/dist/shared/status.js.map +1 -1
  173. package/dist/shared/tool-kind.d.ts +19 -0
  174. package/dist/shared/tool-kind.js +13 -0
  175. package/dist/shared/tool-kind.js.map +1 -1
  176. package/dist/shared/tool-row.d.ts +88 -0
  177. package/dist/shared/tool-row.js +127 -0
  178. package/dist/shared/tool-row.js.map +1 -0
  179. package/dist/shared/workspace/platform-dir.d.ts +25 -0
  180. package/dist/shared/workspace/platform-dir.js +38 -2
  181. package/dist/shared/workspace/platform-dir.js.map +1 -1
  182. package/dist/workflows/call-agent-orchestrator.js +56 -7
  183. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  184. package/dist/workflows/connect-mcp-server.d.ts +50 -0
  185. package/dist/workflows/connect-mcp-server.js +136 -15
  186. package/dist/workflows/connect-mcp-server.js.map +1 -1
  187. package/dist/workflows/types.d.ts +8 -0
  188. package/package.json +2 -2
  189. package/src/__test-utils__/approval-contract/contract.ts +224 -0
  190. package/src/__test-utils__/approval-contract/types.ts +179 -0
  191. package/src/__test-utils__/fake-artifact-storage.ts +72 -0
  192. package/src/__tests__/approval-gateway-contract.test.ts +29 -0
  193. package/src/__tests__/claimcheck-codec.test.ts +16 -53
  194. package/src/__tests__/golden-e2e.test.ts +2 -0
  195. package/src/__tests__/runner-token-coordinator.test.ts +3 -3
  196. package/src/activities/__tests__/call-agent-status.test.ts +135 -0
  197. package/src/activities/__tests__/call-llm.test.ts +1 -1
  198. package/src/activities/__tests__/classify-tool-approvals.test.ts +208 -1
  199. package/src/activities/__tests__/discover-mcp-server.test.ts +30 -0
  200. package/src/activities/__tests__/workflow-event-activities.test.ts +2 -1
  201. package/src/activities/call-agent-status.ts +74 -4
  202. package/src/activities/call-llm.ts +18 -63
  203. package/src/activities/classify-tool-approvals.ts +101 -19
  204. package/src/activities/discover-mcp-server.ts +29 -1
  205. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +216 -0
  206. package/src/activities/execute-cursor/__test-utils__/gateway-substrate.ts +148 -0
  207. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +41 -9
  208. package/src/activities/execute-cursor/__tests__/approval-state.test.ts +292 -0
  209. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +68 -1
  210. package/src/activities/execute-cursor/__tests__/capture-flow.test.ts +1005 -0
  211. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +187 -0
  212. package/src/activities/execute-cursor/__tests__/coarse-fingerprint.test.ts +97 -0
  213. package/src/activities/execute-cursor/__tests__/command-provenance.test.ts +240 -0
  214. package/src/activities/execute-cursor/__tests__/deny-gate-exact-apply.test.ts +203 -0
  215. package/src/activities/execute-cursor/__tests__/exact-apply.test.ts +375 -0
  216. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +1294 -24
  217. package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +446 -0
  218. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +384 -110
  219. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +171 -25
  220. package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +189 -0
  221. package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +44 -23
  222. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +190 -10
  223. package/src/activities/execute-cursor/approval-policy.ts +28 -159
  224. package/src/activities/execute-cursor/approval-state.ts +366 -18
  225. package/src/activities/execute-cursor/capture-flow.ts +323 -0
  226. package/src/activities/execute-cursor/cas-observations.ts +204 -0
  227. package/src/activities/execute-cursor/command-provenance.ts +168 -0
  228. package/src/activities/execute-cursor/exact-apply.ts +253 -0
  229. package/src/activities/execute-cursor/hook-script.ts +317 -51
  230. package/src/activities/execute-cursor/index.ts +575 -67
  231. package/src/activities/execute-cursor/message-translator.ts +963 -89
  232. package/src/activities/execute-cursor/prompt-builder.ts +80 -14
  233. package/src/activities/execute-cursor/workspace-setup.ts +257 -42
  234. package/src/activities/execute-deep-agent/__test-utils__/gateway-substrate.ts +180 -0
  235. package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +134 -0
  236. package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +84 -0
  237. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +11 -24
  238. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +64 -0
  239. package/src/activities/execute-deep-agent/__tests__/cas-capture-observer.test.ts +163 -0
  240. package/src/activities/execute-deep-agent/__tests__/hitl-integration.test.ts +8 -5
  241. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +342 -0
  242. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +284 -0
  243. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +14 -36
  244. package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +31 -13
  245. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +349 -0
  246. package/src/activities/execute-deep-agent/__tests__/stamp-flowed-rows.test.ts +119 -0
  247. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +91 -10
  248. package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +9 -9
  249. package/src/activities/execute-deep-agent/__tests__/subagent-approval-propagation.test.ts +160 -0
  250. package/src/activities/execute-deep-agent/__tests__/subagent-gitignored-capture.test.ts +213 -0
  251. package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +3 -6
  252. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +84 -1
  253. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +4 -1
  254. package/src/activities/execute-deep-agent/approval-file-change.ts +80 -0
  255. package/src/activities/execute-deep-agent/attachment-injector.ts +20 -11
  256. package/src/activities/execute-deep-agent/cas-capture-backend.ts +66 -0
  257. package/src/activities/execute-deep-agent/cas-capture-observer.ts +125 -0
  258. package/src/activities/execute-deep-agent/hitl.ts +23 -7
  259. package/src/activities/execute-deep-agent/index.ts +463 -40
  260. package/src/activities/execute-deep-agent/inline-publisher.ts +27 -4
  261. package/src/activities/execute-deep-agent/setup.ts +223 -125
  262. package/src/activities/execute-deep-agent/stamp-flowed-rows.ts +64 -0
  263. package/src/activities/execute-deep-agent/status-builder-shared.ts +62 -23
  264. package/src/activities/execute-deep-agent/status-builder.ts +63 -8
  265. package/src/activities/execute-deep-agent/streaming-side-effects.ts +2 -16
  266. package/src/activities/execute-deep-agent/streaming.ts +3 -13
  267. package/src/activities/execute-deep-agent/subagent-transformer.ts +53 -13
  268. package/src/activities/execute-deep-agent/subagent-wiring.ts +50 -3
  269. package/src/activities/execute-deep-agent/v3-status-builder.ts +36 -2
  270. package/src/claimcheck/payload-codec.ts +8 -8
  271. package/src/client/stigmer-client.ts +9 -1
  272. package/src/middleware/__tests__/approval-gate.test.ts +488 -4
  273. package/src/middleware/approval-gate.ts +247 -38
  274. package/src/middleware/types.ts +5 -5
  275. package/src/shared/__tests__/activity-input.test.ts +78 -0
  276. package/src/shared/__tests__/approval-canonicalize.test.ts +106 -0
  277. package/src/shared/__tests__/approval-fingerprint.test.ts +115 -0
  278. package/src/shared/__tests__/approval-policy.test.ts +274 -40
  279. package/src/shared/__tests__/args-preview.test.ts +78 -0
  280. package/src/shared/__tests__/artifact-storage-extended.test.ts +62 -10
  281. package/src/shared/__tests__/artifact-storage.test.ts +123 -11
  282. package/src/shared/__tests__/file-change.test.ts +85 -0
  283. package/src/shared/__tests__/file-tools.test.ts +90 -0
  284. package/src/shared/__tests__/fingerprint-secret.test.ts +51 -0
  285. package/src/shared/__tests__/lease-scope-corpus.test.ts +56 -0
  286. package/src/shared/__tests__/model-client.test.ts +162 -0
  287. package/src/shared/__tests__/plan-artifact.test.ts +11 -26
  288. package/src/shared/__tests__/policy-source-corpus.test.ts +58 -0
  289. package/src/shared/__tests__/status-offload.test.ts +573 -16
  290. package/src/shared/__tests__/status.test.ts +4 -5
  291. package/src/shared/__tests__/tool-kind.test.ts +24 -1
  292. package/src/shared/__tests__/tool-row.test.ts +221 -0
  293. package/src/shared/activity-input.ts +57 -0
  294. package/src/shared/approval-canonicalize.ts +159 -0
  295. package/src/shared/approval-fingerprint.ts +148 -0
  296. package/src/shared/approval-policy.ts +303 -27
  297. package/src/shared/args-preview.ts +98 -0
  298. package/src/shared/artifact-storage.ts +62 -11
  299. package/src/shared/checkpointer/__tests__/http-saver.test.ts +1 -2
  300. package/src/shared/file-change.ts +64 -0
  301. package/src/shared/file-tools.ts +169 -0
  302. package/src/shared/filereview/__tests__/capture.test.ts +856 -0
  303. package/src/shared/filereview/__tests__/cas-substrate.test.ts +404 -0
  304. package/src/shared/filereview/__tests__/digest.test.ts +100 -0
  305. package/src/shared/filereview/__tests__/events.test.ts +245 -0
  306. package/src/shared/filereview/__tests__/git-substrate.test.ts +362 -0
  307. package/src/shared/filereview/__tests__/proxy-reconcile.test.ts +286 -0
  308. package/src/shared/filereview/__tests__/secret-paths.test.ts +121 -0
  309. package/src/shared/filereview/capture.ts +727 -0
  310. package/src/shared/filereview/cas-substrate.ts +401 -0
  311. package/src/shared/filereview/digest.ts +83 -0
  312. package/src/shared/filereview/events.ts +449 -0
  313. package/src/shared/filereview/git-substrate.ts +555 -0
  314. package/src/shared/filereview/index.ts +60 -0
  315. package/src/shared/filereview/secret-paths.ts +121 -0
  316. package/src/shared/fingerprint-secret.ts +53 -0
  317. package/src/shared/model-client.ts +122 -0
  318. package/src/shared/plan-artifact.ts +0 -2
  319. package/src/shared/status-offload.ts +433 -77
  320. package/src/shared/status.ts +13 -0
  321. package/src/shared/tool-kind.ts +33 -0
  322. package/src/shared/tool-row.ts +135 -0
  323. package/src/shared/workspace/platform-dir.ts +41 -2
  324. package/src/workflow-engine/__tests__/golden-execution.test.ts +35 -18
  325. package/src/workflow-engine/__tests__/tasks/try.test.ts +1 -1
  326. package/src/workflows/__tests__/connect-mcp-server.test.ts +304 -29
  327. package/src/workflows/call-agent-orchestrator.ts +53 -6
  328. package/src/workflows/connect-mcp-server.ts +179 -24
  329. package/src/workflows/types.ts +8 -0
@@ -1,4 +1,6 @@
1
1
  /**
2
+ * @regression file-hitl-phase0 — pins file-edit HITL fixes #1, #2, #3, #5 (see _projects/2026-06/20260630.01.file-change-hitl-redesign/tasks/T01_3_regression-manifest.md)
3
+ *
2
4
  * Unit tests for the Cursor-harness HITL denial-ledger flow.
3
5
  *
4
6
  * These cover the architecture the platform converges on: the preToolUse hook
@@ -11,14 +13,17 @@
11
13
  * - token-correlated overlay of WAITING_APPROVAL onto streamed tool calls,
12
14
  * INCLUDING the regression where a denied tool was reported as "completed"
13
15
  * (the green-checkmark bug) — it must become WAITING_APPROVAL, not success
16
+ * - the gate's before-reading diff capture: a whole-file rewrite renders a true
17
+ * before/after, an edit renders a HUNK — via the shared gate-file-change builder
18
+ * - in-place collapse of a same-turn duplicate denial twin (one card, not two)
14
19
  * - synthesis of a tool call when a denial produced no stream event
15
20
  * - reconstruction of adjudicated approvals from tool calls on reinvocation
16
21
  * (pending_approvals is empty by then because the backend cleared it)
17
22
  * - the generated hook script wiring (records denials in both deny branches)
18
23
  */
19
24
 
20
- import { describe, it, expect, afterEach } from "vitest";
21
- import { create } from "@bufbuild/protobuf";
25
+ import { describe, it, expect, afterEach, vi } from "vitest";
26
+ import { create, type MessageInitShape } from "@bufbuild/protobuf";
22
27
  import { mkdtempSync, rmSync } from "node:fs";
23
28
  import { writeFile } from "node:fs/promises";
24
29
  import { tmpdir } from "node:os";
@@ -28,6 +33,7 @@ import {
28
33
  AgentMessageSchema,
29
34
  ToolCallSchema,
30
35
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
36
+ import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
31
37
  import type {
32
38
  AgentMessage,
33
39
  ToolCall,
@@ -43,9 +49,13 @@ import {
43
49
  readDenialLedger,
44
50
  denialLedgerPath,
45
51
  reconstructAdjudicatedApprovals,
52
+ buildApprovalGrants,
46
53
  grantToken,
54
+ watchDenialLedger,
47
55
  } from "../approval-state.js";
48
- import { reconcileDeniedToolCalls } from "../message-translator.js";
56
+ import { reconcileDeniedToolCalls, clearProvisionalPostDenialNarration, collapseRedundantToolCallTwins, toolCallIdentityToken } from "../message-translator.js";
57
+ import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
58
+ import type { WorkspaceBackend } from "../../../shared/workspace/types.js";
49
59
  import { generateHookScript } from "../hook-script.js";
50
60
  import type { MergedToolPolicy } from "../approval-policy.js";
51
61
 
@@ -63,11 +73,23 @@ function makeWorkspace(): string {
63
73
  return dir;
64
74
  }
65
75
 
76
+ // The gate now reads each denied file's pre-edit `before` from a WorkspaceBackend
77
+ // (the tool was denied, so disk still holds the old content). These fakes back
78
+ // the two reads the gate uses (exists/readFile) from an in-memory map so the
79
+ // before/after capture is deterministic with no real IO. Rooted at "/root" so the
80
+ // existing absolute-path assertions hold.
81
+ const ROOT = "/root";
82
+
83
+ /** A backend whose files are absent (rooted at ROOT for path assertions). */
84
+ function rootBackend(): WorkspaceBackend {
85
+ return mockWorkspaceBackend({ rootDir: ROOT });
86
+ }
87
+
66
88
  // Stream tool calls use the lowercase SDK taxonomy (edit/shell/delete); the
67
89
  // denial ledger uses the hook taxonomy (Write/Shell/Delete) + a canonical
68
90
  // category+salient token. The two correlate via approvalCategory — that cross-
69
91
  // taxonomy match is exactly what these tests pin.
70
- function toolCall(overrides: Partial<ToolCall>): ToolCall {
92
+ function toolCall(overrides: MessageInitShape<typeof ToolCallSchema>): ToolCall {
71
93
  return create(ToolCallSchema, {
72
94
  id: "call-1",
73
95
  name: "edit",
@@ -118,10 +140,32 @@ describe("denial ledger reset/read", () => {
118
140
  { toolName: "Shell", token: shellToken },
119
141
  ]);
120
142
  });
143
+
144
+ it("decodes the base64 tool_input the hook captures, tolerating absence and garbage", async () => {
145
+ const ws = makeWorkspace();
146
+ await resetDenialLedger(ws);
147
+ const token = grantToken("write", "notes.md");
148
+ const input = { path: "notes.md", contents: "# Notes\n" };
149
+ const inputB64 = Buffer.from(JSON.stringify(input), "utf-8").toString("base64");
150
+ await writeFile(
151
+ denialLedgerPath(ws),
152
+ // 1) full capture, 2) no input field (grep fallback), 3) garbage input.
153
+ `{"toolName":"Write","token":"${token}","input":"${inputB64}"}\n` +
154
+ `{"toolName":"Write","token":"${grantToken("write", "b.txt")}"}\n` +
155
+ `{"toolName":"Write","token":"${grantToken("write", "c.txt")}","input":"!!!not-base64!!!"}\n`,
156
+ "utf-8",
157
+ );
158
+
159
+ const entries = await readDenialLedger(ws);
160
+ expect(entries).toHaveLength(3);
161
+ expect(entries[0].input).toEqual(input);
162
+ expect(entries[1].input).toBeUndefined();
163
+ expect(entries[2].input).toBeUndefined();
164
+ });
121
165
  });
122
166
 
123
167
  describe("reconcileDeniedToolCalls", () => {
124
- it("overlays WAITING_APPROVAL onto the REAL denied tool reported as completed (the green-check bug)", () => {
168
+ it("overlays WAITING_APPROVAL onto the REAL denied tool reported as completed (the green-check bug)", async () => {
125
169
  // Stream reports the file mutation as `edit` (RUNNING/COMPLETED); the hook
126
170
  // denied it as `Write`. The category+salient token bridges the two so the
127
171
  // overlay lands on this exact streamed tool call — no synthesized placeholder.
@@ -136,7 +180,7 @@ describe("reconcileDeniedToolCalls", () => {
136
180
  });
137
181
  const messages = [aiMessageWith([tc])];
138
182
 
139
- const reconciled = reconcileDeniedToolCalls(messages, [
183
+ const reconciled = await reconcileDeniedToolCalls(messages, [
140
184
  { toolName: "Write", token: grantToken("write", "gated.txt") },
141
185
  ]);
142
186
 
@@ -156,7 +200,7 @@ describe("reconcileDeniedToolCalls", () => {
156
200
  expect(tc.error).toBe("");
157
201
  });
158
202
 
159
- it("resolves the MCP policy message for a denied MCP tool", () => {
203
+ it("resolves the MCP policy message for a denied MCP tool", async () => {
160
204
  const tc = toolCall({
161
205
  id: "c1",
162
206
  name: "apply_x",
@@ -170,11 +214,12 @@ describe("reconcileDeniedToolCalls", () => {
170
214
  mcpServerSlug: "planton",
171
215
  requiresApproval: true,
172
216
  approvalMessage: "Apply infrastructure change",
217
+ source: "classifier_default",
173
218
  }],
174
219
  ]);
175
220
 
176
221
  // MCP tools are keyed name-only (their name is consistent across layers).
177
- reconcileDeniedToolCalls(messages, [
222
+ await reconcileDeniedToolCalls(messages, [
178
223
  { toolName: "apply_x", token: grantToken("apply_x", "") },
179
224
  ], policies);
180
225
 
@@ -182,7 +227,7 @@ describe("reconcileDeniedToolCalls", () => {
182
227
  expect(tc.approvalMessage).toBe("Apply infrastructure change");
183
228
  });
184
229
 
185
- it("leaves non-denied tool calls untouched while overlaying the denied one", () => {
230
+ it("leaves non-denied tool calls untouched while overlaying the denied one", async () => {
186
231
  const denied = toolCall({
187
232
  id: "c1",
188
233
  name: "edit",
@@ -197,7 +242,7 @@ describe("reconcileDeniedToolCalls", () => {
197
242
  });
198
243
  const messages = [aiMessageWith([denied, allowed])];
199
244
 
200
- const reconciled = reconcileDeniedToolCalls(messages, [
245
+ const reconciled = await reconcileDeniedToolCalls(messages, [
201
246
  { toolName: "Write", token: grantToken("write", "gated.txt") },
202
247
  ]);
203
248
 
@@ -209,25 +254,37 @@ describe("reconcileDeniedToolCalls", () => {
209
254
  expect(messages[0].toolCalls).toHaveLength(2);
210
255
  });
211
256
 
212
- it("collapses repeated denials of the same resource to a single approval", () => {
257
+ it("collapses repeated same-resource denials to one gate and one hidden twin", async () => {
258
+ // Two completed-but-denied edits of the same file (the green-check duplicate).
259
+ // The first becomes the single gate; the second is a content-less twin that
260
+ // must be collapsed IN PLACE to a hidden SKIPPED row, not left as a second
261
+ // settled card. Neither carries file_changes, so the collapse is safe.
213
262
  const first = toolCall({ id: "c1", name: "edit", args: { path: "gated.txt" } });
214
263
  const second = toolCall({ id: "c2", name: "edit", args: { path: "gated.txt" } });
215
264
  const messages = [aiMessageWith([first, second])];
216
265
 
217
- const reconciled = reconcileDeniedToolCalls(messages, [
266
+ const reconciled = await reconcileDeniedToolCalls(messages, [
218
267
  { toolName: "Write", token: grantToken("write", "gated.txt") },
219
268
  ]);
220
269
 
221
270
  // One approval anchor (so the backend gate resolves cleanly on one decision).
222
271
  expect(reconciled).toHaveLength(1);
223
272
  expect(first.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
224
- expect(second.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
273
+ // The twin is collapsed in place: SKIPPED, content-less, no approval — the SDK
274
+ // hides it (isCollapsedToolCall), so one resource renders one card. Its id is
275
+ // preserved, so the backend append-only guard accepts the finalize.
276
+ expect(second.id).toBe("c2");
277
+ expect(second.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
278
+ expect(second.requiresApproval).toBe(false);
279
+ expect(second.argsPreview).toBe("");
280
+ expect(second.error).toBe("");
281
+ expect(second.result).toBe("");
225
282
  });
226
283
 
227
- it("synthesizes a WAITING_APPROVAL tool call when a denial produced no stream event", () => {
284
+ it("synthesizes a WAITING_APPROVAL tool call when a denial produced no stream event", async () => {
228
285
  const messages = [aiMessageWith([])];
229
286
 
230
- const reconciled = reconcileDeniedToolCalls(messages, [
287
+ const reconciled = await reconcileDeniedToolCalls(messages, [
231
288
  { toolName: "Shell", token: grantToken("shell", "rm -rf build") },
232
289
  ]);
233
290
 
@@ -243,14 +300,960 @@ describe("reconcileDeniedToolCalls", () => {
243
300
  expect(synthesized.argsPreview).toContain("rm -rf build");
244
301
  });
245
302
 
246
- it("is a no-op when the ledger is empty", () => {
303
+ it("is a no-op when the ledger is empty", async () => {
247
304
  const tc = toolCall({ id: "c1", status: ToolCallStatus.TOOL_CALL_COMPLETED });
248
305
  const messages = [aiMessageWith([tc])];
249
- expect(reconcileDeniedToolCalls(messages, [])).toEqual([]);
306
+ expect(await reconcileDeniedToolCalls(messages, [])).toEqual([]);
250
307
  expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
251
308
  });
252
309
  });
253
310
 
311
+ // One resource emitted twice in a turn produced two cards: the gate plus a
312
+ // settled "No preview available" twin. The runner collapses that twin in place
313
+ // (it cannot drop the committed id) to a hidden SKIPPED row. These pin the
314
+ // collapse and its safety guard (a twin carrying its own change is never hidden).
315
+ describe("reconcileDeniedToolCalls — duplicate denial-twin collapse", () => {
316
+ it("collapses a same-resource FAILED twin beside the overlaid gate", async () => {
317
+ const gate = toolCall({
318
+ id: "stream-1",
319
+ name: "edit",
320
+ status: ToolCallStatus.TOOL_CALL_FAILED,
321
+ args: { path: "notes.md", old_string: "a", new_string: "b" },
322
+ argsPreview: JSON.stringify({ path: "notes.md" }),
323
+ });
324
+ const twin = toolCall({
325
+ id: "stream-2",
326
+ name: "edit",
327
+ status: ToolCallStatus.TOOL_CALL_FAILED,
328
+ error: "blocked by a hook",
329
+ args: { path: "notes.md", old_string: "a", new_string: "b" },
330
+ argsPreview: JSON.stringify({ path: "notes.md" }),
331
+ });
332
+ const messages = [aiMessageWith([gate, twin])];
333
+
334
+ const reconciled = await reconcileDeniedToolCalls(
335
+ messages,
336
+ [{ toolName: "Write", token: grantToken("write", "notes.md") }],
337
+ undefined,
338
+ rootBackend(),
339
+ );
340
+
341
+ // Exactly one gate; both committed ids are preserved (append-only).
342
+ expect(reconciled).toHaveLength(1);
343
+ expect(reconciled[0].id).toBe("stream-1");
344
+ expect(messages[0].toolCalls.map((t) => t.id)).toEqual(["stream-1", "stream-2"]);
345
+
346
+ expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
347
+ // The twin is collapsed to a hidden SKIPPED row.
348
+ expect(twin.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
349
+ expect(twin.requiresApproval).toBe(false);
350
+ expect(twin.error).toBe("");
351
+ expect(twin.argsPreview).toBe("");
352
+ });
353
+
354
+ it("collapses a same-command SHELL twin too (the collapse is tool-agnostic, not edit-only)", async () => {
355
+ // The duplicate is keyed on the identity token, so it folds for any gated
356
+ // tool family — shell here, exercising the same collapse a delete or MCP twin
357
+ // would receive. Both attempts at the same command were denied.
358
+ const gate = toolCall({
359
+ id: "sh-1",
360
+ name: "shell",
361
+ status: ToolCallStatus.TOOL_CALL_FAILED,
362
+ args: { command: "rm -rf build" },
363
+ argsPreview: JSON.stringify({ command: "rm -rf build" }),
364
+ });
365
+ const twin = toolCall({
366
+ id: "sh-2",
367
+ name: "shell",
368
+ status: ToolCallStatus.TOOL_CALL_FAILED,
369
+ error: "blocked by a hook",
370
+ args: { command: "rm -rf build" },
371
+ argsPreview: JSON.stringify({ command: "rm -rf build" }),
372
+ });
373
+ const messages = [aiMessageWith([gate, twin])];
374
+
375
+ const reconciled = await reconcileDeniedToolCalls(
376
+ messages,
377
+ [{ toolName: "Shell", token: grantToken("shell", "rm -rf build") }],
378
+ undefined,
379
+ rootBackend(),
380
+ );
381
+
382
+ expect(reconciled).toHaveLength(1);
383
+ expect(reconciled[0].id).toBe("sh-1");
384
+ expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
385
+ expect(twin.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
386
+ expect(twin.requiresApproval).toBe(false);
387
+ expect(twin.argsPreview).toBe("");
388
+ });
389
+
390
+ it("one gate per turn: surfaces the anchor (first denied) and defers a DISTINCT co-pending sibling", async () => {
391
+ // Two edits to DIFFERENT files denied in one turn. Under the Cursor deny-only
392
+ // one-gate-per-turn contract the FIRST denial (ledger[0] = a.md) is the single
393
+ // surfaced gate; the distinct sibling b.md is blanked to a hidden SKIPPED row
394
+ // and re-attempted (and re-gated) on the next turn — sequential gating, not a
395
+ // lost intent. (The native harness keeps full in-turn co-pending; this rule is
396
+ // cursor-only.)
397
+ const gateA = toolCall({
398
+ id: "edit-a",
399
+ name: "edit",
400
+ status: ToolCallStatus.TOOL_CALL_FAILED,
401
+ args: { path: "a.md", old_string: "1", new_string: "2" },
402
+ argsPreview: JSON.stringify({ path: "a.md" }),
403
+ });
404
+ const gateB = toolCall({
405
+ id: "edit-b",
406
+ name: "edit",
407
+ status: ToolCallStatus.TOOL_CALL_FAILED,
408
+ args: { path: "b.md", old_string: "3", new_string: "4" },
409
+ argsPreview: JSON.stringify({ path: "b.md" }),
410
+ });
411
+ const messages = [aiMessageWith([gateA, gateB])];
412
+
413
+ const reconciled = await reconcileDeniedToolCalls(
414
+ messages,
415
+ [
416
+ // The hook records the content-exact token for an edit (its primary token).
417
+ { toolName: "Write", token: toolCallIdentityToken(gateA) },
418
+ { toolName: "Write", token: toolCallIdentityToken(gateB) },
419
+ ],
420
+ undefined,
421
+ rootBackend(),
422
+ );
423
+
424
+ // Exactly one gate (the anchor); the sibling is hidden, not a second card.
425
+ expect(reconciled).toHaveLength(1);
426
+ expect(reconciled[0].id).toBe("edit-a");
427
+ expect(gateA.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
428
+ expect(gateB.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
429
+ expect(gateB.requiresApproval).toBe(false);
430
+ expect(gateB.argsPreview).toBe("");
431
+ });
432
+
433
+ it("collapses a same-path denied write twin onto the single gate (file rows carry no diff)", async () => {
434
+ // Under apply-then-review a file row never carries an authoritative change (it
435
+ // lives in the ledger), so a same-path denied write twin — even a distinct
436
+ // edit — collapses onto the one gate (one gate per turn per resource).
437
+ const gate = toolCall({
438
+ id: "stream-1",
439
+ name: "edit",
440
+ status: ToolCallStatus.TOOL_CALL_FAILED,
441
+ args: { path: "notes.md", old_string: "a", new_string: "b" },
442
+ });
443
+ const twin = toolCall({
444
+ id: "stream-2",
445
+ name: "edit",
446
+ status: ToolCallStatus.TOOL_CALL_FAILED,
447
+ args: { path: "notes.md", old_string: "c", new_string: "d" },
448
+ });
449
+ const messages = [aiMessageWith([gate, twin])];
450
+
451
+ await reconcileDeniedToolCalls(
452
+ messages,
453
+ [{ toolName: "Write", token: grantToken("write", "notes.md") }],
454
+ undefined,
455
+ rootBackend(),
456
+ );
457
+
458
+ expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
459
+ expect(twin.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
460
+ });
461
+ });
462
+
463
+ // One gate per turn (the H-F deny-only clean pause). When a denied edit is
464
+ // followed by a DIFFERENT-identity workaround (the classic `shell: cat > file`
465
+ // bypass) the first-denial stop races the SDK's auto-execution, so both denials
466
+ // can land in the ledger. The reconcile anchors on the FIRST denial and blanks
467
+ // every other denied identity to a hidden SKIPPED row — one card for one intent,
468
+ // regardless of whether the two calls share an assistant message. These pin the
469
+ // production shape (exec aex_01kw4p0cqgk0j8vvxbs5t8gv59: edit + shell in ONE
470
+ // message, no narration between them) that no positional or same-identity rule
471
+ // could fix.
472
+ describe("reconcileDeniedToolCalls — one gate per turn (deny-only workaround)", () => {
473
+ it("collapses the edit+shell workaround in the SAME message to one gate (the production bug)", async () => {
474
+ // edit notes.md (denied) then shell `cat > notes.md` (denied, reported as a
475
+ // success by Cursor) — both attached to ONE AgentMessage with no narration
476
+ // between, exactly as captured in production. The edit is the anchor; the
477
+ // shell is a post-denial reaction and must be hidden, not a second card.
478
+ const edit = toolCall({
479
+ id: "edit-1",
480
+ name: "edit",
481
+ status: ToolCallStatus.TOOL_CALL_FAILED,
482
+ args: { path: "notes.md", old_string: "", new_string: "hi" },
483
+ argsPreview: JSON.stringify({ path: "notes.md" }),
484
+ });
485
+ const shell = toolCall({
486
+ id: "shell-1",
487
+ name: "shell",
488
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
489
+ result: '""', // denied-reported-as-success degenerate result
490
+ args: { command: "cat > notes.md" },
491
+ argsPreview: JSON.stringify({ command: "cat > notes.md" }),
492
+ });
493
+ const messages = [aiMessageWith([edit, shell])];
494
+
495
+ const reconciled = await reconcileDeniedToolCalls(
496
+ messages,
497
+ [
498
+ { toolName: "Write", token: grantToken("write", "notes.md") },
499
+ { toolName: "Shell", token: grantToken("shell", "cat > notes.md") },
500
+ ],
501
+ undefined,
502
+ rootBackend(),
503
+ );
504
+
505
+ expect(reconciled).toHaveLength(1);
506
+ expect(reconciled[0].id).toBe("edit-1");
507
+ expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
508
+ // The workaround is hidden (SKIPPED), never surfaced, never COMPLETED.
509
+ expect(shell.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
510
+ expect(shell.requiresApproval).toBe(false);
511
+ expect(shell.result).toBe("");
512
+ expect(shell.argsPreview).toBe("");
513
+ // Both ids are preserved in place (append-only finalize).
514
+ expect(messages[0].toolCalls.map((t) => t.id)).toEqual(["edit-1", "shell-1"]);
515
+ });
516
+
517
+ it("collapses the workaround even when the shell lands in a LATER message (narration case)", async () => {
518
+ // The narration variant: a new AgentMessage starts between the two tools.
519
+ // The rule is identity/ledger-based, not positional, so the outcome matches
520
+ // the same-message case above — proving it is robust to message segmentation.
521
+ const edit = toolCall({
522
+ id: "edit-1",
523
+ name: "edit",
524
+ status: ToolCallStatus.TOOL_CALL_FAILED,
525
+ args: { path: "notes.md", old_string: "", new_string: "hi" },
526
+ argsPreview: JSON.stringify({ path: "notes.md" }),
527
+ });
528
+ const shell = toolCall({
529
+ id: "shell-1",
530
+ name: "shell",
531
+ status: ToolCallStatus.TOOL_CALL_FAILED,
532
+ error: "blocked by a hook",
533
+ args: { command: "cat > notes.md" },
534
+ argsPreview: JSON.stringify({ command: "cat > notes.md" }),
535
+ });
536
+ const messages = [aiMessageWith([edit]), aiMessageWith([shell])];
537
+
538
+ const reconciled = await reconcileDeniedToolCalls(
539
+ messages,
540
+ [
541
+ { toolName: "Write", token: grantToken("write", "notes.md") },
542
+ { toolName: "Shell", token: grantToken("shell", "cat > notes.md") },
543
+ ],
544
+ undefined,
545
+ rootBackend(),
546
+ );
547
+
548
+ expect(reconciled).toHaveLength(1);
549
+ expect(reconciled[0].id).toBe("edit-1");
550
+ expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
551
+ expect(shell.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
552
+ });
553
+
554
+ it("anchors on the FIRST denial by ledger order (shell first -> shell is the gate)", async () => {
555
+ // The anchor is the first denial of the turn, whatever its family. Here the
556
+ // shell was denied first, so it is the surfaced gate and the later edit is
557
+ // the deferred sibling.
558
+ const shell = toolCall({
559
+ id: "shell-1",
560
+ name: "shell",
561
+ status: ToolCallStatus.TOOL_CALL_FAILED,
562
+ args: { command: "echo hi > notes.md" },
563
+ argsPreview: JSON.stringify({ command: "echo hi > notes.md" }),
564
+ });
565
+ const edit = toolCall({
566
+ id: "edit-1",
567
+ name: "edit",
568
+ status: ToolCallStatus.TOOL_CALL_FAILED,
569
+ args: { path: "notes.md", old_string: "", new_string: "hi" },
570
+ argsPreview: JSON.stringify({ path: "notes.md" }),
571
+ });
572
+ const messages = [aiMessageWith([shell, edit])];
573
+
574
+ const reconciled = await reconcileDeniedToolCalls(
575
+ messages,
576
+ [
577
+ { toolName: "Shell", token: toolCallIdentityToken(shell) },
578
+ { toolName: "Write", token: toolCallIdentityToken(edit) },
579
+ ],
580
+ undefined,
581
+ rootBackend(),
582
+ );
583
+
584
+ expect(reconciled).toHaveLength(1);
585
+ expect(reconciled[0].id).toBe("shell-1");
586
+ expect(shell.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
587
+ expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
588
+ });
589
+
590
+ it("anchors an MCP gate and collapses a built-in reaction", async () => {
591
+ // The anchor can be an MCP tool; a built-in workaround denied in the same
592
+ // turn is still the deferred sibling.
593
+ const mcp = toolCall({
594
+ id: "mcp-1",
595
+ name: "fetch",
596
+ mcpServerSlug: "web",
597
+ status: ToolCallStatus.TOOL_CALL_FAILED,
598
+ argsPreview: JSON.stringify({ url: "https://x" }),
599
+ });
600
+ const edit = toolCall({
601
+ id: "edit-1",
602
+ name: "edit",
603
+ status: ToolCallStatus.TOOL_CALL_FAILED,
604
+ args: { path: "notes.md", old_string: "", new_string: "hi" },
605
+ argsPreview: JSON.stringify({ path: "notes.md" }),
606
+ });
607
+ const messages = [aiMessageWith([mcp, edit])];
608
+
609
+ const reconciled = await reconcileDeniedToolCalls(
610
+ messages,
611
+ [
612
+ { toolName: "fetch", token: toolCallIdentityToken(mcp) },
613
+ { toolName: "Write", token: toolCallIdentityToken(edit) },
614
+ ],
615
+ undefined,
616
+ rootBackend(),
617
+ );
618
+
619
+ expect(reconciled).toHaveLength(1);
620
+ expect(reconciled[0].id).toBe("mcp-1");
621
+ expect(mcp.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
622
+ expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
623
+ });
624
+
625
+ it("regression lock: a lone denial is unchanged (one gate, nothing collapsed)", async () => {
626
+ const edit = toolCall({
627
+ id: "edit-1",
628
+ name: "edit",
629
+ status: ToolCallStatus.TOOL_CALL_FAILED,
630
+ args: { path: "notes.md", old_string: "", new_string: "hi" },
631
+ argsPreview: JSON.stringify({ path: "notes.md" }),
632
+ });
633
+ const messages = [aiMessageWith([edit])];
634
+
635
+ const reconciled = await reconcileDeniedToolCalls(
636
+ messages,
637
+ [{ toolName: "Write", token: grantToken("write", "notes.md") }],
638
+ undefined,
639
+ rootBackend(),
640
+ );
641
+
642
+ expect(reconciled).toHaveLength(1);
643
+ expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
644
+ });
645
+
646
+ it("round-trip: approving the anchor never grants the collapsed workaround", async () => {
647
+ // After the user approves the surfaced gate, the grant set keys only on the
648
+ // original write — the shell, hidden as SKIPPED, never becomes a pending
649
+ // approval and so can never be granted (approving the write != approving the
650
+ // shell). This is the safety property the duplicate cards used to violate.
651
+ const edit = toolCall({
652
+ id: "edit-1",
653
+ name: "edit",
654
+ status: ToolCallStatus.TOOL_CALL_FAILED,
655
+ args: { path: "notes.md", old_string: "", new_string: "hi" },
656
+ argsPreview: JSON.stringify({ path: "notes.md" }),
657
+ });
658
+ const shell = toolCall({
659
+ id: "shell-1",
660
+ name: "shell",
661
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
662
+ result: '""',
663
+ args: { command: "cat > notes.md" },
664
+ argsPreview: JSON.stringify({ command: "cat > notes.md" }),
665
+ });
666
+ const messages = [aiMessageWith([edit, shell])];
667
+
668
+ const reconciled = await reconcileDeniedToolCalls(
669
+ messages,
670
+ [
671
+ { toolName: "Write", token: grantToken("write", "notes.md") },
672
+ { toolName: "Shell", token: grantToken("shell", "cat > notes.md") },
673
+ ],
674
+ undefined,
675
+ rootBackend(),
676
+ );
677
+
678
+ // The backend projects pending approvals from WAITING_APPROVAL tool calls —
679
+ // only the anchor qualifies. Simulate the user approving every surfaced gate.
680
+ const pendingApprovals = reconciled.map((tc) =>
681
+ create(PendingApprovalSchema, {
682
+ toolCallId: tc.id,
683
+ toolName: tc.name,
684
+ mcpServerSlug: tc.mcpServerSlug,
685
+ argsPreview: tc.argsPreview,
686
+ }),
687
+ );
688
+ const decisions = new Map(
689
+ pendingApprovals.map((pa) => [pa.toolCallId, ApprovalAction.APPROVE]),
690
+ );
691
+ const grants = buildApprovalGrants(pendingApprovals, decisions);
692
+ const grantTokens = grants.map((g) => grantToken(g.key, g.salient));
693
+
694
+ expect(grantTokens).toContain(grantToken("write", "notes.md"));
695
+ expect(grantTokens).not.toContain(grantToken("shell", "cat > notes.md"));
696
+ });
697
+ });
698
+
699
+ // The terminal/resume path has no denial ledger (the tool is already granted), so
700
+ // reconcileDeniedToolCalls never runs. The shared routine runs directly at the
701
+ // terminal finalize and must collapse the duplicate-edit shapes observed in
702
+ // production data while preserving genuine distinct work and every non-file tool.
703
+ describe("reconcileDeniedToolCalls — interrupted non-terminal rows (raced workaround)", () => {
704
+ function aiText(content: string): AgentMessage {
705
+ return create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content });
706
+ }
707
+ function thinking(content: string): AgentMessage {
708
+ return create(AgentMessageSchema, { type: MessageType.MESSAGE_THINKING, content });
709
+ }
710
+
711
+ it("collapses a workaround stuck RUNNING whose denial never reached the ledger, and the redaction then blanks the whole reaction block (production shape aex_01kwj07f7g23c3wp9sn8496z5g)", async () => {
712
+ // The forensic transcript: the anchor shell was denied (ledger entry), the
713
+ // model reacted with thinking + narration + a python-write shell workaround,
714
+ // and the run.cancel() landed before the workaround's own hook denial
715
+ // reached the ledger read — so the token-scoped collapse could not see it.
716
+ // It persisted as RUNNING + requiresApproval forever, and its tool-bearing
717
+ // message stopped the narration redaction from blanking anything.
718
+ const seqCommand = "for i in $(seq 1 5000); do echo line $i; done > big.txt";
719
+ const anchor = toolCall({
720
+ id: "shell-anchor",
721
+ name: "shell",
722
+ status: ToolCallStatus.TOOL_CALL_FAILED,
723
+ args: { command: seqCommand },
724
+ argsPreview: JSON.stringify({ command: seqCommand }),
725
+ requiresApproval: true,
726
+ });
727
+ const workaround = toolCall({
728
+ id: "shell-workaround",
729
+ name: "shell",
730
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
731
+ args: { command: "python3 -c \"open('big.txt','w').write('...')\"" },
732
+ argsPreview: JSON.stringify({ command: "python3 -c ..." }),
733
+ requiresApproval: true,
734
+ });
735
+ const narration = aiText("Generating the file with a script since the shell command needs approval.");
736
+ narration.toolCalls = [workaround];
737
+ const messages: AgentMessage[] = [
738
+ aiMessageWith([anchor]),
739
+ thinking("Shell commands were blocked. The Write tool will be used instead."),
740
+ narration,
741
+ ];
742
+
743
+ // Only the anchor's denial is in the ledger — the workaround's raced it.
744
+ const reconciled = await reconcileDeniedToolCalls(
745
+ messages,
746
+ [{ toolName: "Shell", token: toolCallIdentityToken(anchor) }],
747
+ undefined,
748
+ rootBackend(),
749
+ );
750
+
751
+ expect(reconciled).toHaveLength(1);
752
+ expect(anchor.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
753
+ // The interrupted workaround is finalized to the hidden SKIPPED shape —
754
+ // never an eternal spinner, never a second approval card.
755
+ expect(workaround.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
756
+ expect(workaround.requiresApproval).toBe(false);
757
+ expect(workaround.args).toBeUndefined();
758
+
759
+ // With the workaround hidden, its message is trailing narration again and
760
+ // the whole reaction block blanks — thinking included.
761
+ const redacted = clearProvisionalPostDenialNarration(messages, reconciled);
762
+ expect(redacted).toHaveLength(2);
763
+ expect(messages[1].content).toBe("");
764
+ expect(messages[2].content).toBe("");
765
+ // Ids preserved in place (append-only finalize).
766
+ expect(messages[2].toolCalls.map((t) => t.id)).toEqual(["shell-workaround"]);
767
+ });
768
+
769
+ it("leaves terminal rows untouched: a completed tool after the gate is real activity and still stops the redaction walk", async () => {
770
+ const anchor = toolCall({
771
+ id: "shell-anchor",
772
+ name: "shell",
773
+ status: ToolCallStatus.TOOL_CALL_FAILED,
774
+ args: { command: "rm -rf build" },
775
+ argsPreview: JSON.stringify({ command: "rm -rf build" }),
776
+ });
777
+ const completedRead = toolCall({
778
+ id: "read-1",
779
+ name: "read",
780
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
781
+ result: "file contents",
782
+ args: { path: "a.txt" },
783
+ });
784
+ const readMsg = aiMessageWith([completedRead]);
785
+ const messages: AgentMessage[] = [
786
+ aiMessageWith([anchor]),
787
+ readMsg,
788
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content: "trailing reaction" }),
789
+ ];
790
+
791
+ const reconciled = await reconcileDeniedToolCalls(
792
+ messages,
793
+ [{ toolName: "Shell", token: toolCallIdentityToken(anchor) }],
794
+ undefined,
795
+ rootBackend(),
796
+ );
797
+
798
+ // The completed read is terminal — the interrupted-row sweep never touches it.
799
+ expect(completedRead.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
800
+ expect(completedRead.result).toBe("file contents");
801
+
802
+ // And as a VISIBLE tool-bearing message it bounds the redaction: only the
803
+ // text after it blanks.
804
+ const redacted = clearProvisionalPostDenialNarration(messages, reconciled);
805
+ expect(redacted).toHaveLength(1);
806
+ expect(messages[2].content).toBe("");
807
+ expect(readMsg.toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
808
+ });
809
+ });
810
+
811
+ describe("collapseRedundantToolCallTwins — terminal-path twin collapse", () => {
812
+ function completedEdit(id: string, path: string): ToolCall {
813
+ return toolCall({
814
+ id,
815
+ name: "edit",
816
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
817
+ args: { path },
818
+ argsPreview: JSON.stringify({ path }),
819
+ result: "success",
820
+ });
821
+ }
822
+
823
+ function bareEdit(id: string, path: string, overrides: MessageInitShape<typeof ToolCallSchema> = {}): ToolCall {
824
+ return toolCall({
825
+ id,
826
+ name: "edit",
827
+ args: { path },
828
+ argsPreview: JSON.stringify({ path }),
829
+ ...overrides,
830
+ });
831
+ }
832
+
833
+ it("collapses a stuck RUNNING twin beside the settled COMPLETED edit (the screenshot)", () => {
834
+ const settled = completedEdit("tool-approved", "notes.md");
835
+ const zombie = bareEdit("tool-zombie", "notes.md", {
836
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
837
+ });
838
+ const messages = [aiMessageWith([settled, zombie])];
839
+
840
+ const collapsed = collapseRedundantToolCallTwins(messages);
841
+
842
+ expect(collapsed).toBe(1);
843
+ // The settled attempt is the survivor (terminal preferred over the zombie).
844
+ expect(settled.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
845
+ expect(zombie.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
846
+ expect(zombie.requiresApproval).toBe(false);
847
+ expect(zombie.argsPreview).toBe("");
848
+ // Both committed ids are preserved (append-only by construction).
849
+ expect(messages[0].toolCalls.map((t) => t.id)).toEqual(["tool-approved", "tool-zombie"]);
850
+ });
851
+
852
+ it("keeps exactly one representative when every attempt produced no change", () => {
853
+ // Two COMPLETED edits to one path, neither carrying a diff (both the empty-
854
+ // result green-check). No diff-carrier and no gate — keep one card, hide the
855
+ // rest, so the resource never renders as a duplicate.
856
+ const first = bareEdit("tool-a", "notes.md", {
857
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
858
+ result: '""',
859
+ });
860
+ const second = bareEdit("tool-b", "notes.md", {
861
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
862
+ result: '""',
863
+ });
864
+ const messages = [aiMessageWith([first, second])];
865
+
866
+ const collapsed = collapseRedundantToolCallTwins(messages);
867
+
868
+ expect(collapsed).toBe(1);
869
+ const visible = messages[0].toolCalls.filter(
870
+ (t) => t.status !== ToolCallStatus.TOOL_CALL_SKIPPED,
871
+ );
872
+ expect(visible).toHaveLength(1);
873
+ });
874
+
875
+ it("prefers a settled attempt over a stuck RUNNING zombie as the survivor", () => {
876
+ const zombie = bareEdit("tool-running", "notes.md", {
877
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
878
+ });
879
+ const done = bareEdit("tool-done", "notes.md", {
880
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
881
+ result: '""',
882
+ });
883
+ const messages = [aiMessageWith([zombie, done])];
884
+
885
+ collapseRedundantToolCallTwins(messages);
886
+
887
+ expect(done.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
888
+ expect(zombie.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
889
+ });
890
+
891
+ it("hides a RUNNING twin beside a WAITING_APPROVAL gate, leaving the gate intact", () => {
892
+ const gate = bareEdit("gate", "notes.md", {
893
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
894
+ requiresApproval: true,
895
+ });
896
+ const zombie = bareEdit("zombie", "notes.md", {
897
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
898
+ });
899
+ const messages = [aiMessageWith([gate, zombie])];
900
+
901
+ const collapsed = collapseRedundantToolCallTwins(messages);
902
+
903
+ expect(collapsed).toBe(1);
904
+ expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
905
+ expect(zombie.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
906
+ });
907
+
908
+ it("makes a WAITING_APPROVAL gate the SOLE keeper: collapses a same-path write sibling", () => {
909
+ // Two whole-file writes to one path in one turn: the gate (call A) is the
910
+ // single review surface; the sibling (call B) is redundant (a file row carries
911
+ // no authoritative change — the ledger does), so it must collapse — one gate.
912
+ const gate = bareEdit("gate", "notes.md", {
913
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
914
+ requiresApproval: true,
915
+ });
916
+ const staleSibling = toolCall({
917
+ id: "stale-sibling",
918
+ name: "write",
919
+ status: ToolCallStatus.TOOL_CALL_FAILED,
920
+ args: { path: "notes.md", content: "stale\n" },
921
+ argsPreview: JSON.stringify({ path: "notes.md" }),
922
+ });
923
+ const messages = [aiMessageWith([gate, staleSibling])];
924
+
925
+ const collapsed = collapseRedundantToolCallTwins(messages);
926
+
927
+ expect(collapsed).toBe(1);
928
+ expect(gate.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
929
+ expect(staleSibling.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
930
+ // Both committed ids are preserved (append-only by construction).
931
+ expect(messages[0].toolCalls.map((t) => t.id)).toEqual(["gate", "stale-sibling"]);
932
+ });
933
+
934
+ it("keeps BOTH identical shell runs that each produced output (output is their change)", () => {
935
+ const runA = toolCall({
936
+ id: "sh-a",
937
+ name: "shell",
938
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
939
+ args: { command: "ls" },
940
+ argsPreview: JSON.stringify({ command: "ls" }),
941
+ result: "file.txt",
942
+ });
943
+ const runB = toolCall({
944
+ id: "sh-b",
945
+ name: "shell",
946
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
947
+ args: { command: "ls" },
948
+ argsPreview: JSON.stringify({ command: "ls" }),
949
+ result: "file.txt",
950
+ });
951
+ const messages = [aiMessageWith([runA, runB])];
952
+
953
+ const collapsed = collapseRedundantToolCallTwins(messages);
954
+
955
+ expect(collapsed).toBe(0);
956
+ expect(runA.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
957
+ expect(runB.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
958
+ });
959
+
960
+ it("leaves an ungated read-only duplicate untouched (out of scope)", () => {
961
+ const readA = toolCall({
962
+ id: "r-a",
963
+ name: "read",
964
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
965
+ args: { path: "notes.md" },
966
+ argsPreview: JSON.stringify({ path: "notes.md" }),
967
+ });
968
+ const readB = toolCall({
969
+ id: "r-b",
970
+ name: "read",
971
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
972
+ args: { path: "notes.md" },
973
+ argsPreview: JSON.stringify({ path: "notes.md" }),
974
+ result: "contents",
975
+ });
976
+ const messages = [aiMessageWith([readA, readB])];
977
+
978
+ const collapsed = collapseRedundantToolCallTwins(messages);
979
+
980
+ expect(collapsed).toBe(0);
981
+ expect(readA.status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
982
+ expect(readB.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
983
+ });
984
+ });
985
+
986
+ // The hook captures the COMPLETE proposed args (tool_input) at gate time — the
987
+ // authoritative source the stream may not have carried before the first-denial
988
+ // cancel. These pin that the runner overlays that input onto the gated call so
989
+ // the approval card renders the proposed change from `args`, for every tool kind,
990
+ // with a compact-but-valid args_preview that preserves the resume-grant salient,
991
+ // and stamps the content digest that binds the resume grant + re-gates a sibling.
992
+ describe("reconcileDeniedToolCalls — authoritative hook input overlay", () => {
993
+ function parsePreview(tc: ToolCall): Record<string, unknown> {
994
+ return JSON.parse(tc.argsPreview) as Record<string, unknown>;
995
+ }
996
+ function args(tc: ToolCall): Record<string, unknown> {
997
+ return (tc.args ?? {}) as Record<string, unknown>;
998
+ }
999
+
1000
+ it("stamps the authoritative write content + a salient-preserving preview + a digest", async () => {
1001
+ const tc = toolCall({
1002
+ id: "c1",
1003
+ name: "write",
1004
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
1005
+ argsPreview: JSON.stringify({ path: "src/new.ts" }),
1006
+ });
1007
+ const messages = [aiMessageWith([tc])];
1008
+
1009
+ await reconcileDeniedToolCalls(
1010
+ messages,
1011
+ [{
1012
+ toolName: "Write",
1013
+ token: grantToken("write", "src/new.ts"),
1014
+ input: { file_path: "src/new.ts", content: "export const x = 1;\n" },
1015
+ }],
1016
+ undefined,
1017
+ rootBackend(),
1018
+ );
1019
+
1020
+ // The full proposed content lives on args (the single source exact-apply reads).
1021
+ expect(args(tc).content).toBe("export const x = 1;\n");
1022
+ // args_preview reflects the authoritative input and stays parseable.
1023
+ expect(parsePreview(tc).file_path).toBe("src/new.ts");
1024
+ // The digest binds the resume grant / re-gates a diverging sibling.
1025
+ expect(tc.approvalContentDigest).not.toBe("");
1026
+ });
1027
+
1028
+ it("stamps the captured edit old/new strings and a digest", async () => {
1029
+ const tc = toolCall({
1030
+ id: "c1",
1031
+ name: "edit",
1032
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
1033
+ argsPreview: JSON.stringify({ path: "src/app.ts" }),
1034
+ });
1035
+ const messages = [aiMessageWith([tc])];
1036
+
1037
+ await reconcileDeniedToolCalls(
1038
+ messages,
1039
+ [{
1040
+ toolName: "StrReplace",
1041
+ token: grantToken("write", "src/app.ts"),
1042
+ input: { file_path: "src/app.ts", old_string: "alpha", new_string: "beta" },
1043
+ }],
1044
+ undefined,
1045
+ rootBackend(),
1046
+ );
1047
+
1048
+ expect(args(tc).old_string).toBe("alpha");
1049
+ expect(args(tc).new_string).toBe("beta");
1050
+ expect(tc.approvalContentDigest).not.toBe("");
1051
+ });
1052
+
1053
+ it("preserves a notebook edit's target_notebook salient in the preview", async () => {
1054
+ const tc = toolCall({
1055
+ id: "c1",
1056
+ name: "EditNotebook",
1057
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
1058
+ argsPreview: JSON.stringify({ target_notebook: "nb.ipynb" }),
1059
+ });
1060
+ const messages = [aiMessageWith([tc])];
1061
+
1062
+ await reconcileDeniedToolCalls(
1063
+ messages,
1064
+ [{
1065
+ toolName: "EditNotebook",
1066
+ token: grantToken("write", "nb.ipynb"),
1067
+ input: { target_notebook: "nb.ipynb", old_string: "x = 1", new_string: "x = 2" },
1068
+ }],
1069
+ undefined,
1070
+ rootBackend(),
1071
+ );
1072
+
1073
+ expect(parsePreview(tc).target_notebook).toBe("nb.ipynb");
1074
+ });
1075
+
1076
+ it("carries shell args so the gate shows the command", async () => {
1077
+ const tc = toolCall({
1078
+ id: "c1",
1079
+ name: "shell",
1080
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
1081
+ argsPreview: JSON.stringify({ command: "rm -rf build" }),
1082
+ });
1083
+ const messages = [aiMessageWith([tc])];
1084
+
1085
+ await reconcileDeniedToolCalls(
1086
+ messages,
1087
+ [{
1088
+ toolName: "Shell",
1089
+ token: grantToken("shell", "rm -rf build"),
1090
+ input: { command: "rm -rf build", cwd: "/root" },
1091
+ }],
1092
+ undefined,
1093
+ rootBackend(),
1094
+ );
1095
+
1096
+ expect(parsePreview(tc).command).toBe("rm -rf build");
1097
+ expect(parsePreview(tc).cwd).toBe("/root");
1098
+ });
1099
+
1100
+ it("upgrades a synthesized placeholder from path-only to the full captured args", async () => {
1101
+ // No streamed call matches (rare); the placeholder must still carry the real
1102
+ // proposed content from the captured input, not a bare {path}.
1103
+ const messages = [aiMessageWith([])];
1104
+
1105
+ const reconciled = await reconcileDeniedToolCalls(
1106
+ messages,
1107
+ [{
1108
+ toolName: "Write",
1109
+ token: grantToken("write", "ghost.md"),
1110
+ input: { file_path: "ghost.md", content: "# Ghost\n" },
1111
+ }],
1112
+ undefined,
1113
+ rootBackend(),
1114
+ );
1115
+
1116
+ expect(reconciled).toHaveLength(1);
1117
+ const tc = reconciled[0];
1118
+ expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
1119
+ expect(args(tc).content).toBe("# Ghost\n");
1120
+ });
1121
+
1122
+ it("keeps a large write's args_preview small, valid, and salient-preserving", async () => {
1123
+ const content = "x".repeat(50_000);
1124
+ const tc = toolCall({
1125
+ id: "c1",
1126
+ name: "write",
1127
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
1128
+ argsPreview: JSON.stringify({ path: "big.ts" }),
1129
+ });
1130
+ const messages = [aiMessageWith([tc])];
1131
+
1132
+ await reconcileDeniedToolCalls(
1133
+ messages,
1134
+ [{
1135
+ toolName: "Write",
1136
+ token: grantToken("write", "big.ts"),
1137
+ input: { file_path: "big.ts", content },
1138
+ }],
1139
+ undefined,
1140
+ rootBackend(),
1141
+ );
1142
+
1143
+ // The preview is bounded and parseable (resume reads it) with the salient
1144
+ // preserved; the full content lives on args (which exact-apply reads).
1145
+ expect(tc.argsPreview.length).toBeLessThan(1_000);
1146
+ expect(parsePreview(tc).file_path).toBe("big.ts");
1147
+ expect((args(tc).content as string).length).toBe(content.length);
1148
+ });
1149
+ });
1150
+
1151
+ // The hook computes its denial identity token from the RAW path Cursor hands it
1152
+ // (it is a bash script and cannot normalize against the workspace root), while
1153
+ // the stream event may carry the same file under a different path FORM (the
1154
+ // classic case: an ABSOLUTE file_path in the hook input vs. a RELATIVE path in
1155
+ // the stream). Their raw tokens then differ, exact correlation misses, and the
1156
+ // reconcile would synthesize a content-less WAITING_APPROVAL placeholder BESIDE
1157
+ // the real streamed call — two cards for one edit, the gate showing "No preview
1158
+ // available" because the synthesized placeholder carries no file_changes. These
1159
+ // pin the runner-side normalized-path fallback that overlays the REAL streamed
1160
+ // call instead (guard-safe: it reuses the already-committed id, never drops one,
1161
+ // and the captured new-file content survives onto the gate).
1162
+ describe("reconcileDeniedToolCalls — normalized-path fallback (abs/rel drift)", () => {
1163
+ it("overlays the real streamed write when the hook salient is absolute but the stream path is relative", async () => {
1164
+ // A denied write streamed (and was committed) as TOOL_CALL_FAILED; the hook
1165
+ // recorded the denial under the absolute path, so the raw tokens do not match.
1166
+ const streamed = toolCall({
1167
+ id: "stream-create",
1168
+ name: "write",
1169
+ status: ToolCallStatus.TOOL_CALL_FAILED,
1170
+ args: { path: "notes.md", contents: "# Notes\n" },
1171
+ });
1172
+ const messages = [aiMessageWith([streamed])];
1173
+
1174
+ const reconciled = await reconcileDeniedToolCalls(
1175
+ messages,
1176
+ [{ toolName: "Write", token: grantToken("write", "/root/notes.md") }],
1177
+ undefined,
1178
+ rootBackend(),
1179
+ );
1180
+
1181
+ // No synthesized placeholder was appended; the only tool call is the real
1182
+ // streamed one (its committed id preserved → backend append-only guard-safe).
1183
+ const ids = messages.flatMap((m) => m.toolCalls.map((t) => t.id));
1184
+ expect(ids).toEqual(["stream-create"]);
1185
+ expect(ids.some((id) => id.startsWith("approval:"))).toBe(false);
1186
+
1187
+ // The streamed call was overlaid in place as the single gate.
1188
+ expect(streamed.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
1189
+ expect(streamed.requiresApproval).toBe(true);
1190
+ expect(reconciled).toHaveLength(1);
1191
+ expect(reconciled[0].id).toBe("stream-create");
1192
+ });
1193
+
1194
+ it("overlays a denied EDIT under abs/rel drift via the shared overlay path", async () => {
1195
+ const streamed = toolCall({
1196
+ id: "stream-edit",
1197
+ name: "edit",
1198
+ status: ToolCallStatus.TOOL_CALL_FAILED,
1199
+ args: { path: "src/app.ts", old_string: "alpha", new_string: "beta" },
1200
+ });
1201
+ const messages = [aiMessageWith([streamed])];
1202
+
1203
+ await reconcileDeniedToolCalls(
1204
+ messages,
1205
+ [{ toolName: "Write", token: grantToken("write", "/root/src/app.ts") }],
1206
+ undefined,
1207
+ rootBackend(),
1208
+ );
1209
+
1210
+ const ids = messages.flatMap((m) => m.toolCalls.map((t) => t.id));
1211
+ expect(ids).toEqual(["stream-edit"]);
1212
+ expect(streamed.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
1213
+ });
1214
+
1215
+ it("still synthesizes a placeholder when NO streamed call matches even after normalization", async () => {
1216
+ // The genuine no-stream-event denial (rare) must still surface a gate.
1217
+ const messages = [aiMessageWith([])];
1218
+
1219
+ const reconciled = await reconcileDeniedToolCalls(
1220
+ messages,
1221
+ [{ toolName: "Write", token: grantToken("write", "/root/ghost.md") }],
1222
+ undefined,
1223
+ rootBackend(),
1224
+ );
1225
+
1226
+ expect(reconciled).toHaveLength(1);
1227
+ expect(reconciled[0].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
1228
+ expect(reconciled[0].id.startsWith("approval:")).toBe(true);
1229
+ });
1230
+
1231
+ it("does not overlay a DIFFERENT file that happens to be denied (no false normalized match)", async () => {
1232
+ // A streamed create for one file must not absorb a denial for another file
1233
+ // just because the fallback ran — normalization is per-path.
1234
+ const streamed = toolCall({
1235
+ id: "stream-other",
1236
+ name: "write",
1237
+ status: ToolCallStatus.TOOL_CALL_FAILED,
1238
+ args: { path: "other.md", contents: "x" },
1239
+ });
1240
+ const messages = [aiMessageWith([streamed])];
1241
+
1242
+ const reconciled = await reconcileDeniedToolCalls(
1243
+ messages,
1244
+ [{ toolName: "Write", token: grantToken("write", "/root/notes.md") }],
1245
+ undefined,
1246
+ rootBackend(),
1247
+ );
1248
+
1249
+ // The unrelated streamed call is untouched; the denial is satisfied by a
1250
+ // synthesized placeholder for the actually-denied file.
1251
+ expect(streamed.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
1252
+ expect(reconciled).toHaveLength(1);
1253
+ expect(reconciled[0].id).toBe("approval:" + grantToken("write", "/root/notes.md"));
1254
+ });
1255
+ });
1256
+
254
1257
  describe("reconstructAdjudicatedApprovals", () => {
255
1258
  it("reads decisions and rebuilds pending approvals from adjudicated tool calls", () => {
256
1259
  const approved = toolCall({
@@ -296,15 +1299,282 @@ describe("reconstructAdjudicatedApprovals", () => {
296
1299
  });
297
1300
  });
298
1301
 
1302
+ // When a Cursor turn pauses for approval, the model frequently reacts to the
1303
+ // deny ("blocked by a hook; enable it in your Cursor settings") and that
1304
+ // provisional verdict must NOT render next to the approval card. These pin the
1305
+ // deterministic positional rule that BLANKS exactly the trailing reaction block
1306
+ // (preserving the message count so the finalize stays append-only) and touches
1307
+ // nothing load-bearing.
1308
+ describe("clearProvisionalPostDenialNarration", () => {
1309
+ function aiText(content: string): AgentMessage {
1310
+ return create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content });
1311
+ }
1312
+ function thinking(content: string): AgentMessage {
1313
+ return create(AgentMessageSchema, { type: MessageType.MESSAGE_THINKING, content });
1314
+ }
1315
+
1316
+ it("blanks the trailing assistant/thinking block after the last gated tool call without removing it", () => {
1317
+ const gated = toolCall({ id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL });
1318
+ const messages: AgentMessage[] = [
1319
+ aiText("Let me create the file."),
1320
+ aiMessageWith([gated]),
1321
+ thinking("The hook blocked me; the environment must be misconfigured."),
1322
+ aiText("I couldn't do this — please enable the hook in your Cursor settings."),
1323
+ ];
1324
+
1325
+ const redacted = clearProvisionalPostDenialNarration(messages, [gated]);
1326
+
1327
+ expect(redacted).toHaveLength(2);
1328
+ // Append-only by construction: count is preserved, only content is blanked.
1329
+ expect(messages).toHaveLength(4);
1330
+ expect(messages[0].content).toBe("Let me create the file."); // pre-tool text untouched
1331
+ expect(messages[1].toolCalls[0]).toBe(gated); // gated call untouched
1332
+ expect(messages[2].content).toBe(""); // provisional thinking blanked
1333
+ expect(messages[2].isStreaming).toBe(false);
1334
+ expect(messages[3].content).toBe(""); // provisional verdict blanked
1335
+ expect(messages[3].isStreaming).toBe(false);
1336
+ // The redacted handles are the very messages still in the transcript.
1337
+ expect(redacted[0]).toBe(messages[2]);
1338
+ expect(redacted[1]).toBe(messages[3]);
1339
+ });
1340
+
1341
+ it("preserves pre-tool narration and the gated call itself", () => {
1342
+ const gated = toolCall({ id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL });
1343
+ const messages: AgentMessage[] = [aiText("Working on it."), aiMessageWith([gated])];
1344
+
1345
+ const redacted = clearProvisionalPostDenialNarration(messages, [gated]);
1346
+
1347
+ expect(redacted).toHaveLength(0);
1348
+ expect(messages).toHaveLength(2);
1349
+ expect(messages[0].content).toBe("Working on it.");
1350
+ });
1351
+
1352
+ it("stops at the first tool-bearing message so real post-gate activity is kept", () => {
1353
+ const gated = toolCall({ id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL });
1354
+ const readAfter = toolCall({ id: "c2", name: "read", status: ToolCallStatus.TOOL_CALL_COMPLETED });
1355
+ const messages: AgentMessage[] = [
1356
+ aiMessageWith([gated]),
1357
+ aiText("checking something else"),
1358
+ aiMessageWith([readAfter]), // real activity — not trailing narration
1359
+ ];
1360
+
1361
+ const redacted = clearProvisionalPostDenialNarration(messages, [gated]);
1362
+
1363
+ // The read message is the last gated-or-activity boundary; iteration stops
1364
+ // there and the intermediate text is preserved (it is not "trailing").
1365
+ expect(redacted).toHaveLength(0);
1366
+ expect(messages).toHaveLength(3);
1367
+ expect(messages[1].content).toBe("checking something else");
1368
+ });
1369
+
1370
+ it("is a no-op when there are no denied tool calls", () => {
1371
+ const messages: AgentMessage[] = [aiText("all good")];
1372
+ expect(clearProvisionalPostDenialNarration(messages, [])).toEqual([]);
1373
+ expect(messages).toHaveLength(1);
1374
+ expect(messages[0].content).toBe("all good");
1375
+ });
1376
+ });
1377
+
1378
+ // First-denial stop contract (index.ts stream loop). When the preToolUse hook
1379
+ // records its first denial, the runner ends the turn immediately — before the
1380
+ // model can react to Cursor's tool-failure surface with inter-tool narration or
1381
+ // a second gated tool. These pin the COMPOSED outcome of that stop using the
1382
+ // real ledger + reconcile + trim helpers; the live SDK orchestration is covered
1383
+ // by the end-to-end integration test (cursor_hitl_test.go).
1384
+ describe("first-denial stop contract", () => {
1385
+ type SimEvent =
1386
+ | { kind: "text"; content: string }
1387
+ | {
1388
+ kind: "tool";
1389
+ tool: ToolCall;
1390
+ denyToken?: { name: string; token: string };
1391
+ // When the hook's ledger append lands relative to the loop's read for
1392
+ // THIS event. "before-read" is the common case (the hook adjudicates as
1393
+ // the tool_call event surfaces); "after-read" reproduces the production
1394
+ // race where the append lands just after — the watcher's dirty flag
1395
+ // must then stop the turn on the NEXT event of any type.
1396
+ denyTiming?: "before-read" | "after-read";
1397
+ };
1398
+
1399
+ // Faithfully mirrors the index.ts loop rule: process each event, then read
1400
+ // the denial ledger when the fs watcher flagged it dirty OR the event is a
1401
+ // tool_call (the backstop) — the instant the ledger is non-empty, cancel the
1402
+ // run and stop consuming the stream. The watcher is simulated by flipping the
1403
+ // dirty flag as the append happens (the real fs.watch notification).
1404
+ async function runTurnWithFirstDenialStop(
1405
+ hitlDir: string,
1406
+ events: SimEvent[],
1407
+ ): Promise<{ messages: AgentMessage[]; cancelled: boolean; consumed: number }> {
1408
+ await resetDenialLedger(hitlDir);
1409
+ const messages: AgentMessage[] = [];
1410
+ let cancelled = false;
1411
+ let consumed = 0;
1412
+ let ledgerDirty = false;
1413
+ let pendingAppend: { name: string; token: string } | undefined;
1414
+
1415
+ const appendDenial = async (deny: { name: string; token: string }) => {
1416
+ await writeFile(
1417
+ denialLedgerPath(hitlDir),
1418
+ `{"toolName":"${deny.name}","token":"${deny.token}"}\n`,
1419
+ { flag: "a" },
1420
+ );
1421
+ ledgerDirty = true;
1422
+ };
1423
+
1424
+ for (const ev of events) {
1425
+ consumed++;
1426
+ // A raced append from the PREVIOUS event lands as this event arrives.
1427
+ if (pendingAppend) {
1428
+ await appendDenial(pendingAppend);
1429
+ pendingAppend = undefined;
1430
+ }
1431
+ if (ev.kind === "text") {
1432
+ messages.push(create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content: ev.content }));
1433
+ } else {
1434
+ messages.push(aiMessageWith([ev.tool]));
1435
+ if (ev.denyToken) {
1436
+ if ((ev.denyTiming ?? "before-read") === "before-read") {
1437
+ await appendDenial(ev.denyToken);
1438
+ } else {
1439
+ pendingAppend = ev.denyToken;
1440
+ }
1441
+ }
1442
+ }
1443
+ if (ledgerDirty || ev.kind === "tool") {
1444
+ ledgerDirty = false;
1445
+ const denials = await readDenialLedger(hitlDir);
1446
+ if (denials.length > 0) {
1447
+ cancelled = true;
1448
+ break;
1449
+ }
1450
+ }
1451
+ }
1452
+
1453
+ return { messages, cancelled, consumed };
1454
+ }
1455
+
1456
+ it("stops at the first gated tool, never consuming the inter-tool narration or a second gated tool", async () => {
1457
+ const ws = makeWorkspace();
1458
+ const edit = toolCall({ id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_COMPLETED, args: { path: "gated.txt" } });
1459
+ const shell = toolCall({ id: "c2", name: "shell", status: ToolCallStatus.TOOL_CALL_COMPLETED, args: { command: "echo hi > gated.txt" } });
1460
+
1461
+ // The full turn the model WOULD produce if left running: pre-tool text, the
1462
+ // gated edit (denied), a defeatist reaction, then a shell workaround (also
1463
+ // gated). The stop must cut the turn after the gated edit.
1464
+ const { messages, cancelled, consumed } = await runTurnWithFirstDenialStop(ws, [
1465
+ { kind: "text", content: "Let me create the file." },
1466
+ { kind: "tool", tool: edit, denyToken: { name: "Write", token: grantToken("write", "gated.txt") } },
1467
+ { kind: "text", content: "I'm blocked by a hook — I'll try the shell instead." },
1468
+ { kind: "tool", tool: shell, denyToken: { name: "Shell", token: grantToken("shell", "echo hi > gated.txt") } },
1469
+ ]);
1470
+
1471
+ expect(cancelled).toBe(true);
1472
+ expect(consumed).toBe(2); // pre-tool text + gated edit only
1473
+ // The second gated tool and the inter-tool narration were never consumed.
1474
+ expect(messages).toHaveLength(2);
1475
+ expect(messages.some((m) => m.content.includes("try the shell"))).toBe(false);
1476
+
1477
+ // Phase 12 reconcile + redact on the stopped transcript yields the clean shape.
1478
+ const denied = await reconcileDeniedToolCalls(messages, await readDenialLedger(ws));
1479
+ expect(denied).toHaveLength(1);
1480
+ expect(edit.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
1481
+ const redacted = clearProvisionalPostDenialNarration(messages, denied);
1482
+ expect(redacted).toHaveLength(0); // nothing trailing to redact — the stop already did it
1483
+ expect(messages).toHaveLength(2);
1484
+ expect(messages[0].content).toBe("Let me create the file.");
1485
+ expect(messages[1].toolCalls[0]).toBe(edit);
1486
+ });
1487
+
1488
+ it("does not stop a turn with no denials (auto-approved / read-only tools run to completion)", async () => {
1489
+ const ws = makeWorkspace();
1490
+ const read = toolCall({ id: "c1", name: "read", status: ToolCallStatus.TOOL_CALL_COMPLETED, args: { path: "a.txt" } });
1491
+
1492
+ const { cancelled, consumed } = await runTurnWithFirstDenialStop(ws, [
1493
+ { kind: "text", content: "Reading the file." },
1494
+ { kind: "tool", tool: read },
1495
+ { kind: "text", content: "Here is the content." },
1496
+ ]);
1497
+
1498
+ expect(cancelled).toBe(false);
1499
+ expect(consumed).toBe(3); // the whole turn is consumed
1500
+ });
1501
+
1502
+ it("stops on the NEXT event of any type when the denial lands after the tool_call read (the watcher rule)", async () => {
1503
+ // The production race (aex_01kwj07f7g23c3wp9sn8496z5g): the hook's ledger
1504
+ // append landed just after the gated tool's own tool_call-event read, so the
1505
+ // old tool_call-only rule consumed the model's ENTIRE reaction (thinking,
1506
+ // narration, a workaround shell) before the next tool_call finally saw the
1507
+ // ledger. The watcher flags the append the moment it lands; the very next
1508
+ // event — a mere text delta — must end the turn.
1509
+ const ws = makeWorkspace();
1510
+ const shell = toolCall({ id: "c1", name: "shell", status: ToolCallStatus.TOOL_CALL_RUNNING, args: { command: "seq 1 5000 > big.txt" } });
1511
+ const workaround = toolCall({ id: "c2", name: "shell", status: ToolCallStatus.TOOL_CALL_RUNNING, args: { command: "python3 -c ..." } });
1512
+
1513
+ const { messages, cancelled, consumed } = await runTurnWithFirstDenialStop(ws, [
1514
+ { kind: "text", content: "Let me create the file." },
1515
+ { kind: "tool", tool: shell, denyToken: { name: "Shell", token: grantToken("shell", "seq 1 5000 > big.txt") }, denyTiming: "after-read" },
1516
+ { kind: "text", content: "Shell was blocked — generating with a script instead." },
1517
+ { kind: "tool", tool: workaround, denyToken: { name: "Shell", token: grantToken("shell", "python3 -c ...") } },
1518
+ ]);
1519
+
1520
+ expect(cancelled).toBe(true);
1521
+ // The reaction text event triggers the dirty-flag read and is the LAST
1522
+ // event consumed; the workaround tool is never consumed at all.
1523
+ expect(consumed).toBe(3);
1524
+ expect(messages).toHaveLength(3);
1525
+ expect(messages.some((m) => m.toolCalls.includes(workaround))).toBe(false);
1526
+
1527
+ // Phase 12 on the stopped transcript: the anchor gates, and the one raced
1528
+ // reaction message is blanked by the trim backstop.
1529
+ const denied = await reconcileDeniedToolCalls(messages, await readDenialLedger(ws), undefined, rootBackend());
1530
+ expect(denied).toHaveLength(1);
1531
+ expect(shell.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
1532
+ const redacted = clearProvisionalPostDenialNarration(messages, denied);
1533
+ expect(redacted).toHaveLength(1);
1534
+ expect(messages[2].content).toBe("");
1535
+ });
1536
+ });
1537
+
1538
+ describe("watchDenialLedger", () => {
1539
+ it("flags dirty when the hook appends a denial to the ledger", async () => {
1540
+ const ws = makeWorkspace();
1541
+ await resetDenialLedger(ws);
1542
+ let dirty = false;
1543
+ const stop = watchDenialLedger(ws, () => {
1544
+ dirty = true;
1545
+ });
1546
+ try {
1547
+ // Give the watcher a beat to arm, then simulate the hook's append.
1548
+ await new Promise((resolve) => setTimeout(resolve, 50));
1549
+ await writeFile(
1550
+ denialLedgerPath(ws),
1551
+ `{"toolName":"Shell","token":"${grantToken("shell", "echo hi")}"}\n`,
1552
+ { flag: "a" },
1553
+ );
1554
+ await vi.waitFor(() => expect(dirty).toBe(true), { timeout: 3_000 });
1555
+ } finally {
1556
+ stop();
1557
+ }
1558
+ });
1559
+
1560
+ it("close is idempotent and safe after the directory is gone", async () => {
1561
+ const ws = makeWorkspace();
1562
+ await resetDenialLedger(ws);
1563
+ const stop = watchDenialLedger(ws, () => {});
1564
+ stop();
1565
+ stop(); // second close must not throw
1566
+ });
1567
+ });
1568
+
299
1569
  describe("generateHookScript ledger wiring", () => {
300
- it("wires the ledger path and records denials in both deny branches", () => {
301
- const script = generateHookScript(
302
- "/hitl/approval-state.json",
303
- "/hitl/denials.jsonl",
304
- process.pid,
305
- );
1570
+ it("bakes the active-turn pointer, derives the ledger from it, and records denials in both deny branches", () => {
1571
+ const script = generateHookScript("/gate/active.json");
306
1572
 
307
- expect(script).toContain('LEDGER_FILE="/hitl/denials.jsonl"');
1573
+ // Stable script: it bakes the pointer path (not per-turn state/ledger), and
1574
+ // derives LEDGER_FILE from the pointer it reads each invocation.
1575
+ expect(script).toContain('ACTIVE_FILE="/gate/active.json"');
1576
+ expect(script).not.toContain('LEDGER_FILE="/');
1577
+ expect(script).toContain('>> "$LEDGER_FILE"');
308
1578
  expect(script).toContain("record_denial()");
309
1579
  // One definition + a call in the gated-built-in branch + a call in the MCP
310
1580
  // branch = 3 occurrences.