@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,856 @@
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 the harness-agnostic capture orchestration directly with the deep-agent
5
+ * harness id (the Cursor adapter is covered by execute-cursor/capture-flow.test).
6
+ * Confirms producer parity: the deep-agent authors IDENTICAL ledger entries to
7
+ * Cursor through this seam — only `harness_id` differs — and that with no
8
+ * excludePaths every changed file is captured. Runs against a REAL temp git repo.
9
+ */
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { promisify } from "node:util";
16
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
17
+ import { clone, create } from "@bufbuild/protobuf";
18
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
19
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
20
+ import {
21
+ CapturedFileChangeSchema,
22
+ FileChangeSetSchema,
23
+ FileDecisionSchema,
24
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
25
+ import type { CapturedFileChange } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
26
+ import {
27
+ DiffCompleteness,
28
+ FileCaptureClass,
29
+ FileChangeKind,
30
+ FileChangeSetStatus,
31
+ FileDecisionAction,
32
+ FileDecisionScope,
33
+ FileReviewBlockReason,
34
+ FileReviewEventType,
35
+ SnapshotKind,
36
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
37
+ import type { ArtifactStorage } from "../../artifact-storage.js";
38
+ import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
39
+ import {
40
+ applyCaptureDecisions,
41
+ captureBaselineToLedger,
42
+ captureCandidateToLedger,
43
+ deriveCaptureMode,
44
+ } from "../capture.js";
45
+ import { casBlobReader } from "../cas-substrate.js";
46
+
47
+ const execFileAsync = promisify(execFile);
48
+ const EXEC_ID = "exec-da-1";
49
+ const CHANGE_SET_ID = `${EXEC_ID}:0`;
50
+ const HARNESS = "deep-agent";
51
+
52
+ // ── deriveCaptureMode: the shared capture-vs-deny-gate decision (both harnesses) ──
53
+ // A non-git workspace with NO artifact storage (or no workspace) has no capture
54
+ // substrate, so it falls back to the deny-gate — the branch this pins for BOTH the
55
+ // Cursor (exact-apply) and deep-agent (LangGraph-replay) harnesses.
56
+ describe("deriveCaptureMode", () => {
57
+ it("engages CAPTURE for a git work tree — with or without artifact storage (git needs none)", () => {
58
+ expect(deriveCaptureMode("/ws", true, true)).toBe(true);
59
+ expect(deriveCaptureMode("/ws", true, false)).toBe(true);
60
+ });
61
+
62
+ it("engages CAPTURE for a non-git workspace only WITH artifact storage (CAS needs it)", () => {
63
+ expect(deriveCaptureMode("/ws", false, true)).toBe(true);
64
+ });
65
+
66
+ it("falls back to the DENY-GATE for a non-git workspace with NO storage", () => {
67
+ // No git snapshot, no CAS blob store: no capture substrate, so file writes
68
+ // gate pre-execution (Cursor arms exact-apply; deep-agent replays on approve).
69
+ expect(deriveCaptureMode("/ws", false, false)).toBe(false);
70
+ });
71
+
72
+ it("falls back to the DENY-GATE when there is no primary workspace at all", () => {
73
+ expect(deriveCaptureMode(undefined, true, true)).toBe(false);
74
+ expect(deriveCaptureMode(undefined, false, false)).toBe(false);
75
+ expect(deriveCaptureMode("", true, true)).toBe(false);
76
+ });
77
+ });
78
+
79
+ let repo: string;
80
+
81
+ async function git(args: string[]): Promise<string> {
82
+ const { stdout } = await execFileAsync("git", args, { cwd: repo });
83
+ return stdout;
84
+ }
85
+ async function read(rel: string): Promise<string> {
86
+ return readFile(join(repo, rel), "utf-8");
87
+ }
88
+ async function write(rel: string, content: string): Promise<void> {
89
+ await mkdir(join(repo, rel, ".."), { recursive: true });
90
+ await writeFile(join(repo, rel), content, "utf-8");
91
+ }
92
+
93
+ function newStatus(): AgentExecutionStatus {
94
+ return create(AgentExecutionStatusSchema, {});
95
+ }
96
+ function eventsOfType(status: AgentExecutionStatus, type: FileReviewEventType) {
97
+ return (status.fileReviewEventStream?.events ?? []).filter((e) => e.eventType === type);
98
+ }
99
+ function candidateChanges(status: AgentExecutionStatus): CapturedFileChange[] {
100
+ const ev = eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)[0];
101
+ return ev?.payload.case === "candidateCaptured" ? ev.payload.value.changes : [];
102
+ }
103
+ function decidedChangeSet(
104
+ status: AgentExecutionStatus,
105
+ decisionByPath: Record<string, FileDecisionAction>,
106
+ ) {
107
+ const changes = candidateChanges(status).map((c) => clone(CapturedFileChangeSchema, c));
108
+ const decisions = changes
109
+ .filter((c) => decisionByPath[c.pathAfter || c.pathBefore] !== undefined)
110
+ .map((c) =>
111
+ create(FileDecisionSchema, {
112
+ id: `${c.id}:d`,
113
+ changeSetId: CHANGE_SET_ID,
114
+ scope: FileDecisionScope.FILE,
115
+ fileChangeId: c.id,
116
+ action: decisionByPath[c.pathAfter || c.pathBefore],
117
+ expectedDigest: c.fileDigest,
118
+ }),
119
+ );
120
+ // Carry the candidate snapshot too (as the server projection does), so a CAS-only
121
+ // reconcile can point RECONCILED back at the durable manifest. Git tests reconcile
122
+ // from git refs and ignore it.
123
+ const candEv = eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)[0];
124
+ const candidateSnapshot =
125
+ candEv?.payload.case === "candidateCaptured" ? candEv.payload.value.candidateSnapshot : undefined;
126
+ return create(FileChangeSetSchema, {
127
+ id: CHANGE_SET_ID,
128
+ changes,
129
+ decisions,
130
+ candidateSnapshot,
131
+ status: FileChangeSetStatus.DECIDED,
132
+ });
133
+ }
134
+
135
+ beforeEach(async () => {
136
+ repo = await mkdtemp(join(tmpdir(), "stigmer-da-capture-"));
137
+ await git(["init", "-q"]);
138
+ await git(["config", "user.email", "t@t.local"]);
139
+ await git(["config", "user.name", "t"]);
140
+ await write("notes.md", "platon notes\n");
141
+ await write("src/main.ts", "export const x = 1;\n");
142
+ await git(["add", "-A"]);
143
+ await git(["commit", "-q", "-m", "initial"]);
144
+ });
145
+
146
+ afterEach(async () => {
147
+ await rm(repo, { recursive: true, force: true });
148
+ });
149
+
150
+ describe("capture orchestration — deep-agent harness", () => {
151
+ it("stamps harness_id=deep-agent on BASELINE and authors CANDIDATE with the captured changes", async () => {
152
+ const status = newStatus();
153
+ const baseline = await captureBaselineToLedger({
154
+ status,
155
+ gitRoot: repo,
156
+ executionId: EXEC_ID,
157
+ changeSetId: CHANGE_SET_ID,
158
+ harnessId: HARNESS,
159
+ });
160
+
161
+ const baselineEvents = eventsOfType(status, FileReviewEventType.BASELINE_CAPTURED);
162
+ expect(baselineEvents).toHaveLength(1);
163
+ expect(baselineEvents[0].actor).toBe("runner");
164
+ const basePayload = baselineEvents[0].payload;
165
+ expect(basePayload.case).toBe("baselineCaptured");
166
+ if (basePayload.case === "baselineCaptured") {
167
+ // The projection reads turn_id / harness_id ONLY from this payload.
168
+ expect(basePayload.value.changeSetId).toBe(CHANGE_SET_ID);
169
+ expect(basePayload.value.turnId).toBe(CHANGE_SET_ID);
170
+ expect(basePayload.value.harnessId).toBe(HARNESS);
171
+ }
172
+
173
+ await write("notes.md", "planton notes\n");
174
+ await write("src/new.ts", "export const y = 2;\n");
175
+
176
+ const changes = await captureCandidateToLedger({
177
+ status,
178
+ gitRoot: repo,
179
+ executionId: EXEC_ID,
180
+ changeSetId: CHANGE_SET_ID,
181
+ baselineTree: baseline,
182
+ harnessId: HARNESS,
183
+ });
184
+
185
+ expect(changes).toHaveLength(2);
186
+ const cand = candidateChanges(status);
187
+ expect(cand.map((c) => c.pathAfter).sort()).toEqual(["notes.md", "src/new.ts"]);
188
+ expect(cand.find((c) => c.pathAfter === "notes.md")!.id).toBe(`${CHANGE_SET_ID}:notes.md`);
189
+ });
190
+
191
+ it("reconciles approve/reject on resume and authors RECONCILED with harness parity", async () => {
192
+ const status = newStatus();
193
+ const baseline = await captureBaselineToLedger({
194
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
195
+ });
196
+ await write("notes.md", "planton notes\n");
197
+ await write("src/main.ts", "export const x = 99;\n");
198
+ await captureCandidateToLedger({
199
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
200
+ baselineTree: baseline, harnessId: HARNESS,
201
+ });
202
+
203
+ const changeSet = decidedChangeSet(status, {
204
+ "notes.md": FileDecisionAction.APPROVE,
205
+ "src/main.ts": FileDecisionAction.REJECT,
206
+ });
207
+
208
+ const result = await applyCaptureDecisions({
209
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
210
+ });
211
+
212
+ expect(result.isCaptureTurn).toBe(true);
213
+ expect(result.failed).toBe(false);
214
+ expect(result.approvedPaths).toEqual(["notes.md"]);
215
+ expect(result.rejectedPaths).toEqual(["src/main.ts"]);
216
+
217
+ // Approved kept; rejected snapped back to baseline; nothing committed.
218
+ expect(await read("notes.md")).toBe("planton notes\n");
219
+ expect(await read("src/main.ts")).toBe("export const x = 1;\n");
220
+ expect((await git(["log", "--oneline"])).trim().split("\n")).toHaveLength(1);
221
+
222
+ const reconciled = eventsOfType(status, FileReviewEventType.RECONCILED);
223
+ expect(reconciled).toHaveLength(1);
224
+ expect(reconciled[0].actor).toBe("runner");
225
+ expect((await git(["for-each-ref", "refs/stigmer/"])).trim()).toBe("");
226
+ });
227
+
228
+ it("git-only reconcile never probes storage for a CAS manifest, even when exists() lies (regression: proxy 404 crash)", async () => {
229
+ // Regression for the file-review reconcile crash in cloud/desktop (proxy)
230
+ // artifact mode. `ProxyArtifactStorage.exists()` reports true for ANY key
231
+ // (the presign endpoint mints a URL regardless of object existence), so the
232
+ // old reconcile "found" a CAS manifest that a git-only turn never wrote,
233
+ // downloaded it, and died on the R2 `NoSuchKey` 404 (see the ExecuteCursor
234
+ // crash in _cursor/error.md). The reconcile must decide "is this a CAS turn?"
235
+ // from the change set's CANDIDATE snapshot — a git-only turn carries no cas
236
+ // ref — so a lying `exists()` can no longer turn a git reconcile into a
237
+ // doomed download.
238
+ const status = newStatus();
239
+ const baseline = await captureBaselineToLedger({
240
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
241
+ });
242
+ await write("notes.md", "planton notes\n");
243
+ await captureCandidateToLedger({
244
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
245
+ baselineTree: baseline, harnessId: HARNESS,
246
+ });
247
+
248
+ const changeSet = decidedChangeSet(status, { "notes.md": FileDecisionAction.APPROVE });
249
+ // Precondition: this is a git-only turn — its candidate snapshot has no CAS ref.
250
+ expect(changeSet.candidateSnapshot?.kind).toBe(SnapshotKind.GIT_TREE_REF);
251
+ expect(changeSet.candidateSnapshot?.cas).toBeUndefined();
252
+
253
+ // A storage that behaves like the proxy: `exists()` lies (true for every key)
254
+ // and a download of the absent manifest fails (the R2 404 from error.md).
255
+ const { storage } = makeInMemoryArtifactStorage();
256
+ storage.exists.mockResolvedValue(true);
257
+
258
+ const result = await applyCaptureDecisions({
259
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
260
+ storage, readBlob: casBlobReader(storage),
261
+ });
262
+
263
+ // Reconciles via git; the approved edit is kept and nothing crashed.
264
+ expect(result.isCaptureTurn).toBe(true);
265
+ expect(result.failed).toBe(false);
266
+ expect(result.approvedPaths).toEqual(["notes.md"]);
267
+ expect(await read("notes.md")).toBe("planton notes\n");
268
+ // The proof: the git-only reconcile never asked storage for the CAS manifest.
269
+ const manifestKey =
270
+ `artifacts/${EXEC_ID}/filereview/cas/${CHANGE_SET_ID.replace(/[^A-Za-z0-9._-]/g, "_")}.manifest.json`;
271
+ expect(storage.download).not.toHaveBeenCalledWith(manifestKey);
272
+ });
273
+
274
+ it("reconciles an APPROVED binary byte-exact — the digest gate accepts a byte-true binary", async () => {
275
+ // A binary is captured incomplete (no text diff); Slice B lets a user
276
+ // acknowledge-APPROVE it. This proves the runner half: the reconcile's digest
277
+ // gate must ACCEPT the byte-true binary (never spuriously HASH_MISMATCH on a
278
+ // body-less side) and re-apply the exact bytes from the git ref.
279
+ const BIN = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe, 0x80, 0x01]);
280
+ const status = newStatus();
281
+ const baseline = await captureBaselineToLedger({
282
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
283
+ });
284
+ await mkdir(join(repo, "assets"), { recursive: true });
285
+ await writeFile(join(repo, "assets/logo.png"), BIN); // binary create
286
+
287
+ await captureCandidateToLedger({
288
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
289
+ baselineTree: baseline, harnessId: HARNESS,
290
+ });
291
+
292
+ // Captured honestly: incomplete (binary), is_binary set, no inline body.
293
+ const bin = candidateChanges(status).find((c) => c.pathAfter === "assets/logo.png")!;
294
+ expect(bin.diffComplete).toBe(false);
295
+ expect(bin.after?.isBinary).toBe(true);
296
+ expect(bin.after?.body.case).toBeUndefined(); // body-less
297
+
298
+ const changeSet = decidedChangeSet(status, {
299
+ "assets/logo.png": FileDecisionAction.APPROVE,
300
+ });
301
+ // Remove from disk first to prove reconcile re-applies from the ref, not disk.
302
+ await rm(join(repo, "assets/logo.png"), { force: true });
303
+
304
+ const result = await applyCaptureDecisions({
305
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
306
+ });
307
+
308
+ expect(result.failed).toBe(false);
309
+ expect(result.approvedPaths).toEqual(["assets/logo.png"]);
310
+ expect((await readFile(join(repo, "assets/logo.png"))).equals(BIN)).toBe(true);
311
+ });
312
+ });
313
+
314
+ // In-memory artifact store for the CAS composition tests — the canonical double.
315
+ function makeStorage(): ArtifactStorage & { blobs: Map<string, Buffer> } {
316
+ const { storage, blobs } = makeInMemoryArtifactStorage();
317
+ return Object.assign(storage, { blobs });
318
+ }
319
+
320
+ function candidateSnapshotKind(status: AgentExecutionStatus): SnapshotKind | undefined {
321
+ const ev = eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)[0];
322
+ return ev?.payload.case === "candidateCaptured"
323
+ ? ev.payload.value.candidateSnapshot?.kind
324
+ : undefined;
325
+ }
326
+ function candidateCompleteness(status: AgentExecutionStatus): DiffCompleteness | undefined {
327
+ const ev = eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)[0];
328
+ return ev?.payload.case === "candidateCaptured"
329
+ ? ev.payload.value.diffCompleteness
330
+ : undefined;
331
+ }
332
+
333
+ describe("capture orchestration — hybrid git + CAS (Phase 3)", () => {
334
+ it("composes git-tracked and CAS captures into one HYBRID change set", async () => {
335
+ const status = newStatus();
336
+ const storage = makeStorage();
337
+ const baseline = await captureBaselineToLedger({
338
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
339
+ });
340
+
341
+ // A git-tracked edit AND an ignored file the harness captured out-of-band.
342
+ await write("notes.md", "planton notes\n");
343
+ const gitChanges = await captureCandidateToLedger({
344
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
345
+ baselineTree: baseline, harnessId: HARNESS, storage,
346
+ casCaptures: [
347
+ { path: ".env", before: null, after: new TextEncoder().encode("SECRET=2"), captureClass: FileCaptureClass.GIT_IGNORED_CAPTURED },
348
+ ],
349
+ });
350
+
351
+ // The return is still the git-tracked changes (adapter presentation concern).
352
+ expect(gitChanges.map((c) => c.path)).toEqual(["notes.md"]);
353
+
354
+ const cand = candidateChanges(status);
355
+ const byPath = new Map(cand.map((c) => [c.pathAfter || c.pathBefore, c]));
356
+ expect([...byPath.keys()].sort()).toEqual([".env", "notes.md"]);
357
+
358
+ // The git file is inline; the CAS file is a blob ref (stored once, not inlined).
359
+ const gitFile = byPath.get("notes.md")!;
360
+ expect(gitFile.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
361
+ expect(gitFile.after?.body.case).toBe("inline");
362
+
363
+ const casFile = byPath.get(".env")!;
364
+ expect(casFile.captureClass).toBe(FileCaptureClass.GIT_IGNORED_CAPTURED);
365
+ expect(casFile.kind).toBe(FileChangeKind.ADD);
366
+ expect(casFile.after?.body.case).toBe("ref");
367
+ // The referenced blob physically exists in the store.
368
+ if (casFile.after?.body.case === "ref") {
369
+ expect(storage.blobs.has(casFile.after.body.value.storageKey)).toBe(true);
370
+ }
371
+
372
+ // The snapshot spans both substrates; the aggregate digest folds both files.
373
+ expect(candidateSnapshotKind(status)).toBe(SnapshotKind.HYBRID);
374
+ const ev = eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)[0];
375
+ if (ev.payload.case === "candidateCaptured") {
376
+ expect(ev.payload.value.aggregateDigest).not.toBe("");
377
+ }
378
+ });
379
+
380
+ it("stays git-only (no CAS) when no ignored paths are captured", async () => {
381
+ const status = newStatus();
382
+ const storage = makeStorage();
383
+ const baseline = await captureBaselineToLedger({
384
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
385
+ });
386
+ await write("notes.md", "planton notes\n");
387
+ await captureCandidateToLedger({
388
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
389
+ baselineTree: baseline, harnessId: HARNESS, storage, casCaptures: [],
390
+ });
391
+ expect(candidateSnapshotKind(status)).toBe(SnapshotKind.GIT_TREE_REF);
392
+ expect(storage.blobs.size).toBe(0);
393
+ });
394
+
395
+ it("marks the change set BINARY_SUMMARY_ONLY when the only incomplete file is a binary CAS file", async () => {
396
+ const status = newStatus();
397
+ const storage = makeStorage();
398
+ const baseline = await captureBaselineToLedger({
399
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
400
+ });
401
+ await captureCandidateToLedger({
402
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
403
+ baselineTree: baseline, harnessId: HARNESS, storage,
404
+ casCaptures: [
405
+ { path: "cache.bin", before: null, after: new Uint8Array([1, 0, 2]), captureClass: FileCaptureClass.GIT_IGNORED_CAPTURED },
406
+ ],
407
+ });
408
+ const cand = candidateChanges(status);
409
+ expect(cand[0].diffComplete).toBe(false);
410
+ // Binary is conveyed by FileContent.is_binary, not blocked_reason (doc 15).
411
+ expect(cand[0].blockedReason).toBe(FileReviewBlockReason.UNSPECIFIED);
412
+ // Binary is the set's only blocker, so it is keepable in one acknowledged action.
413
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.BINARY_SUMMARY_ONLY);
414
+ });
415
+
416
+ it("reconciles hybrid decisions on resume: approved CAS kept, rejected CAS reverted", async () => {
417
+ const status = newStatus();
418
+ const storage = makeStorage();
419
+ const readBlob = async (key: string): Promise<Buffer> => {
420
+ const b = storage.blobs.get(key);
421
+ if (!b) throw new Error(`missing ${key}`);
422
+ return b;
423
+ };
424
+ const enc = (s: string) => new TextEncoder().encode(s);
425
+
426
+ // Ignore dist/ and build/ so git does not also capture these paths.
427
+ await write(".gitignore", "dist/\nbuild/\n");
428
+ await git(["add", ".gitignore"]);
429
+ await git(["commit", "-q", "-m", "ignore"]);
430
+
431
+ const baseline = await captureBaselineToLedger({
432
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
433
+ });
434
+
435
+ // Apply the turn's edits to disk (the "after" state the review window shows).
436
+ await write("notes.md", "planton\n"); // git-tracked
437
+ await write("dist/out.txt", "X"); // ignored ADD
438
+ await write("build/cache.txt", "NEW"); // ignored MODIFY (baseline was OLD)
439
+
440
+ await captureCandidateToLedger({
441
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
442
+ baselineTree: baseline, harnessId: HARNESS, storage,
443
+ casCaptures: [
444
+ { path: "dist/out.txt", before: null, after: enc("X"), captureClass: FileCaptureClass.GIT_IGNORED_CAPTURED },
445
+ { path: "build/cache.txt", before: enc("OLD"), after: enc("NEW"), captureClass: FileCaptureClass.GIT_IGNORED_CAPTURED },
446
+ ],
447
+ });
448
+
449
+ const changeSet = decidedChangeSet(status, {
450
+ "notes.md": FileDecisionAction.APPROVE,
451
+ "dist/out.txt": FileDecisionAction.APPROVE,
452
+ "build/cache.txt": FileDecisionAction.REJECT,
453
+ });
454
+
455
+ const result = await applyCaptureDecisions({
456
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
457
+ storage, readBlob,
458
+ });
459
+
460
+ expect(result.failed).toBe(false);
461
+ expect([...result.approvedPaths].sort()).toEqual(["dist/out.txt", "notes.md"]);
462
+ expect(result.rejectedPaths).toEqual(["build/cache.txt"]);
463
+
464
+ // Approved git + approved CAS kept; rejected CAS reverted to baseline bytes.
465
+ expect(await read("notes.md")).toBe("planton\n");
466
+ expect(await read("dist/out.txt")).toBe("X");
467
+ expect(await read("build/cache.txt")).toBe("OLD");
468
+ });
469
+
470
+ it("fails closed when the candidate snapshot records CAS files but no blob reader is provided", async () => {
471
+ // The ledger says CAS files were captured; a caller that cannot read blobs
472
+ // back must not silently skip them (their decisions would go unenforced).
473
+ const status = newStatus();
474
+ const storage = makeStorage();
475
+ await write(".gitignore", "dist/\n");
476
+ await git(["add", ".gitignore"]);
477
+ await git(["commit", "-q", "-m", "ignore"]);
478
+ const baseline = await captureBaselineToLedger({
479
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
480
+ });
481
+ await write("dist/out.txt", "X");
482
+ await captureCandidateToLedger({
483
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
484
+ baselineTree: baseline, harnessId: HARNESS, storage,
485
+ casCaptures: [
486
+ { path: "dist/out.txt", before: null, after: new TextEncoder().encode("X"), captureClass: FileCaptureClass.GIT_IGNORED_CAPTURED },
487
+ ],
488
+ });
489
+ const changeSet = decidedChangeSet(status, { "dist/out.txt": FileDecisionAction.APPROVE });
490
+
491
+ await expect(
492
+ applyCaptureDecisions({
493
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
494
+ }),
495
+ ).rejects.toThrow(/captured CAS files .* but no blob reader was provided/);
496
+ });
497
+
498
+ it("fails closed on resume when the stored manifest no longer hashes to the ledger's manifestDigest", async () => {
499
+ // The reconcile must verify the manifest it downloads against the digest the
500
+ // CANDIDATE event pinned — per-blob content addresses protect blob bodies,
501
+ // but only this check protects the path→blob mapping itself.
502
+ const status = newStatus();
503
+ const storage = makeStorage();
504
+ const readBlob = async (key: string): Promise<Buffer> => {
505
+ const b = storage.blobs.get(key);
506
+ if (!b) throw new Error(`missing ${key}`);
507
+ return b;
508
+ };
509
+ await write(".gitignore", "dist/\n");
510
+ await git(["add", ".gitignore"]);
511
+ await git(["commit", "-q", "-m", "ignore"]);
512
+ const baseline = await captureBaselineToLedger({
513
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
514
+ });
515
+ await write("dist/out.txt", "X");
516
+ await captureCandidateToLedger({
517
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
518
+ baselineTree: baseline, harnessId: HARNESS, storage,
519
+ casCaptures: [
520
+ { path: "dist/out.txt", before: null, after: new TextEncoder().encode("X"), captureClass: FileCaptureClass.GIT_IGNORED_CAPTURED },
521
+ ],
522
+ });
523
+ const changeSet = decidedChangeSet(status, { "dist/out.txt": FileDecisionAction.APPROVE });
524
+
525
+ // Tamper with the stored manifest between review and resume.
526
+ const manifestUri = changeSet.candidateSnapshot!.cas!.artifactUri;
527
+ const stored = storage.blobs.get(manifestUri)!;
528
+ storage.blobs.set(manifestUri, Buffer.from(stored.toString("utf8").replace("dist/out.txt", "dist/evil.txt"), "utf8"));
529
+
530
+ await expect(
531
+ applyCaptureDecisions({
532
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
533
+ storage, readBlob,
534
+ }),
535
+ ).rejects.toThrow(/CAS manifest integrity check failed/);
536
+ });
537
+
538
+ it("throws if CAS captures are supplied without a storage backend", async () => {
539
+ const status = newStatus();
540
+ const baseline = await captureBaselineToLedger({
541
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
542
+ });
543
+ await expect(
544
+ captureCandidateToLedger({
545
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
546
+ baselineTree: baseline, harnessId: HARNESS,
547
+ casCaptures: [
548
+ { path: ".env", before: null, after: new TextEncoder().encode("X=1"), captureClass: FileCaptureClass.GIT_IGNORED_CAPTURED },
549
+ ],
550
+ }),
551
+ ).rejects.toThrow(/requires an ArtifactStorage/);
552
+ });
553
+ });
554
+
555
+ describe("capture orchestration — secret-blocked DIFF_UNREVIEWABLE (DD-E)", () => {
556
+ it("authors a content-less DIFF_UNREVIEWABLE entry that blocks approval and persists nothing", async () => {
557
+ const status = newStatus();
558
+ const storage = makeStorage();
559
+ const baseline = await captureBaselineToLedger({
560
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
561
+ });
562
+
563
+ await write("notes.md", "planton\n"); // a real git change alongside the block
564
+
565
+ await captureCandidateToLedger({
566
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
567
+ baselineTree: baseline, harnessId: HARNESS, storage,
568
+ unreviewablePaths: [".env"],
569
+ });
570
+
571
+ const byPath = new Map(candidateChanges(status).map((c) => [c.pathAfter || c.pathBefore, c]));
572
+ expect([...byPath.keys()].sort()).toEqual([".env", "notes.md"]);
573
+
574
+ const secret = byPath.get(".env")!;
575
+ expect(secret.captureClass).toBe(FileCaptureClass.GIT_IGNORED_CAPTURED);
576
+ expect(secret.diffComplete).toBe(false);
577
+ // The honest cause is recorded so the UI can say *why* (doc 15), while the
578
+ // real git change beside it stays fully reviewable (UNSPECIFIED).
579
+ expect(secret.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
580
+ expect(byPath.get("notes.md")!.blockedReason).toBe(FileReviewBlockReason.UNSPECIFIED);
581
+ // No content and no enforcement digests: the bytes are never captured.
582
+ expect(secret.before).toBeUndefined();
583
+ expect(secret.after).toBeUndefined();
584
+ expect(secret.beforeSha256).toBe("");
585
+ expect(secret.afterSha256).toBe("");
586
+
587
+ // One incomplete file forces the whole set PARTIAL_BLOCKED (approval blocked).
588
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
589
+ // Nothing was persisted to storage for the blocked path.
590
+ expect(storage.blobs.size).toBe(0);
591
+ });
592
+
593
+ it("authors a CANDIDATE for an unreviewable-only turn (no git or CAS change)", async () => {
594
+ const status = newStatus();
595
+ const baseline = await captureBaselineToLedger({
596
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
597
+ });
598
+
599
+ // The only thing that "happened" is a blocked secret write attempt.
600
+ await captureCandidateToLedger({
601
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
602
+ baselineTree: baseline, harnessId: HARNESS,
603
+ unreviewablePaths: [".env"],
604
+ });
605
+
606
+ expect(eventsOfType(status, FileReviewEventType.CANDIDATE_CAPTURED)).toHaveLength(1);
607
+ expect(candidateChanges(status).map((c) => c.pathAfter)).toEqual([".env"]);
608
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
609
+ });
610
+ });
611
+
612
+ // Slice 2a (DD-21 D2): the shared orchestration must capture and reconcile a
613
+ // NON-GIT workspace entirely from the CAS manifest — no git refs, no whole-tree
614
+ // snapshot, bounded to the paths the observer actually touched. These exercise
615
+ // capture.ts directly against a plain (non-git) temp dir; the harness wirings
616
+ // that flip `gitWorkspace=false` and observe all touched paths are slices 2b/2c.
617
+ function reconcilerReadBlob(storage: { blobs: Map<string, Buffer> }): (key: string) => Promise<Buffer> {
618
+ return async (key: string): Promise<Buffer> => {
619
+ const b = storage.blobs.get(key);
620
+ if (!b) throw new Error(`missing ${key}`);
621
+ return b;
622
+ };
623
+ }
624
+
625
+ describe("capture orchestration — CAS-only non-git workspace (Slice 2a)", () => {
626
+ let ws: string;
627
+ beforeEach(async () => {
628
+ ws = await mkdtemp(join(tmpdir(), "stigmer-nongit-"));
629
+ });
630
+ afterEach(async () => {
631
+ await rm(ws, { recursive: true, force: true });
632
+ });
633
+
634
+ it("authors a CAS_MANIFEST baseline (returns \"\") + candidate with NON_GIT_CAS changes", async () => {
635
+ const status = newStatus();
636
+ const storage = makeStorage();
637
+
638
+ const baseline = await captureBaselineToLedger({
639
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
640
+ harnessId: HARNESS, gitWorkspace: false,
641
+ });
642
+ // No git tree to pin — baseline is empty; the event still carries turn/harness
643
+ // (the projection's only source) and a CAS-kind snapshot placeholder.
644
+ expect(baseline).toBe("");
645
+ const baseEv = eventsOfType(status, FileReviewEventType.BASELINE_CAPTURED)[0];
646
+ expect(baseEv.payload.case).toBe("baselineCaptured");
647
+ if (baseEv.payload.case === "baselineCaptured") {
648
+ expect(baseEv.payload.value.harnessId).toBe(HARNESS);
649
+ expect(baseEv.payload.value.turnId).toBe(CHANGE_SET_ID);
650
+ expect(baseEv.payload.value.baselineSnapshot?.kind).toBe(SnapshotKind.CAS_MANIFEST);
651
+ }
652
+
653
+ await writeFile(join(ws, "app.log"), "new\n");
654
+ await captureCandidateToLedger({
655
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
656
+ baselineTree: baseline, harnessId: HARNESS, gitWorkspace: false, storage,
657
+ casCaptures: [
658
+ { path: "app.log", before: null, after: new TextEncoder().encode("new\n"), captureClass: FileCaptureClass.NON_GIT_CAS },
659
+ ],
660
+ });
661
+
662
+ expect(candidateSnapshotKind(status)).toBe(SnapshotKind.CAS_MANIFEST);
663
+ const cand = candidateChanges(status);
664
+ expect(cand.map((c) => c.pathAfter)).toEqual(["app.log"]);
665
+ expect(cand[0].captureClass).toBe(FileCaptureClass.NON_GIT_CAS);
666
+ expect(cand[0].kind).toBe(FileChangeKind.ADD);
667
+ // Bytes are offloaded to a content-addressed blob, never inlined.
668
+ expect(cand[0].after?.body.case).toBe("ref");
669
+ if (cand[0].after?.body.case === "ref") {
670
+ expect(storage.blobs.has(cand[0].after.body.value.storageKey)).toBe(true);
671
+ }
672
+ });
673
+
674
+ it("reconciles approve/reject from the manifest and authors a CAS_MANIFEST RECONCILED", async () => {
675
+ const status = newStatus();
676
+ const storage = makeStorage();
677
+ const readBlob = reconcilerReadBlob(storage);
678
+ const enc = (s: string) => new TextEncoder().encode(s);
679
+
680
+ const baseline = await captureBaselineToLedger({
681
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
682
+ harnessId: HARNESS, gitWorkspace: false,
683
+ });
684
+
685
+ // The turn's edits are already on disk (the "after" state the review shows).
686
+ await writeFile(join(ws, "created.txt"), "X");
687
+ await writeFile(join(ws, "edited.txt"), "NEW");
688
+ await captureCandidateToLedger({
689
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
690
+ baselineTree: baseline, harnessId: HARNESS, gitWorkspace: false, storage,
691
+ casCaptures: [
692
+ { path: "created.txt", before: null, after: enc("X"), captureClass: FileCaptureClass.NON_GIT_CAS },
693
+ { path: "edited.txt", before: enc("OLD"), after: enc("NEW"), captureClass: FileCaptureClass.NON_GIT_CAS },
694
+ ],
695
+ });
696
+
697
+ const changeSet = decidedChangeSet(status, {
698
+ "created.txt": FileDecisionAction.APPROVE,
699
+ "edited.txt": FileDecisionAction.REJECT,
700
+ });
701
+
702
+ const result = await applyCaptureDecisions({
703
+ status, gitRoot: ws, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
704
+ gitWorkspace: false, storage, readBlob,
705
+ });
706
+
707
+ expect(result.isCaptureTurn).toBe(true);
708
+ expect(result.failed).toBe(false);
709
+ expect(result.approvedPaths).toEqual(["created.txt"]);
710
+ expect(result.rejectedPaths).toEqual(["edited.txt"]);
711
+
712
+ // Approved kept at its "after" bytes; rejected snapped back to "before".
713
+ expect(await readFile(join(ws, "created.txt"), "utf-8")).toBe("X");
714
+ expect(await readFile(join(ws, "edited.txt"), "utf-8")).toBe("OLD");
715
+
716
+ const reconciled = eventsOfType(status, FileReviewEventType.RECONCILED);
717
+ expect(reconciled).toHaveLength(1);
718
+ if (reconciled[0].payload.case === "reconciled") {
719
+ const snap = reconciled[0].payload.value.approvedSnapshot;
720
+ expect(snap?.kind).toBe(SnapshotKind.CAS_MANIFEST);
721
+ // Points back at the durable candidate manifest (the reconciled state is
722
+ // manifest + decisions; there is no tree to re-pin).
723
+ expect(snap?.cas?.artifactUri).not.toBe("");
724
+ }
725
+ });
726
+
727
+ it("a rejected ADD is removed; enforcement re-applies an approved file from the blob", async () => {
728
+ const status = newStatus();
729
+ const storage = makeStorage();
730
+ const readBlob = reconcilerReadBlob(storage);
731
+ const enc = (s: string) => new TextEncoder().encode(s);
732
+
733
+ const baseline = await captureBaselineToLedger({
734
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
735
+ harnessId: HARNESS, gitWorkspace: false,
736
+ });
737
+ await writeFile(join(ws, "keep.txt"), "KEEP");
738
+ await writeFile(join(ws, "drop.txt"), "DROP");
739
+ await captureCandidateToLedger({
740
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
741
+ baselineTree: baseline, harnessId: HARNESS, gitWorkspace: false, storage,
742
+ casCaptures: [
743
+ { path: "keep.txt", before: null, after: enc("KEEP"), captureClass: FileCaptureClass.NON_GIT_CAS },
744
+ { path: "drop.txt", before: null, after: enc("DROP"), captureClass: FileCaptureClass.NON_GIT_CAS },
745
+ ],
746
+ });
747
+
748
+ const changeSet = decidedChangeSet(status, {
749
+ "keep.txt": FileDecisionAction.APPROVE,
750
+ "drop.txt": FileDecisionAction.REJECT,
751
+ });
752
+
753
+ // Delete the approved file from disk first to prove reconcile re-applies it
754
+ // from the durable blob, not the live tree.
755
+ await rm(join(ws, "keep.txt"), { force: true });
756
+
757
+ const result = await applyCaptureDecisions({
758
+ status, gitRoot: ws, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
759
+ gitWorkspace: false, storage, readBlob,
760
+ });
761
+
762
+ expect(result.failed).toBe(false);
763
+ expect(await readFile(join(ws, "keep.txt"), "utf-8")).toBe("KEEP");
764
+ // A rejected create is removed (it did not exist at baseline).
765
+ await expect(readFile(join(ws, "drop.txt"), "utf-8")).rejects.toThrow();
766
+ });
767
+
768
+ it("is not a capture turn when neither a git ref nor a CAS manifest exists", async () => {
769
+ const status = newStatus();
770
+ const storage = makeStorage();
771
+ const readBlob = reconcilerReadBlob(storage);
772
+ const changeSet = create(FileChangeSetSchema, { id: CHANGE_SET_ID, status: FileChangeSetStatus.DECIDED });
773
+
774
+ const result = await applyCaptureDecisions({
775
+ status, gitRoot: ws, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
776
+ gitWorkspace: false, storage, readBlob,
777
+ });
778
+
779
+ expect(result.isCaptureTurn).toBe(false);
780
+ expect(eventsOfType(status, FileReviewEventType.RECONCILED)).toHaveLength(0);
781
+ });
782
+
783
+ // Slice 2d: durable across a sandbox recycle — the reconcile sources approved
784
+ // bytes ENTIRELY from the CAS manifest + blobs (a different durability domain
785
+ // than the workspace), so it converges even after the whole tree is wiped.
786
+ it("is durable across a sandbox recycle: reconciles from the manifest after the tree is wiped", async () => {
787
+ const status = newStatus();
788
+ const storage = makeStorage();
789
+ const readBlob = reconcilerReadBlob(storage);
790
+ const enc = (s: string) => new TextEncoder().encode(s);
791
+
792
+ const baseline = await captureBaselineToLedger({
793
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
794
+ harnessId: HARNESS, gitWorkspace: false,
795
+ });
796
+ await writeFile(join(ws, "keep.txt"), "NEW");
797
+ await captureCandidateToLedger({
798
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
799
+ baselineTree: baseline, harnessId: HARNESS, gitWorkspace: false, storage,
800
+ casCaptures: [
801
+ { path: "keep.txt", before: enc("OLD"), after: enc("NEW"), captureClass: FileCaptureClass.NON_GIT_CAS },
802
+ ],
803
+ });
804
+
805
+ const changeSet = decidedChangeSet(status, { "keep.txt": FileDecisionAction.APPROVE });
806
+
807
+ // Wipe the whole tree (sandbox recycle / Temporal retry on a fresh host).
808
+ await rm(ws, { recursive: true, force: true });
809
+ await mkdir(ws, { recursive: true });
810
+
811
+ const result = await applyCaptureDecisions({
812
+ status, gitRoot: ws, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
813
+ gitWorkspace: false, storage, readBlob,
814
+ });
815
+
816
+ expect(result.isCaptureTurn).toBe(true);
817
+ expect(result.failed).toBe(false);
818
+ expect(result.approvedPaths).toEqual(["keep.txt"]);
819
+ expect(await readFile(join(ws, "keep.txt"), "utf-8")).toBe("NEW");
820
+ });
821
+
822
+ // Slice 2b: a non-git secret-blocked path must be labeled NON_GIT_CAS (not
823
+ // "gitignored"), so the review UI reports its true provenance while its content
824
+ // is still withheld (DD-E).
825
+ it("labels a non-git secret-blocked path NON_GIT_CAS and blocks approval", async () => {
826
+ const status = newStatus();
827
+ const storage = makeStorage();
828
+
829
+ const baseline = await captureBaselineToLedger({
830
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
831
+ harnessId: HARNESS, gitWorkspace: false,
832
+ });
833
+
834
+ await writeFile(join(ws, "app.log"), "log\n");
835
+ await captureCandidateToLedger({
836
+ status, gitRoot: ws, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
837
+ baselineTree: baseline, harnessId: HARNESS, gitWorkspace: false, storage,
838
+ casCaptures: [
839
+ { path: "app.log", before: null, after: new TextEncoder().encode("log\n"), captureClass: FileCaptureClass.NON_GIT_CAS },
840
+ ],
841
+ unreviewablePaths: [".env"],
842
+ unreviewableCaptureClass: FileCaptureClass.NON_GIT_CAS,
843
+ });
844
+
845
+ const byPath = new Map(candidateChanges(status).map((c) => [c.pathAfter || c.pathBefore, c]));
846
+ const blocked = byPath.get(".env")!;
847
+ expect(blocked.captureClass).toBe(FileCaptureClass.NON_GIT_CAS);
848
+ expect(blocked.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
849
+ expect(blocked.diffComplete).toBe(false);
850
+ // The blocked path never enters storage; only the reviewable log's blob does.
851
+ expect(blocked.before).toBeUndefined();
852
+ expect(blocked.after).toBeUndefined();
853
+ // One incomplete (non-binary) file forces PARTIAL_BLOCKED (approval blocked).
854
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
855
+ });
856
+ });