@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
@@ -0,0 +1,1005 @@
1
+ /**
2
+ * @regression file-hitl-phase0 — pins file-edit HITL fixes #7, #8, #9 (see _projects/2026-06/20260630.01.file-change-hitl-redesign/tasks/T01_3_regression-manifest.md)
3
+ *
4
+ * Tests for capture-mode turn orchestration against the file_review LEDGER (the
5
+ * Cursor cutover): the turn-start baseline event, the turn-end candidate event
6
+ * (streamed edit rows STAMPED with the change set id — observational rows, kept
7
+ * visible in place — tree LEFT applied for Cursor-parity review), and the
8
+ * resume reconcile (decisions read from a DECIDED FileChangeSet projection,
9
+ * approved kept / rejected reverted, hash-verified, RECONCILED authored). Runs
10
+ * against a REAL temp git repo with in-memory transcript + status protos.
11
+ */
12
+
13
+ import { execFile, execFileSync } from "node:child_process";
14
+ import { mkdtemp, mkdir, readFile, rm, writeFile, stat } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { promisify } from "node:util";
18
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
19
+ import { clone, create } from "@bufbuild/protobuf";
20
+ import {
21
+ AgentMessageSchema,
22
+ ToolCallSchema,
23
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
24
+ import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
25
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
26
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
27
+ import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
28
+ import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
29
+ import { collectSubAgentToolCallIds } from "../../../shared/tool-row.js";
30
+ import {
31
+ CapturedFileChangeSchema,
32
+ FileChangeSetSchema,
33
+ FileDecisionSchema,
34
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
35
+ import type { CapturedFileChange } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
36
+ import {
37
+ FileCaptureClass,
38
+ FileChangeSetStatus,
39
+ FileDecisionAction,
40
+ FileDecisionScope,
41
+ FileReviewBlockReason,
42
+ FileReviewEventType,
43
+ SnapshotKind,
44
+ ToolCallStatus,
45
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
46
+ import {
47
+ applyCaptureDecisions,
48
+ captureBaselineToLedger,
49
+ captureTurnToLedger,
50
+ } from "../capture-flow.js";
51
+ import { toolIdentity, primaryToken } from "../approval-state.js";
52
+ import { contentDigest } from "../../../shared/file-tools.js";
53
+ import { buildObservationStagingScript, casObservationsDir } from "../cas-observations.js";
54
+ import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
55
+
56
+ const execFileAsync = promisify(execFile);
57
+ const EXEC_ID = "exec-capflow-1";
58
+ const CHANGE_SET_ID = `${EXEC_ID}:0`;
59
+
60
+ let repo: string;
61
+
62
+ async function git(args: string[]): Promise<string> {
63
+ const { stdout } = await execFileAsync("git", args, { cwd: repo });
64
+ return stdout;
65
+ }
66
+ async function read(rel: string): Promise<string> {
67
+ return readFile(join(repo, rel), "utf-8");
68
+ }
69
+ async function write(rel: string, content: string): Promise<void> {
70
+ await mkdir(join(repo, rel, ".."), { recursive: true });
71
+ await writeFile(join(repo, rel), content, "utf-8");
72
+ }
73
+ async function exists(rel: string): Promise<boolean> {
74
+ try {
75
+ await stat(join(repo, rel));
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function newStatus(): AgentExecutionStatus {
83
+ return create(AgentExecutionStatusSchema, {});
84
+ }
85
+
86
+ /** A streamed (COMPLETED) file-edit tool call, as the SDK would have recorded. */
87
+ function streamedEdit(id: string, path: string, content: string): AgentMessage {
88
+ return create(AgentMessageSchema, {
89
+ type: 1, // MESSAGE_AI
90
+ toolCalls: [
91
+ create(ToolCallSchema, {
92
+ id,
93
+ name: "edit",
94
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
95
+ args: { path, content },
96
+ }),
97
+ ],
98
+ });
99
+ }
100
+
101
+ /** A sub-agent execution whose message carries one streamed file-edit row. */
102
+ function subAgentWithEdit(subId: string, toolCallId: string, path: string, content: string): SubAgentExecution {
103
+ return create(SubAgentExecutionSchema, {
104
+ id: subId,
105
+ name: "code_editor",
106
+ messages: [streamedEdit(toolCallId, path, content)],
107
+ });
108
+ }
109
+
110
+ /** Events of a given type on the status's file_review ledger. */
111
+ function eventsOfType(status: AgentExecutionStatus, type: FileReviewEventType) {
112
+ return (status.fileReviewEventStream?.events ?? []).filter((e) => e.eventType === type);
113
+ }
114
+
115
+ /** The CapturedFileChange list carried on the CANDIDATE_CAPTURED event. */
116
+ function candidateChanges(status: AgentExecutionStatus): CapturedFileChange[] {
117
+ const ev = eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)[0];
118
+ return ev?.payload.case === "candidateCaptured" ? ev.payload.value.changes : [];
119
+ }
120
+
121
+ /**
122
+ * Build the DECIDED FileChangeSet projection the server would compute, from the
123
+ * authored candidate changes plus a set of per-file decisions. `tamper` lets a
124
+ * test corrupt a change's after_sha256 to exercise the hash-mismatch gate.
125
+ */
126
+ function decidedChangeSet(
127
+ status: AgentExecutionStatus,
128
+ decisionByPath: Record<string, FileDecisionAction>,
129
+ tamper?: (changes: CapturedFileChange[]) => void,
130
+ ) {
131
+ const changes = candidateChanges(status).map((c) => clone(CapturedFileChangeSchema, c));
132
+ if (tamper) tamper(changes);
133
+ const decisions = changes
134
+ .filter((c) => decisionByPath[c.pathAfter || c.pathBefore] !== undefined)
135
+ .map((c) =>
136
+ create(FileDecisionSchema, {
137
+ id: `${c.id}:d`,
138
+ changeSetId: CHANGE_SET_ID,
139
+ scope: FileDecisionScope.FILE,
140
+ fileChangeId: c.id,
141
+ action: decisionByPath[c.pathAfter || c.pathBefore],
142
+ expectedDigest: c.fileDigest,
143
+ }),
144
+ );
145
+ // Carry the CANDIDATE snapshot exactly as the server projection does
146
+ // (project.go copies `candidate_snapshot` onto the FileChangeSet). The resume
147
+ // reconcile reads it to decide whether this turn captured CAS files — a git-only
148
+ // turn has no CAS ref — so a faithful fixture must include it.
149
+ const candEv = eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)[0];
150
+ const candidateSnapshot =
151
+ candEv?.payload.case === "candidateCaptured" ? candEv.payload.value.candidateSnapshot : undefined;
152
+ return create(FileChangeSetSchema, {
153
+ id: CHANGE_SET_ID,
154
+ changes,
155
+ decisions,
156
+ candidateSnapshot,
157
+ status: FileChangeSetStatus.DECIDED,
158
+ });
159
+ }
160
+
161
+ beforeEach(async () => {
162
+ repo = await mkdtemp(join(tmpdir(), "stigmer-capflow-"));
163
+ await git(["init", "-q"]);
164
+ await git(["config", "user.email", "t@t.local"]);
165
+ await git(["config", "user.name", "t"]);
166
+ await write("notes.md", "platon notes\n");
167
+ await write("src/main.ts", "export const x = 1;\n");
168
+ await git(["add", "-A"]);
169
+ await git(["commit", "-q", "-m", "initial"]);
170
+ });
171
+
172
+ afterEach(async () => {
173
+ await rm(repo, { recursive: true, force: true });
174
+ });
175
+
176
+ describe("captureTurnToLedger (producer)", () => {
177
+ it("authors BASELINE + CANDIDATE, keeps edits applied, stamps streamed rows", async () => {
178
+ const status = newStatus();
179
+ const baseline = await captureBaselineToLedger({
180
+ status,
181
+ gitRoot: repo,
182
+ executionId: EXEC_ID,
183
+ changeSetId: CHANGE_SET_ID,
184
+ });
185
+
186
+ // BASELINE_CAPTURED authored at turn start (the projection reads turn_id /
187
+ // harness_id ONLY from this payload, so they are load-bearing).
188
+ const baselineEvents = eventsOfType(status, FileReviewEventType.BASELINE_CAPTURED);
189
+ expect(baselineEvents).toHaveLength(1);
190
+ expect(baselineEvents[0].actor).toBe("runner");
191
+ const basePayload = baselineEvents[0].payload;
192
+ expect(basePayload.case).toBe("baselineCaptured");
193
+ if (basePayload.case === "baselineCaptured") {
194
+ expect(basePayload.value.changeSetId).toBe(CHANGE_SET_ID);
195
+ expect(basePayload.value.turnId).toBe(CHANGE_SET_ID);
196
+ expect(basePayload.value.harnessId).toBe("cursor");
197
+ expect(basePayload.value.baselineSnapshot?.git?.ref).toBe(
198
+ `refs/stigmer/baseline/${EXEC_ID}`,
199
+ );
200
+ }
201
+
202
+ // The agent's edits flowed to disk during the turn.
203
+ await write("notes.md", "planton notes\n\n## TODO\n- ship\n");
204
+ await write("src/new.ts", "export const y = 2;\n");
205
+ const messages: AgentMessage[] = [
206
+ streamedEdit("tc-1", "notes.md", "planton notes\n\n## TODO\n- ship\n"),
207
+ streamedEdit("tc-2", "src/new.ts", "export const y = 2;\n"),
208
+ ];
209
+
210
+ const changes = await captureTurnToLedger({
211
+ status,
212
+ gitRoot: repo,
213
+ executionId: EXEC_ID,
214
+ changeSetId: CHANGE_SET_ID,
215
+ baselineTree: baseline,
216
+ messages,
217
+ deniedTokens: new Set(),
218
+ });
219
+
220
+ expect(changes).toHaveLength(2);
221
+
222
+ // Cursor parity: the agent's edits stay applied on disk for review.
223
+ expect(await read("notes.md")).toBe("planton notes\n\n## TODO\n- ship\n");
224
+ expect(await exists("src/new.ts")).toBe(true);
225
+
226
+ // The streamed edit rows stay visible in place as observational records,
227
+ // stamped with the change set id and with their content intact — the ledger
228
+ // remains the single DECISION surface, the row is the audit trail.
229
+ for (const tc of messages.flatMap((m) => m.toolCalls)) {
230
+ expect(tc.fileChangeSetId).toBe(CHANGE_SET_ID);
231
+ expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
232
+ expect(tc.args).toBeDefined();
233
+ }
234
+
235
+ // CANDIDATE_CAPTURED authored with one change per file + the byte-exact
236
+ // after content, and a non-empty aggregate digest.
237
+ const candidateEvents = eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED);
238
+ expect(candidateEvents).toHaveLength(1);
239
+ expect(candidateEvents[0].actor).toBe("runner");
240
+ const cand = candidateChanges(status);
241
+ expect(cand.map((c) => c.pathAfter).sort()).toEqual(["notes.md", "src/new.ts"]);
242
+ const notesChange = cand.find((c) => c.pathAfter === "notes.md")!;
243
+ expect(notesChange.id).toBe(`${CHANGE_SET_ID}:notes.md`);
244
+ expect(notesChange.after?.body.value).toBe("planton notes\n\n## TODO\n- ship\n");
245
+ expect(notesChange.afterSha256.length).toBeGreaterThan(0);
246
+ });
247
+
248
+ it("authors no candidate when the turn changed nothing", async () => {
249
+ const status = newStatus();
250
+ const baseline = await captureBaselineToLedger({
251
+ status,
252
+ gitRoot: repo,
253
+ executionId: EXEC_ID,
254
+ changeSetId: CHANGE_SET_ID,
255
+ });
256
+ const changes = await captureTurnToLedger({
257
+ status,
258
+ gitRoot: repo,
259
+ executionId: EXEC_ID,
260
+ changeSetId: CHANGE_SET_ID,
261
+ baselineTree: baseline,
262
+ messages: [],
263
+ deniedTokens: new Set(),
264
+ });
265
+ expect(changes).toHaveLength(0);
266
+ expect(eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)).toHaveLength(0);
267
+ });
268
+
269
+ it("stamps NO rows on a no-net-change turn (an edit fully reverted before the boundary)", async () => {
270
+ const status = newStatus();
271
+ const baseline = await captureBaselineToLedger({
272
+ status,
273
+ gitRoot: repo,
274
+ executionId: EXEC_ID,
275
+ changeSetId: CHANGE_SET_ID,
276
+ });
277
+
278
+ // The agent edited notes.md and then reverted it — the tool row flowed, but
279
+ // the workspace nets ZERO delta, so no CANDIDATE is authored and no change
280
+ // set exists. The row must NOT be stamped: a row must never reference a
281
+ // change set that does not exist.
282
+ const messages: AgentMessage[] = [streamedEdit("tc-1", "notes.md", "platon notes\n")];
283
+ await captureTurnToLedger({
284
+ status,
285
+ gitRoot: repo,
286
+ executionId: EXEC_ID,
287
+ changeSetId: CHANGE_SET_ID,
288
+ baselineTree: baseline,
289
+ messages,
290
+ deniedTokens: new Set(),
291
+ });
292
+
293
+ expect(eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)).toHaveLength(0);
294
+ expect(messages[0].toolCalls[0].fileChangeSetId).toBe("");
295
+ expect(messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
296
+ });
297
+
298
+ it("never re-stamps a prior turn's seeded rows with the current turn's change set id", async () => {
299
+ // Turn 2 of a resumed execution: the transcript is seeded with turn 1's
300
+ // rows (already stamped `exec:0`). The turn-2 boundary pass walks the WHOLE
301
+ // transcript — the stamp itself must be the guard, or turn 1's rows would
302
+ // be silently mis-attributed to turn 2's change set.
303
+ const turn2SetId = `${EXEC_ID}:1`;
304
+ const status = newStatus();
305
+ const baseline = await captureBaselineToLedger({
306
+ status,
307
+ gitRoot: repo,
308
+ executionId: EXEC_ID,
309
+ changeSetId: turn2SetId,
310
+ });
311
+
312
+ const seededTurn1 = streamedEdit("tc-old", "notes.md", "from turn 1\n");
313
+ seededTurn1.toolCalls[0].fileChangeSetId = CHANGE_SET_ID; // exec:0
314
+ await write("src/new.ts", "export const y = 2;\n");
315
+ const turn2Row = streamedEdit("tc-new", "src/new.ts", "export const y = 2;\n");
316
+ const messages: AgentMessage[] = [seededTurn1, turn2Row];
317
+
318
+ await captureTurnToLedger({
319
+ status,
320
+ gitRoot: repo,
321
+ executionId: EXEC_ID,
322
+ changeSetId: turn2SetId,
323
+ baselineTree: baseline,
324
+ messages,
325
+ deniedTokens: new Set(),
326
+ });
327
+
328
+ expect(seededTurn1.toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
329
+ expect(turn2Row.toolCalls[0].fileChangeSetId).toBe(turn2SetId);
330
+ });
331
+
332
+ it("skips legacy hidden rows (pre-stamping sessions) — they belong to an earlier turn's set", async () => {
333
+ const status = newStatus();
334
+ const baseline = await captureBaselineToLedger({
335
+ status,
336
+ gitRoot: repo,
337
+ executionId: EXEC_ID,
338
+ changeSetId: CHANGE_SET_ID,
339
+ });
340
+
341
+ // A row hidden by the pre-stamping runner: collapsed SKIPPED, no args.
342
+ const legacyHidden = create(AgentMessageSchema, {
343
+ type: 1, // MESSAGE_AI
344
+ toolCalls: [
345
+ create(ToolCallSchema, {
346
+ id: "tc-legacy",
347
+ name: "edit",
348
+ status: ToolCallStatus.TOOL_CALL_SKIPPED,
349
+ }),
350
+ ],
351
+ });
352
+ await write("notes.md", "changed\n");
353
+ const messages: AgentMessage[] = [
354
+ legacyHidden,
355
+ streamedEdit("tc-1", "notes.md", "changed\n"),
356
+ ];
357
+
358
+ await captureTurnToLedger({
359
+ status,
360
+ gitRoot: repo,
361
+ executionId: EXEC_ID,
362
+ changeSetId: CHANGE_SET_ID,
363
+ baselineTree: baseline,
364
+ messages,
365
+ deniedTokens: new Set(),
366
+ });
367
+
368
+ expect(legacyHidden.toolCalls[0].fileChangeSetId).toBe("");
369
+ expect(legacyHidden.toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
370
+ expect(messages[1].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
371
+ });
372
+
373
+ it("leaves a DENIED edit row unstamped — the deny-gate reconcile path owns it", async () => {
374
+ const status = newStatus();
375
+ const baseline = await captureBaselineToLedger({
376
+ status,
377
+ gitRoot: repo,
378
+ executionId: EXEC_ID,
379
+ changeSetId: CHANGE_SET_ID,
380
+ });
381
+
382
+ // A denied gitignored-delete style row alongside a flowed edit. Its denial
383
+ // token is derived exactly as the hook records it.
384
+ const deniedArgs = { path: "secretish.txt", content: "nope" };
385
+ const deniedRow = streamedEdit("tc-denied", "secretish.txt", "nope");
386
+ const id = toolIdentity("edit", "", deniedArgs);
387
+ const deniedToken = primaryToken(id.key, id.salient, contentDigest(deniedArgs));
388
+
389
+ await write("notes.md", "changed\n");
390
+ const flowedRow = streamedEdit("tc-flowed", "notes.md", "changed\n");
391
+ const messages: AgentMessage[] = [deniedRow, flowedRow];
392
+
393
+ await captureTurnToLedger({
394
+ status,
395
+ gitRoot: repo,
396
+ executionId: EXEC_ID,
397
+ changeSetId: CHANGE_SET_ID,
398
+ baselineTree: baseline,
399
+ messages,
400
+ deniedTokens: new Set([deniedToken]),
401
+ });
402
+
403
+ expect(deniedRow.toolCalls[0].fileChangeSetId).toBe("");
404
+ expect(flowedRow.toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
405
+ });
406
+
407
+ // Sub-agent edit rows fold their files into the parent turn's change set, so
408
+ // they carry the parent change set id. They live under subAgentExecutions
409
+ // (not the top-level transcript) and lack the already-stamped/hidden shields,
410
+ // so they are scoped to this turn by tool-call-id novelty (DD-24 follow-up).
411
+ describe("sub-agent rows", () => {
412
+ it("stamps a current-turn sub-agent's flowed edit row with the parent change set id", async () => {
413
+ const status = newStatus();
414
+ const baseline = await captureBaselineToLedger({
415
+ status,
416
+ gitRoot: repo,
417
+ executionId: EXEC_ID,
418
+ changeSetId: CHANGE_SET_ID,
419
+ });
420
+
421
+ // A delegated sub-agent edited a file this turn; it flowed to disk and its
422
+ // row lives on the sub-agent's own message list.
423
+ await write("src/sub.ts", "export const s = 1;\n");
424
+ status.subAgentExecutions = [subAgentWithEdit("sa-1", "sa-tc-1", "src/sub.ts", "export const s = 1;\n")];
425
+
426
+ await captureTurnToLedger({
427
+ status,
428
+ gitRoot: repo,
429
+ executionId: EXEC_ID,
430
+ changeSetId: CHANGE_SET_ID,
431
+ baselineTree: baseline,
432
+ messages: status.messages, // no top-level edits this turn
433
+ deniedTokens: new Set(),
434
+ priorSubAgentToolCallIds: new Set(),
435
+ });
436
+
437
+ expect(status.subAgentExecutions[0].messages[0].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
438
+ });
439
+
440
+ it("never stamps a seeded prior sub-agent's row (cross-turn scope via priorSubAgentToolCallIds)", async () => {
441
+ // Turn 2 of a resumed execution: Cursor seeds prior sub-agents (cloned in
442
+ // from the persisted status). Before sub-agent stamping existed, all their
443
+ // rows are unstamped — a naive walk would mis-attribute them to this turn.
444
+ const turn2SetId = `${EXEC_ID}:1`;
445
+ const status = newStatus();
446
+ const baseline = await captureBaselineToLedger({
447
+ status,
448
+ gitRoot: repo,
449
+ executionId: EXEC_ID,
450
+ changeSetId: turn2SetId,
451
+ });
452
+
453
+ const priorSub = subAgentWithEdit("sa-old", "sa-tc-old", "notes.md", "from turn 1\n");
454
+ await write("src/sub-new.ts", "export const n = 2;\n");
455
+ const currentSub = subAgentWithEdit("sa-new", "sa-tc-new", "src/sub-new.ts", "export const n = 2;\n");
456
+ status.subAgentExecutions = [priorSub, currentSub];
457
+
458
+ // The snapshot is taken from the SEEDED prior sub-agents (index.ts), before
459
+ // the current turn's sub-agents are appended.
460
+ const priorSubAgentToolCallIds = collectSubAgentToolCallIds([priorSub]);
461
+
462
+ await captureTurnToLedger({
463
+ status,
464
+ gitRoot: repo,
465
+ executionId: EXEC_ID,
466
+ changeSetId: turn2SetId,
467
+ baselineTree: baseline,
468
+ messages: status.messages,
469
+ deniedTokens: new Set(),
470
+ priorSubAgentToolCallIds,
471
+ });
472
+
473
+ expect(priorSub.messages[0].toolCalls[0].fileChangeSetId).toBe(""); // untouched
474
+ expect(currentSub.messages[0].toolCalls[0].fileChangeSetId).toBe(turn2SetId);
475
+ });
476
+
477
+ it("withholds content for a sub-agent write to a tracked secret-like path (DD-12 D4 inherited)", async () => {
478
+ const status = newStatus();
479
+ const baseline = await captureBaselineToLedger({
480
+ status,
481
+ gitRoot: repo,
482
+ executionId: EXEC_ID,
483
+ changeSetId: CHANGE_SET_ID,
484
+ });
485
+
486
+ await write("config/credentials.json", "TOKEN=super-secret\n");
487
+ const sub = subAgentWithEdit("sa-1", "sa-tc-secret", "config/credentials.json", "TOKEN=super-secret\n");
488
+ status.subAgentExecutions = [sub];
489
+
490
+ await captureTurnToLedger({
491
+ status,
492
+ gitRoot: repo,
493
+ executionId: EXEC_ID,
494
+ changeSetId: CHANGE_SET_ID,
495
+ baselineTree: baseline,
496
+ messages: status.messages,
497
+ deniedTokens: new Set(),
498
+ priorSubAgentToolCallIds: new Set(),
499
+ });
500
+
501
+ const row = sub.messages[0].toolCalls[0];
502
+ expect(row.fileChangeSetId).toBe(CHANGE_SET_ID); // stamped (path visible)
503
+ expect(row.args).toEqual({ path: "config/credentials.json" }); // body withheld
504
+ expect(row.result).toBe("");
505
+ });
506
+ });
507
+ });
508
+
509
+ describe("applyCaptureDecisions (resume)", () => {
510
+ async function captureTwoFileTurn(status: AgentExecutionStatus) {
511
+ const baseline = await captureBaselineToLedger({
512
+ status,
513
+ gitRoot: repo,
514
+ executionId: EXEC_ID,
515
+ changeSetId: CHANGE_SET_ID,
516
+ });
517
+ await write("notes.md", "planton notes\n");
518
+ await write("src/main.ts", "export const x = 99;\n");
519
+ const messages: AgentMessage[] = [
520
+ streamedEdit("tc-1", "notes.md", "planton notes\n"),
521
+ streamedEdit("tc-2", "src/main.ts", "export const x = 99;\n"),
522
+ ];
523
+ await captureTurnToLedger({
524
+ status,
525
+ gitRoot: repo,
526
+ executionId: EXEC_ID,
527
+ changeSetId: CHANGE_SET_ID,
528
+ baselineTree: baseline,
529
+ messages,
530
+ deniedTokens: new Set(),
531
+ });
532
+ }
533
+
534
+ it("applies the approved file, discards the rejected one, authors RECONCILED, drops refs", async () => {
535
+ const status = newStatus();
536
+ await captureTwoFileTurn(status);
537
+
538
+ // Both edits remain applied on disk during review (pre-decision).
539
+ expect(await read("notes.md")).toBe("planton notes\n");
540
+ expect(await read("src/main.ts")).toBe("export const x = 99;\n");
541
+
542
+ const changeSet = decidedChangeSet(status, {
543
+ "notes.md": FileDecisionAction.APPROVE,
544
+ "src/main.ts": FileDecisionAction.REJECT,
545
+ });
546
+
547
+ const result = await applyCaptureDecisions({
548
+ status,
549
+ gitRoot: repo,
550
+ executionId: EXEC_ID,
551
+ changeSet,
552
+ });
553
+
554
+ expect(result.isCaptureTurn).toBe(true);
555
+ expect(result.failed).toBe(false);
556
+ expect(result.approvedPaths).toEqual(["notes.md"]);
557
+ expect(result.rejectedPaths).toEqual(["src/main.ts"]);
558
+ expect(result.hadReject).toBe(true);
559
+
560
+ // Approved file applied (uncommitted); rejected file at baseline.
561
+ expect(await read("notes.md")).toBe("planton notes\n");
562
+ expect(await read("src/main.ts")).toBe("export const x = 1;\n");
563
+
564
+ // Approved file uncommitted (HEAD unchanged — the harness never commits).
565
+ expect((await git(["log", "--oneline"])).trim().split("\n")).toHaveLength(1);
566
+
567
+ // RECONCILED authored with an approved snapshot; refs released.
568
+ const reconciled = eventsOfType(status, FileReviewEventType.RECONCILED);
569
+ expect(reconciled).toHaveLength(1);
570
+ expect(reconciled[0].actor).toBe("runner");
571
+ if (reconciled[0].payload.case === "reconciled") {
572
+ expect(reconciled[0].payload.value.approvedSnapshot?.git?.ref).toBe(
573
+ `refs/stigmer/approved/${EXEC_ID}`,
574
+ );
575
+ }
576
+ expect((await git(["for-each-ref", "refs/stigmer/"])).trim()).toBe("");
577
+ });
578
+
579
+ it("reconstructs an approved file from the ref even if the tree was reset (retry-safe)", async () => {
580
+ const status = newStatus();
581
+ const baseline = await captureBaselineToLedger({
582
+ status,
583
+ gitRoot: repo,
584
+ executionId: EXEC_ID,
585
+ changeSetId: CHANGE_SET_ID,
586
+ });
587
+ await write("notes.md", "planton notes\n");
588
+ await captureTurnToLedger({
589
+ status,
590
+ gitRoot: repo,
591
+ executionId: EXEC_ID,
592
+ changeSetId: CHANGE_SET_ID,
593
+ baselineTree: baseline,
594
+ messages: [streamedEdit("tc-1", "notes.md", "planton notes\n")],
595
+ deniedTokens: new Set(),
596
+ });
597
+
598
+ const changeSet = decidedChangeSet(status, { "notes.md": FileDecisionAction.APPROVE });
599
+
600
+ // Simulate a tree reset before resume (Temporal retry / sandbox recycle):
601
+ // the approved bytes are gone from disk, but the pinned refs survive.
602
+ await write("notes.md", "platon notes\n");
603
+
604
+ const result = await applyCaptureDecisions({
605
+ status,
606
+ gitRoot: repo,
607
+ executionId: EXEC_ID,
608
+ changeSet,
609
+ });
610
+
611
+ expect(result.approvedPaths).toEqual(["notes.md"]);
612
+ // Reconcile-from-refs re-asserts the approved "after" bytes regardless of the
613
+ // tree's current contents.
614
+ expect(await read("notes.md")).toBe("planton notes\n");
615
+ });
616
+
617
+ it("CHANGE_SET-scoped approval covers all files; a FILE decision overrides it (most-specific-wins)", async () => {
618
+ const status = newStatus();
619
+ await captureTwoFileTurn(status);
620
+
621
+ const changes = candidateChanges(status);
622
+ const mainChange = changes.find((c) => c.pathAfter === "src/main.ts")!;
623
+ const changeSet = create(FileChangeSetSchema, {
624
+ id: CHANGE_SET_ID,
625
+ changes,
626
+ status: FileChangeSetStatus.DECIDED,
627
+ decisions: [
628
+ // CHANGE_SET: approve everything…
629
+ create(FileDecisionSchema, {
630
+ id: "d-cs",
631
+ changeSetId: CHANGE_SET_ID,
632
+ scope: FileDecisionScope.CHANGE_SET,
633
+ action: FileDecisionAction.APPROVE,
634
+ }),
635
+ // …but a FILE decision rejects src/main.ts (more specific wins).
636
+ create(FileDecisionSchema, {
637
+ id: "d-file",
638
+ changeSetId: CHANGE_SET_ID,
639
+ scope: FileDecisionScope.FILE,
640
+ fileChangeId: mainChange.id,
641
+ action: FileDecisionAction.REJECT,
642
+ expectedDigest: mainChange.fileDigest,
643
+ }),
644
+ ],
645
+ });
646
+
647
+ const result = await applyCaptureDecisions({
648
+ status,
649
+ gitRoot: repo,
650
+ executionId: EXEC_ID,
651
+ changeSet,
652
+ });
653
+
654
+ expect(result.approvedPaths).toEqual(["notes.md"]);
655
+ expect(result.rejectedPaths).toEqual(["src/main.ts"]);
656
+ expect(await read("notes.md")).toBe("planton notes\n");
657
+ expect(await read("src/main.ts")).toBe("export const x = 1;\n");
658
+ });
659
+
660
+ it("refuses to apply and authors FAILED(HASH_MISMATCH) when on-disk bytes diverge from the approved digest", async () => {
661
+ const status = newStatus();
662
+ await captureTwoFileTurn(status);
663
+
664
+ // The reviewer approved both, but the recorded after_sha256 for notes.md no
665
+ // longer matches the captured bytes (what-you-approve-is-what-applies fails).
666
+ const changeSet = decidedChangeSet(
667
+ status,
668
+ { "notes.md": FileDecisionAction.APPROVE, "src/main.ts": FileDecisionAction.APPROVE },
669
+ (changes) => {
670
+ const notes = changes.find((c) => c.pathAfter === "notes.md")!;
671
+ notes.afterSha256 = "deadbeef".repeat(8); // 64-hex, but wrong
672
+ },
673
+ );
674
+
675
+ const result = await applyCaptureDecisions({
676
+ status,
677
+ gitRoot: repo,
678
+ executionId: EXEC_ID,
679
+ changeSet,
680
+ });
681
+
682
+ expect(result.failed).toBe(true);
683
+ expect(result.failureDetail).toContain("notes.md");
684
+
685
+ // NOTHING was applied (a partial apply under verification failure is worse
686
+ // than none): src/main.ts still holds its un-reconciled (after) bytes.
687
+ expect(await read("src/main.ts")).toBe("export const x = 99;\n");
688
+
689
+ const failed = eventsOfType(status, FileReviewEventType.FAILED);
690
+ expect(failed).toHaveLength(1);
691
+ if (failed[0].payload.case === "failed") {
692
+ expect(failed[0].payload.value.detail).toContain("notes.md");
693
+ }
694
+ // No RECONCILED on a failed verify.
695
+ expect(eventsOfType(status, FileReviewEventType.RECONCILED)).toHaveLength(0);
696
+ });
697
+
698
+ it("returns isCaptureTurn=false when there is no capture ref (non-capture resume)", async () => {
699
+ const status = newStatus();
700
+ const changeSet = create(FileChangeSetSchema, {
701
+ id: "never-captured:0",
702
+ status: FileChangeSetStatus.DECIDED,
703
+ });
704
+ const result = await applyCaptureDecisions({
705
+ status,
706
+ gitRoot: repo,
707
+ executionId: "never-captured",
708
+ changeSet,
709
+ });
710
+ expect(result.isCaptureTurn).toBe(false);
711
+ });
712
+ });
713
+
714
+ // The Cursor CAS half (DD-18): the hook stages gitignored writes into the
715
+ // cas-observations sidecar; captureTurnToLedger composes them with the git diff
716
+ // into ONE hybrid change set, and applyCaptureDecisions reconciles the CAS files
717
+ // from the durable manifest. Exercised with the REAL staging script + a real git
718
+ // repo + an in-memory artifact store — the deterministic stand-in for the live
719
+ // (CURSOR_API_KEY) end-to-end test.
720
+ describe("hybrid capture (git-tracked + gitignored CAS)", () => {
721
+ let hitl: string;
722
+
723
+ /** Run the real staging script exactly as the hook does (salient on stdin). */
724
+ function stage(wsRoot: string, salient: string): void {
725
+ execFileSync(process.execPath, ["-e", buildObservationStagingScript(), wsRoot, casObservationsDir(hitl)], {
726
+ input: salient,
727
+ });
728
+ }
729
+
730
+ beforeEach(async () => {
731
+ hitl = await mkdtemp(join(tmpdir(), "stigmer-capflow-hitl-"));
732
+ // Commit a .gitignore so the git half ignores *.log / .env (only CAS sees
733
+ // them) — otherwise they would double-capture as untracked git changes.
734
+ await write(".gitignore", "*.log\n.env\n");
735
+ await git(["add", ".gitignore"]);
736
+ await git(["commit", "-q", "-m", "gitignore"]);
737
+ });
738
+
739
+ afterEach(async () => {
740
+ await rm(hitl, { recursive: true, force: true });
741
+ });
742
+
743
+ it("composes one hybrid change set: git-tracked + GIT_IGNORED_CAPTURED + secret DIFF_UNREVIEWABLE", async () => {
744
+ const status = newStatus();
745
+ const { storage, blobs } = makeInMemoryArtifactStorage();
746
+
747
+ const baseline = await captureBaselineToLedger({
748
+ status,
749
+ gitRoot: repo,
750
+ executionId: EXEC_ID,
751
+ changeSetId: CHANGE_SET_ID,
752
+ });
753
+
754
+ // A gitignored MODIFY: seed the pre-turn bytes, stage (records before), then
755
+ // let the write apply (the runner re-reads after at the boundary).
756
+ await write("build/out.log", "BEFORE");
757
+ stage(repo, "build/out.log");
758
+ await write("build/out.log", "AFTER");
759
+ // A secret-like gitignored write: hard-blocked by the hook -> a secret marker.
760
+ stage(repo, ".env");
761
+ // A git-tracked edit rides the same turn.
762
+ await write("notes.md", "changed\n");
763
+
764
+ const changes = await captureTurnToLedger({
765
+ status,
766
+ gitRoot: repo,
767
+ executionId: EXEC_ID,
768
+ changeSetId: CHANGE_SET_ID,
769
+ baselineTree: baseline,
770
+ messages: [streamedEdit("tc-1", "notes.md", "changed\n")],
771
+ deniedTokens: new Set(),
772
+ hitlDir: hitl,
773
+ storage,
774
+ });
775
+
776
+ // captureTurnToLedger returns only the git-tracked changes.
777
+ expect(changes.map((c) => c.path)).toEqual(["notes.md"]);
778
+
779
+ const cand = candidateChanges(status);
780
+ const byPath = new Map(cand.map((c) => [c.pathAfter || c.pathBefore, c]));
781
+ expect([...byPath.keys()].sort()).toEqual([".env", "build/out.log", "notes.md"]);
782
+
783
+ // Git-tracked file.
784
+ expect(byPath.get("notes.md")!.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
785
+
786
+ // The gitignored file is a CAS capture (blob-ref body), byte-exact after.
787
+ const log = byPath.get("build/out.log")!;
788
+ expect(log.captureClass).toBe(FileCaptureClass.GIT_IGNORED_CAPTURED);
789
+ expect(log.after?.body.case).toBe("ref");
790
+ expect(blobs.size).toBeGreaterThan(0); // before + after blobs persisted
791
+
792
+ // The secret is surfaced honestly as content-less DIFF_UNREVIEWABLE.
793
+ const env = byPath.get(".env")!;
794
+ expect(env.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
795
+ expect(env.diffComplete).toBe(false);
796
+ expect(env.after).toBeUndefined();
797
+ });
798
+
799
+ it("reconciles an APPROVED gitignored file (kept byte-exact) via the CAS manifest", async () => {
800
+ const status = newStatus();
801
+ const { storage } = makeInMemoryArtifactStorage();
802
+ const baseline = await captureBaselineToLedger({ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID });
803
+
804
+ await write("build/out.log", "BEFORE");
805
+ stage(repo, "build/out.log");
806
+ await write("build/out.log", "AFTER");
807
+ await write("notes.md", "changed\n");
808
+
809
+ await captureTurnToLedger({
810
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
811
+ baselineTree: baseline,
812
+ messages: [streamedEdit("tc-1", "notes.md", "changed\n")],
813
+ deniedTokens: new Set(), hitlDir: hitl, storage,
814
+ });
815
+
816
+ const changeSet = decidedChangeSet(status, {
817
+ "notes.md": FileDecisionAction.APPROVE,
818
+ "build/out.log": FileDecisionAction.APPROVE,
819
+ });
820
+ const result = await applyCaptureDecisions({
821
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, storage,
822
+ });
823
+
824
+ expect(result.isCaptureTurn).toBe(true);
825
+ expect(result.failed).toBe(false);
826
+ expect([...result.approvedPaths].sort()).toEqual(["build/out.log", "notes.md"]);
827
+ expect(await read("build/out.log")).toBe("AFTER");
828
+ expect(await read("notes.md")).toBe("changed\n");
829
+ });
830
+
831
+ it("reconciles a REJECTED gitignored file by snapping it back to the pre-turn bytes", async () => {
832
+ const status = newStatus();
833
+ const { storage } = makeInMemoryArtifactStorage();
834
+ const baseline = await captureBaselineToLedger({ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID });
835
+
836
+ await write("build/out.log", "BEFORE");
837
+ stage(repo, "build/out.log");
838
+ await write("build/out.log", "AFTER");
839
+
840
+ await captureTurnToLedger({
841
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
842
+ baselineTree: baseline,
843
+ messages: [],
844
+ deniedTokens: new Set(), hitlDir: hitl, storage,
845
+ });
846
+
847
+ const changeSet = decidedChangeSet(status, { "build/out.log": FileDecisionAction.REJECT });
848
+ const result = await applyCaptureDecisions({
849
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, storage,
850
+ });
851
+
852
+ expect(result.hadReject).toBe(true);
853
+ expect(result.rejectedPaths).toEqual(["build/out.log"]);
854
+ // Snapped back byte-exact to the pre-turn content the hook staged.
855
+ expect(await read("build/out.log")).toBe("BEFORE");
856
+ });
857
+ });
858
+
859
+ // Slice 2c: a NON-git Cursor workspace has no git snapshot, so the whole change
860
+ // set is CAS (every touched path), classed NON_GIT_CAS, with a CAS_MANIFEST
861
+ // baseline/candidate/reconcile. Exercised with the REAL staging script + a plain
862
+ // (non-git) temp dir + an in-memory artifact store.
863
+ describe("non-git workspace CAS-only capture (Slice 2c)", () => {
864
+ let ws: string;
865
+ let hitl: string;
866
+
867
+ /** Run the real staging script exactly as the hook does (salient on stdin). */
868
+ function stage(salient: string): void {
869
+ execFileSync(process.execPath, ["-e", buildObservationStagingScript(), ws, casObservationsDir(hitl)], {
870
+ input: salient,
871
+ });
872
+ }
873
+ async function wsRead(rel: string): Promise<string> {
874
+ return readFile(join(ws, rel), "utf-8");
875
+ }
876
+ async function wsWrite(rel: string, content: string): Promise<void> {
877
+ await mkdir(join(ws, rel, ".."), { recursive: true });
878
+ await writeFile(join(ws, rel), content, "utf-8");
879
+ }
880
+
881
+ beforeEach(async () => {
882
+ // A plain directory — deliberately NOT a git repo.
883
+ ws = await mkdtemp(join(tmpdir(), "stigmer-capflow-nongit-"));
884
+ hitl = await mkdtemp(join(tmpdir(), "stigmer-capflow-nongit-hitl-"));
885
+ });
886
+ afterEach(async () => {
887
+ await rm(ws, { recursive: true, force: true });
888
+ await rm(hitl, { recursive: true, force: true });
889
+ });
890
+
891
+ it("authors a CAS_MANIFEST baseline + NON_GIT_CAS candidate and reconciles approve/reject", async () => {
892
+ const status = newStatus();
893
+ const { storage } = makeInMemoryArtifactStorage();
894
+
895
+ const baseline = await captureBaselineToLedger({
896
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, gitWorkspace: false,
897
+ });
898
+ // No git tree to pin in a non-git workspace.
899
+ expect(baseline).toBe("");
900
+
901
+ // Two edits this turn: a MODIFY (before staged first) and an ADD.
902
+ await wsWrite("keep.txt", "OLD");
903
+ stage("keep.txt");
904
+ await wsWrite("keep.txt", "NEW");
905
+ stage("created.txt");
906
+ await wsWrite("created.txt", "X");
907
+
908
+ const changes = await captureTurnToLedger({
909
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
910
+ baselineTree: baseline, messages: [], deniedTokens: new Set(),
911
+ hitlDir: hitl, storage, gitWorkspace: false,
912
+ });
913
+ // No git-tracked changes exist in a non-git workspace — the set is CAS-only.
914
+ expect(changes).toHaveLength(0);
915
+
916
+ const cand = candidateChanges(status);
917
+ const byPath = new Map(cand.map((c) => [c.pathAfter || c.pathBefore, c]));
918
+ expect([...byPath.keys()].sort()).toEqual(["created.txt", "keep.txt"]);
919
+ expect(byPath.get("keep.txt")!.captureClass).toBe(FileCaptureClass.NON_GIT_CAS);
920
+ expect(byPath.get("created.txt")!.captureClass).toBe(FileCaptureClass.NON_GIT_CAS);
921
+
922
+ const changeSet = decidedChangeSet(status, {
923
+ "keep.txt": FileDecisionAction.APPROVE,
924
+ "created.txt": FileDecisionAction.REJECT,
925
+ });
926
+ const result = await applyCaptureDecisions({
927
+ status, gitRoot: ws, executionId: EXEC_ID, changeSet, storage, gitWorkspace: false,
928
+ });
929
+
930
+ expect(result.isCaptureTurn).toBe(true);
931
+ expect(result.failed).toBe(false);
932
+ expect(result.approvedPaths).toEqual(["keep.txt"]);
933
+ expect(result.rejectedPaths).toEqual(["created.txt"]);
934
+ // Approved kept at its "after" bytes; the rejected ADD is removed.
935
+ expect(await wsRead("keep.txt")).toBe("NEW");
936
+ await expect(readFile(join(ws, "created.txt"), "utf-8")).rejects.toThrow();
937
+
938
+ // RECONCILED authored with a CAS_MANIFEST snapshot (no git tree to re-pin).
939
+ const reconciled = eventsOfType(status, FileReviewEventType.RECONCILED);
940
+ expect(reconciled).toHaveLength(1);
941
+ if (reconciled[0].payload.case === "reconciled") {
942
+ expect(reconciled[0].payload.value.approvedSnapshot?.kind).toBe(SnapshotKind.CAS_MANIFEST);
943
+ }
944
+ });
945
+
946
+ it("labels a non-git secret-blocked path NON_GIT_CAS and blocks approval", async () => {
947
+ const status = newStatus();
948
+ const { storage } = makeInMemoryArtifactStorage();
949
+ const baseline = await captureBaselineToLedger({
950
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, gitWorkspace: false,
951
+ });
952
+
953
+ await wsWrite("app.txt", "hi");
954
+ stage("app.txt");
955
+ stage(".env"); // secret-like -> a content-less marker only
956
+
957
+ await captureTurnToLedger({
958
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
959
+ baselineTree: baseline, messages: [], deniedTokens: new Set(),
960
+ hitlDir: hitl, storage, gitWorkspace: false,
961
+ });
962
+
963
+ const cand = candidateChanges(status);
964
+ const env = cand.find((c) => (c.pathAfter || c.pathBefore) === ".env")!;
965
+ expect(env.captureClass).toBe(FileCaptureClass.NON_GIT_CAS);
966
+ expect(env.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
967
+ expect(env.after).toBeUndefined();
968
+ });
969
+
970
+ it("is durable across a sandbox recycle: reconciles from the manifest after the tree is wiped", async () => {
971
+ const status = newStatus();
972
+ const { storage } = makeInMemoryArtifactStorage();
973
+ const baseline = await captureBaselineToLedger({
974
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, gitWorkspace: false,
975
+ });
976
+
977
+ await wsWrite("keep.txt", "OLD");
978
+ stage("keep.txt");
979
+ await wsWrite("keep.txt", "NEW");
980
+
981
+ await captureTurnToLedger({
982
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
983
+ baselineTree: baseline, messages: [], deniedTokens: new Set(),
984
+ hitlDir: hitl, storage, gitWorkspace: false,
985
+ });
986
+
987
+ const changeSet = decidedChangeSet(status, { "keep.txt": FileDecisionAction.APPROVE });
988
+
989
+ // Simulate a sandbox recycle before resume: the whole working tree is gone,
990
+ // but the durable CAS manifest + blobs (artifact storage, a different
991
+ // durability domain) survive. Reconcile must re-materialize the approved bytes
992
+ // from them alone — never the live tree.
993
+ await rm(ws, { recursive: true, force: true });
994
+ await mkdir(ws, { recursive: true });
995
+
996
+ const result = await applyCaptureDecisions({
997
+ status, gitRoot: ws, executionId: EXEC_ID, changeSet, storage, gitWorkspace: false,
998
+ });
999
+
1000
+ expect(result.isCaptureTurn).toBe(true);
1001
+ expect(result.failed).toBe(false);
1002
+ expect(result.approvedPaths).toEqual(["keep.txt"]);
1003
+ expect(await wsRead("keep.txt")).toBe("NEW");
1004
+ });
1005
+ });