@rryando/arcs 5.1.0 → 5.2.1

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 (219) hide show
  1. package/README.md +30 -17
  2. package/dist/cli/arcs-orchestrate.d.ts +1 -1
  3. package/dist/cli/arcs-orchestrate.d.ts.map +1 -1
  4. package/dist/cli/commands/bundle.js +39 -0
  5. package/dist/cli/commands/bundle.js.map +1 -1
  6. package/dist/cli/commands/proposal-doc.js +76 -58
  7. package/dist/cli/commands/proposal-doc.js.map +1 -1
  8. package/dist/cli/commands/web.js +2 -2
  9. package/dist/cli/commands/web.js.map +1 -1
  10. package/dist/cli/config.d.ts +37 -9
  11. package/dist/cli/config.d.ts.map +1 -1
  12. package/dist/cli/config.js +64 -5
  13. package/dist/cli/config.js.map +1 -1
  14. package/dist/cli/instructions.d.ts +3 -3
  15. package/dist/cli/instructions.d.ts.map +1 -1
  16. package/dist/cli/instructions.js +10 -2
  17. package/dist/cli/instructions.js.map +1 -1
  18. package/dist/cli/orchestrator-shared-blocks.d.ts +1 -1
  19. package/dist/cli/orchestrator-shared-blocks.d.ts.map +1 -1
  20. package/dist/cli/orchestrator-shared-blocks.js +1 -1
  21. package/dist/cli/setup.d.ts.map +1 -1
  22. package/dist/cli/setup.js +281 -47
  23. package/dist/cli/setup.js.map +1 -1
  24. package/dist/utils/json-schemas.d.ts +32 -9
  25. package/dist/utils/json-schemas.d.ts.map +1 -1
  26. package/dist/utils/json-schemas.js +8 -1
  27. package/dist/utils/json-schemas.js.map +1 -1
  28. package/dist/utils/project-resolver.d.ts.map +1 -1
  29. package/dist/utils/project-resolver.js +9 -2
  30. package/dist/utils/project-resolver.js.map +1 -1
  31. package/dist/utils/run-transcript.d.ts +138 -0
  32. package/dist/utils/run-transcript.d.ts.map +1 -0
  33. package/dist/utils/run-transcript.js +146 -0
  34. package/dist/utils/run-transcript.js.map +1 -0
  35. package/dist/utils/storage-utils.d.ts +1 -1
  36. package/dist/utils/storage-utils.d.ts.map +1 -1
  37. package/dist/utils/storage-utils.js +1 -1
  38. package/dist/utils/storage-utils.js.map +1 -1
  39. package/dist/web-client/assets/{GraphCanvas-BYZE4sO9.js → GraphCanvas-BgWF1rB_.js} +1 -1
  40. package/dist/web-client/assets/{MarkdownEditor-BKx6M1cw.js → MarkdownEditor-5buYPQ1d.js} +1 -1
  41. package/dist/web-client/assets/{abnfDiagram-VRR7QNED-D4kt0l2y.js → abnfDiagram-VRR7QNED-AjQ08-zY.js} +1 -1
  42. package/dist/web-client/assets/architecture-TIHT7OUA-a3lTAuFS.js +1 -0
  43. package/dist/web-client/assets/{architectureDiagram-ZJ3FMSHR-bu29SJFS.js → architectureDiagram-ZJ3FMSHR-CtW2pYEp.js} +1 -1
  44. package/dist/web-client/assets/{blockDiagram-677ZJIJ3-hsu7mXKw.js → blockDiagram-677ZJIJ3-DE6HRcpA.js} +1 -1
  45. package/dist/web-client/assets/{c4Diagram-LMCZKHZV-DMllRlT_.js → c4Diagram-LMCZKHZV-Rn6iQdUl.js} +1 -1
  46. package/dist/web-client/assets/channel-Rxk7gzWl.js +1 -0
  47. package/dist/web-client/assets/{chunk-32BRIVSS-BSzwj5eM.js → chunk-32BRIVSS-C4-ay_Qd.js} +1 -1
  48. package/dist/web-client/assets/{chunk-52WLFC77-RDdZn6yY.js → chunk-52WLFC77-Bib0DU0H.js} +1 -1
  49. package/dist/web-client/assets/{chunk-C7G6YPKG-DQr--txT.js → chunk-C7G6YPKG-BLphnESE.js} +1 -1
  50. package/dist/web-client/assets/{chunk-EX3LRPZG-Bb8nc4z4.js → chunk-EX3LRPZG-CzvkrPNM.js} +1 -1
  51. package/dist/web-client/assets/{chunk-FWX5IMBZ-QvBpOcOg.js → chunk-FWX5IMBZ-BFTR_OLz.js} +2 -2
  52. package/dist/web-client/assets/{chunk-HOUHSVGY-B0mX_yjc.js → chunk-HOUHSVGY-CZS3ExUR.js} +1 -1
  53. package/dist/web-client/assets/{chunk-ICXQ74PX-BmJSQqOH.js → chunk-ICXQ74PX-iWFpNZgv.js} +1 -1
  54. package/dist/web-client/assets/{chunk-MOJQB5TN-BrEI4GGn.js → chunk-MOJQB5TN-opEQCwVq.js} +1 -1
  55. package/dist/web-client/assets/{chunk-OGEWGWER-CrqDPOWP.js → chunk-OGEWGWER-NCfmjyhi.js} +1 -1
  56. package/dist/web-client/assets/{chunk-PUDLZKDR-DfPKldpG.js → chunk-PUDLZKDR-D3_A--Z2.js} +1 -1
  57. package/dist/web-client/assets/{chunk-Q4XR5HBZ-ekATI9aV.js → chunk-Q4XR5HBZ-D6s-qZLi.js} +1 -1
  58. package/dist/web-client/assets/{chunk-V7JOEXUC-C5APsP1t.js → chunk-V7JOEXUC-DTqLR-bZ.js} +1 -1
  59. package/dist/web-client/assets/{chunk-VAUOI2AC-BhfSWJZI.js → chunk-VAUOI2AC-Gsd0Itap.js} +1 -1
  60. package/dist/web-client/assets/{chunk-VR4S4FIN-BGa-44J7.js → chunk-VR4S4FIN-Cdo_MeDQ.js} +1 -1
  61. package/dist/web-client/assets/{chunk-WYO6CB5R-By1K0guW.js → chunk-WYO6CB5R-n7FhYrli.js} +1 -1
  62. package/dist/web-client/assets/{chunk-ZGVPDNZ5-Vnzpc76F.js → chunk-ZGVPDNZ5-DU1Jcue0.js} +1 -1
  63. package/dist/web-client/assets/classDiagram-OUVF2IWQ-BG5uYDvC.js +1 -0
  64. package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-BG5uYDvC.js +1 -0
  65. package/dist/web-client/assets/{cynefin-VYW2F7L2-C6MOMOOz.js → cynefin-VYW2F7L2-D-gD9LCO.js} +1 -1
  66. package/dist/web-client/assets/{cynefinDiagram-TSTJHNR4-Cseyu79b.js → cynefinDiagram-TSTJHNR4-BFGkcrNl.js} +1 -1
  67. package/dist/web-client/assets/{dagre-VKFMJZFB-CQJlIuNh.js → dagre-VKFMJZFB-BUaC6fks.js} +1 -1
  68. package/dist/web-client/assets/{diagram-FQU43EPY-DpqDxhq4.js → diagram-FQU43EPY-Ckgu02XC.js} +1 -1
  69. package/dist/web-client/assets/{diagram-G47NLZAW-C11fYYaF.js → diagram-G47NLZAW-BickHhmE.js} +1 -1
  70. package/dist/web-client/assets/{diagram-NH7WQ7WH-BiG-uRAF.js → diagram-NH7WQ7WH-CoqgJpcU.js} +1 -1
  71. package/dist/web-client/assets/{diagram-OA4YK3LP-CIXWWjq-.js → diagram-OA4YK3LP-CTSZWqCe.js} +1 -1
  72. package/dist/web-client/assets/{diagram-WEI45ONY-C3OgIWu9.js → diagram-WEI45ONY-D7xd1naU.js} +1 -1
  73. package/dist/web-client/assets/{ebnfDiagram-CCIWWBDH-BHF_NA3_.js → ebnfDiagram-CCIWWBDH-FESKYpKQ.js} +1 -1
  74. package/dist/web-client/assets/{erDiagram-Q63AITRT-CUOJCrLy.js → erDiagram-Q63AITRT-C-879ftU.js} +1 -1
  75. package/dist/web-client/assets/eventmodeling-45OFAUF4-C3kRZCei.js +1 -0
  76. package/dist/web-client/assets/flowDiagram-23GEKE2U-QKWFycdH.js +1 -0
  77. package/dist/web-client/assets/{ganttDiagram-NO4QXBWP-D_4BMJ-g.js → ganttDiagram-NO4QXBWP-mIX5nPpv.js} +1 -1
  78. package/dist/web-client/assets/{gitGraph-TEB2WS4Q-CBuaZBId.js → gitGraph-TEB2WS4Q-DlEDJ1IK.js} +1 -1
  79. package/dist/web-client/assets/{gitGraphDiagram-IHSO6WYX-BbztsGuO.js → gitGraphDiagram-IHSO6WYX-D2fXD4AO.js} +1 -1
  80. package/dist/web-client/assets/index-BsYR-C8u.js +61 -0
  81. package/dist/web-client/assets/index-Cc5DCTOr.css +2 -0
  82. package/dist/web-client/assets/{info-DKCQHKI2-Bzi0Xjro.js → info-DKCQHKI2-jinbCROt.js} +1 -1
  83. package/dist/web-client/assets/{infoDiagram-FWYZ7A6U-BkeEKcr0.js → infoDiagram-FWYZ7A6U-CM-A4TL8.js} +1 -1
  84. package/dist/web-client/assets/{ishikawaDiagram-FXEZZL3T-CVusWu0p.js → ishikawaDiagram-FXEZZL3T-BdFpHwp4.js} +1 -1
  85. package/dist/web-client/assets/{journeyDiagram-5HDEW3XC-BUxu71zw.js → journeyDiagram-5HDEW3XC-BwDWz25z.js} +1 -1
  86. package/dist/web-client/assets/{kanban-definition-HUTT4EX6-BE8Hv4Kd.js → kanban-definition-HUTT4EX6-_2QXjSvx.js} +1 -1
  87. package/dist/web-client/assets/{line-CiAoINJS.js → line-HCziev87.js} +1 -1
  88. package/dist/web-client/assets/{mermaid-parser.core-BW47khiS.js → mermaid-parser.core-C4139mkz.js} +3 -3
  89. package/dist/web-client/assets/{mermaid.core-DP--Jl9R.js → mermaid.core-ocHwkZiF.js} +3 -3
  90. package/dist/web-client/assets/{mindmap-definition-LN4V7U3C-CvWJUqdq.js → mindmap-definition-LN4V7U3C-DHyCmflp.js} +1 -1
  91. package/dist/web-client/assets/{packet-7NZHBO7P-XtzX9SaQ.js → packet-7NZHBO7P-Bk0w_irl.js} +1 -1
  92. package/dist/web-client/assets/{pegDiagram-2B236MQR-XNo0K1ct.js → pegDiagram-2B236MQR-D_GrmL93.js} +1 -1
  93. package/dist/web-client/assets/{pie-RZYD4A2V-B1eUd9yt.js → pie-RZYD4A2V-B6VIAbyj.js} +1 -1
  94. package/dist/web-client/assets/{pieDiagram-ENE6RG2P-CRSb5z-4.js → pieDiagram-ENE6RG2P-CiR2Y8y2.js} +1 -1
  95. package/dist/web-client/assets/{quadrantDiagram-ABIIQ3AL-DziMaQxE.js → quadrantDiagram-ABIIQ3AL-CLlicKmV.js} +1 -1
  96. package/dist/web-client/assets/{radar-I7S5WNFK-BpRqZH2g.js → radar-I7S5WNFK-B53XVRSZ.js} +1 -1
  97. package/dist/web-client/assets/{railroad-3IZDKUUU-FDIHW04k.js → railroad-3IZDKUUU-Dn8TN2M-.js} +1 -1
  98. package/dist/web-client/assets/railroad-abnf-AHOZXSZD-UrOKy6yx.js +1 -0
  99. package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-Bx73sfMZ.js +1 -0
  100. package/dist/web-client/assets/railroad-peg-LSFZ7HO6-C4QEje-y.js +1 -0
  101. package/dist/web-client/assets/{railroadDiagram-RFXS5EU6-Cs7EVBaz.js → railroadDiagram-RFXS5EU6-C6kZwOio.js} +1 -1
  102. package/dist/web-client/assets/{requirementDiagram-TGXJPOKE-B9_Lj1ga.js → requirementDiagram-TGXJPOKE-D2b1MeP8.js} +1 -1
  103. package/dist/web-client/assets/{sankeyDiagram-HTMAVEWB-DuTJTiy0.js → sankeyDiagram-HTMAVEWB-pS_Ufq1E.js} +1 -1
  104. package/dist/web-client/assets/{sequenceDiagram-DBY2YBRQ-vOCU5UoE.js → sequenceDiagram-DBY2YBRQ-BrsKXNzu.js} +1 -1
  105. package/dist/web-client/assets/{stateDiagram-2N3HPSRC-rxAnfzWn.js → stateDiagram-2N3HPSRC-BuQKu3i0.js} +1 -1
  106. package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-BhqgaxDu.js +1 -0
  107. package/dist/web-client/assets/{swimlanes-5IMT3BWC-gdVZUnPe.js → swimlanes-5IMT3BWC-CzoImfTA.js} +1 -1
  108. package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-CHOEI9hI.js +8 -0
  109. package/dist/web-client/assets/{timeline-definition-FHXFAJF6-Do68JyGy.js → timeline-definition-FHXFAJF6-DvezW7Fr.js} +1 -1
  110. package/dist/web-client/assets/{treeView-QDETBFTQ-CdZkmg80.js → treeView-QDETBFTQ-xu58R4kS.js} +1 -1
  111. package/dist/web-client/assets/{treemap-6X3UGDF4-ub54fmpW.js → treemap-6X3UGDF4-DMYBAOW1.js} +1 -1
  112. package/dist/web-client/assets/{vennDiagram-L72KCM5P-D-r8NBEp.js → vennDiagram-L72KCM5P-l1CbC7Ig.js} +1 -1
  113. package/dist/web-client/assets/{wardley-OPB4EBWU-CTleH1-J.js → wardley-OPB4EBWU-G3zmnnM0.js} +1 -1
  114. package/dist/web-client/assets/{wardleyDiagram-EHGQE667-BU8Kw39F.js → wardleyDiagram-EHGQE667-Dl2I79Vx.js} +1 -1
  115. package/dist/web-client/assets/{xychartDiagram-FW5EYKEG-CfNnag6i.js → xychartDiagram-FW5EYKEG-BWfge21N.js} +1 -1
  116. package/dist/web-client/index.html +2 -2
  117. package/dist/web-server/app.d.ts.map +1 -1
  118. package/dist/web-server/app.js +13 -7
  119. package/dist/web-server/app.js.map +1 -1
  120. package/dist/web-server/ask-prompt.d.ts +70 -0
  121. package/dist/web-server/ask-prompt.d.ts.map +1 -0
  122. package/dist/web-server/ask-prompt.js +238 -0
  123. package/dist/web-server/ask-prompt.js.map +1 -0
  124. package/dist/web-server/index.d.ts.map +1 -1
  125. package/dist/web-server/index.js +56 -3
  126. package/dist/web-server/index.js.map +1 -1
  127. package/dist/web-server/respond.d.ts.map +1 -1
  128. package/dist/web-server/respond.js +6 -1
  129. package/dist/web-server/respond.js.map +1 -1
  130. package/dist/web-server/routes/ask.d.ts +29 -0
  131. package/dist/web-server/routes/ask.d.ts.map +1 -0
  132. package/dist/web-server/routes/ask.js +698 -0
  133. package/dist/web-server/routes/ask.js.map +1 -0
  134. package/dist/web-server/routes/changes.d.ts +35 -0
  135. package/dist/web-server/routes/changes.d.ts.map +1 -0
  136. package/dist/web-server/routes/changes.js +93 -0
  137. package/dist/web-server/routes/changes.js.map +1 -0
  138. package/dist/web-server/routes/projects.d.ts.map +1 -1
  139. package/dist/web-server/routes/projects.js +10 -2
  140. package/dist/web-server/routes/projects.js.map +1 -1
  141. package/dist/web-server/routes/proposal-docs.d.ts +18 -0
  142. package/dist/web-server/routes/proposal-docs.d.ts.map +1 -0
  143. package/dist/web-server/routes/proposal-docs.js +208 -0
  144. package/dist/web-server/routes/proposal-docs.js.map +1 -0
  145. package/dist/web-server/routes/runners.d.ts +15 -0
  146. package/dist/web-server/routes/runners.d.ts.map +1 -0
  147. package/dist/web-server/routes/runners.js +66 -0
  148. package/dist/web-server/routes/runners.js.map +1 -0
  149. package/dist/web-server/routes/workspace.d.ts +2 -2
  150. package/dist/web-server/routes/workspace.js +4 -4
  151. package/dist/web-server/run-diff.d.ts +188 -0
  152. package/dist/web-server/run-diff.d.ts.map +1 -0
  153. package/dist/web-server/run-diff.js +739 -0
  154. package/dist/web-server/run-diff.js.map +1 -0
  155. package/dist/web-server/run-driver.d.ts +102 -2
  156. package/dist/web-server/run-driver.d.ts.map +1 -1
  157. package/dist/web-server/run-driver.js +495 -1
  158. package/dist/web-server/run-driver.js.map +1 -1
  159. package/dist/web-server/run-event-log.d.ts +13 -6
  160. package/dist/web-server/run-event-log.d.ts.map +1 -1
  161. package/dist/web-server/run-event-log.js +70 -25
  162. package/dist/web-server/run-event-log.js.map +1 -1
  163. package/dist/web-server/run-store.d.ts +201 -0
  164. package/dist/web-server/run-store.d.ts.map +1 -0
  165. package/dist/web-server/run-store.js +304 -0
  166. package/dist/web-server/run-store.js.map +1 -0
  167. package/dist/web-server/watcher.d.ts.map +1 -1
  168. package/dist/web-server/watcher.js +4 -0
  169. package/dist/web-server/watcher.js.map +1 -1
  170. package/opencode/arcs/manifest.json +8 -8
  171. package/opencode/arcs/prompts/arcs-orchestrate-caveman.txt +1 -1
  172. package/opencode/arcs/prompts/arcs-orchestrate.txt +1 -1
  173. package/opencode/arcs/skills/writing-proposals/SKILL.md +6 -3
  174. package/package.json +1 -1
  175. package/scripts/deploy-claudecode-bundle.mjs +2 -2
  176. package/scripts/deploy-opencode-bundle.mjs +1 -1
  177. package/scripts/deploy-pi-bundle.mjs +468 -0
  178. package/scripts/lint-bundle.mjs +1 -1
  179. package/dist/shared/session-vocabulary.d.ts +0 -88
  180. package/dist/shared/session-vocabulary.d.ts.map +0 -1
  181. package/dist/shared/session-vocabulary.js +0 -114
  182. package/dist/shared/session-vocabulary.js.map +0 -1
  183. package/dist/utils/claude-transcript.d.ts +0 -206
  184. package/dist/utils/claude-transcript.d.ts.map +0 -1
  185. package/dist/utils/claude-transcript.js +0 -384
  186. package/dist/utils/claude-transcript.js.map +0 -1
  187. package/dist/utils/session-store.d.ts +0 -313
  188. package/dist/utils/session-store.d.ts.map +0 -1
  189. package/dist/utils/session-store.js +0 -513
  190. package/dist/utils/session-store.js.map +0 -1
  191. package/dist/web-client/assets/architecture-TIHT7OUA-CIdGE6VO.js +0 -1
  192. package/dist/web-client/assets/channel-CBg-s-ZD.js +0 -1
  193. package/dist/web-client/assets/classDiagram-OUVF2IWQ-ciwDjhUR.js +0 -1
  194. package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-ciwDjhUR.js +0 -1
  195. package/dist/web-client/assets/eventmodeling-45OFAUF4-DxfboL1J.js +0 -1
  196. package/dist/web-client/assets/flowDiagram-23GEKE2U-C3NzpTtI.js +0 -1
  197. package/dist/web-client/assets/index-B1KVIr80.css +0 -2
  198. package/dist/web-client/assets/index-BWd2fBNL.js +0 -60
  199. package/dist/web-client/assets/railroad-abnf-AHOZXSZD-BKj6JAhH.js +0 -1
  200. package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-ChDM1OBv.js +0 -1
  201. package/dist/web-client/assets/railroad-peg-LSFZ7HO6-DvwA2e0i.js +0 -1
  202. package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-B3EvQPEe.js +0 -1
  203. package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-WcOXuCPg.js +0 -8
  204. package/dist/web-server/permission-policy.d.ts +0 -46
  205. package/dist/web-server/permission-policy.d.ts.map +0 -1
  206. package/dist/web-server/permission-policy.js +0 -96
  207. package/dist/web-server/permission-policy.js.map +0 -1
  208. package/dist/web-server/prompt-assembly.d.ts +0 -454
  209. package/dist/web-server/prompt-assembly.d.ts.map +0 -1
  210. package/dist/web-server/prompt-assembly.js +0 -1121
  211. package/dist/web-server/prompt-assembly.js.map +0 -1
  212. package/dist/web-server/routes/sessions.d.ts +0 -19
  213. package/dist/web-server/routes/sessions.d.ts.map +0 -1
  214. package/dist/web-server/routes/sessions.js +0 -1296
  215. package/dist/web-server/routes/sessions.js.map +0 -1
  216. package/dist/web-server/session-reconciler.d.ts +0 -162
  217. package/dist/web-server/session-reconciler.d.ts.map +0 -1
  218. package/dist/web-server/session-reconciler.js +0 -363
  219. package/dist/web-server/session-reconciler.js.map +0 -1
@@ -0,0 +1,739 @@
1
+ /**
2
+ * Workspace diff snapshot + approve/revert review surface for Ask-AI runs.
3
+ *
4
+ * The allow-all gate: a run may change the workspace, the panel shows the
5
+ * diff, and the user approves (keeps) or rejects (reverts). Three layers:
6
+ *
7
+ * - SNAPSHOT — captured in the ask route's spawn path, before the claim is
8
+ * taken and before the child can write. This is the baseline every later
9
+ * diff and revert computes against.
10
+ * - DIFF — computed in the settle write-back, comparing the workspace to the
11
+ * snapshot, and persisted as `changes.json` beside the run's event log.
12
+ * - REVERT — restores the workspace to the snapshot baseline, scoped to this
13
+ * run's own changes list, and marks the run record so a second revert is
14
+ * refused.
15
+ *
16
+ * Two workspace modes:
17
+ *
18
+ * - GIT — the snapshot records `{ mode, head, cwd, untracked[] }` and
19
+ * NOTHING else. Diffs and reverts compute against git at review time
20
+ * (`git diff HEAD`, `git restore`), because git already holds the exact
21
+ * baseline — copying file contents would be redundant with the object DB.
22
+ * `untracked` lists the paths that were already untracked at snapshot time:
23
+ * the run is never credited with (and revert never deletes) a file that
24
+ * merely remained untracked across the run. State the degradation plainly:
25
+ * an untracked file that EXISTED before the run and was then edited by it
26
+ * is invisible to this surface — git has no baseline for it, by design.
27
+ * - TREE (non-git) — the snapshot walks the tree and records a hash+size
28
+ * manifest. A pure manifest can answer modified/added/deleted but cannot
29
+ * render a modified file's old bytes, so the snapshot ALSO stores each
30
+ * file's bytes up to a total blob cap (RUN_SNAPSHOT_BLOB_MAX_BYTES). A
31
+ * file whose baseline bytes were refused by the cap reports `diff: null`
32
+ * with `capped: true` and cannot be reverted.
33
+ *
34
+ * Every failure here DEGRADES the review surface rather than failing the turn
35
+ * or the settle: an unreadable workspace, a wedged git, a refused blob — all
36
+ * record on the snapshot/change file and the client sees an empty changes
37
+ * list. The turn itself already happened.
38
+ *
39
+ * File keying follows run-event-log.ts exactly: `sessions/{slug}.run-{seg}`
40
+ * plus a `.snapshot.json` / `.changes.json` suffix, pruned together by
41
+ * `pruneRunEventLogs` under the same retention.
42
+ */
43
+ import { spawnSync } from "node:child_process";
44
+ import { createHash } from "node:crypto";
45
+ import { rmSync } from "node:fs";
46
+ import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
47
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
48
+ import { readJsonSafe } from "../utils/json.js";
49
+ import { normalizeIdentifier } from "../utils/slug.js";
50
+ import { ensureDir, writeJson } from "../utils/storage-utils.js";
51
+ import { runEventLogSegment } from "./run-event-log.js";
52
+ // ---------------------------------------------------------------------------
53
+ // Budgets — every number a walk, diff or blob capture may touch
54
+ // ---------------------------------------------------------------------------
55
+ /** Ceiling on files a non-git snapshot walk records. Past it the walk stops
56
+ * (sorted order) and the rest of the tree is NOT in the manifest — a
57
+ * documented truncation: files that sort after the cap compare as phantom
58
+ * adds/deletes. */
59
+ export const RUN_SNAPSHOT_MAX_FILES = 10_000;
60
+ /** Ceiling on levels below the workspace root a walk descends. */
61
+ export const RUN_SNAPSHOT_MAX_DEPTH = 12;
62
+ /** Ceiling on total baseline bytes a snapshot may keep for later diffs. A
63
+ * modified/deleted file whose bytes were refused CANNOT render a diff or be
64
+ * reverted — it reports `capped: true` instead. */
65
+ export const RUN_SNAPSHOT_BLOB_MAX_BYTES = 20 * 1024 * 1024;
66
+ /** Ceiling on one change's diff text — the first N lines AND the first N
67
+ * bytes, whichever lands first. Line counts are always parsed from the FULL
68
+ * diff before the cap is applied. */
69
+ export const RUN_DIFF_MAX_LINES = 300;
70
+ export const RUN_DIFF_MAX_BYTES = 12 * 1024;
71
+ /** Ceiling on one spawnSync git call. A wedged git (stalled mount, held
72
+ * index.lock) must degrade the surface, never hang the request. */
73
+ export const RUN_GIT_TIMEOUT_MS = 5_000;
74
+ /** Directories a walk skips wholesale. Not a security control — the walk
75
+ * never leaves the root — but each of these would eat the entry cap alone. */
76
+ const WALK_SKIP_DIRECTORIES = new Set([".git", ".codegraph", "node_modules"]);
77
+ /**
78
+ * One git call against `root`. `spawnSync` with a hard timeout: sync git on
79
+ * the server is a deliberate trade, because the snapshot is captured inline in
80
+ * the spawn path and the diff/revert run inline in the settle/review path —
81
+ * each is a handful of quick calls, and a wedged git must fail closed to
82
+ * "degrade the surface", not hang the request (that is what the timeout is
83
+ * for).
84
+ */
85
+ function runGit(root, args, timeoutMs = RUN_GIT_TIMEOUT_MS) {
86
+ try {
87
+ const proc = spawnSync("git", ["-C", root, ...args], {
88
+ encoding: "utf-8",
89
+ timeout: timeoutMs,
90
+ killSignal: "SIGKILL",
91
+ windowsHide: true,
92
+ stdio: ["ignore", "pipe", "pipe"],
93
+ });
94
+ return {
95
+ failed: proc.error !== undefined || proc.status !== 0,
96
+ stdout: typeof proc.stdout === "string" ? proc.stdout : "",
97
+ };
98
+ }
99
+ catch {
100
+ return { failed: true, stdout: "" };
101
+ }
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // Containment — nothing outside the snapshot's root
105
+ // ---------------------------------------------------------------------------
106
+ /**
107
+ * Segment-aware containment of a root-relative path (exactly the rule the
108
+ * workspace route uses): `relative()` alone is not enough — a `startsWith` on
109
+ * its output rejects a legitimate file literally named `..foo`, and a
110
+ * `startsWith` on the raw paths accepts `/repo-secrets` for root `/repo`.
111
+ * Returns the absolute path, or null when the path escapes the root. Every
112
+ * read, diff, revert and delete the surface performs goes through this.
113
+ */
114
+ function resolveInside(root, relPath) {
115
+ if (relPath === "" || isAbsolute(relPath))
116
+ return null;
117
+ const abs = resolve(root, relPath);
118
+ const rel = relative(root, abs);
119
+ if (rel === "" || isAbsolute(rel) || rel.split(sep).includes(".."))
120
+ return null;
121
+ return abs;
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Snapshot capture
125
+ // ---------------------------------------------------------------------------
126
+ /**
127
+ * Total by contract: NEVER throws. Every failure mode records `error` on the
128
+ * snapshot, and an errored snapshot skips the review surface for its run
129
+ * (changes: [], revert no-ops) — a baseline that cannot be computed must not
130
+ * fail the accepted turn.
131
+ */
132
+ export async function captureWorkspaceSnapshot(root, options = {}) {
133
+ // Resolve the root FIRST — the snapshot, diff and revert must all agree on
134
+ // the canonical path, and a root that reaches through a symlink must not
135
+ // contain a different set of files than the CLI thinks it does.
136
+ const cwd = await realpath(root).catch(() => null);
137
+ if (cwd === null) {
138
+ return {
139
+ mode: "tree",
140
+ cwd: root,
141
+ files: {},
142
+ blobs: {},
143
+ walk: walkCaps(options),
144
+ error: `workspace "${root}" is not readable on this machine`,
145
+ };
146
+ }
147
+ // Git detection: `git rev-parse --git-dir` succeeding from the workspace
148
+ // root (a `.git` dir OR file — worktrees and submodules included — "at or
149
+ // near root"). Its failure is the non-git branch, not an error.
150
+ if (!runGit(cwd, ["rev-parse", "--git-dir"]).failed) {
151
+ const head = runGit(cwd, ["rev-parse", "HEAD"]);
152
+ if (head.failed) {
153
+ return {
154
+ mode: "git",
155
+ cwd,
156
+ head: "",
157
+ untracked: [],
158
+ error: "git HEAD is not readable (empty repository? stash?) — the run has no baseline to diff against",
159
+ };
160
+ }
161
+ const untracked = runGit(cwd, ["ls-files", "--others", "--exclude-standard"]);
162
+ return {
163
+ mode: "git",
164
+ cwd,
165
+ head: head.stdout.trim(),
166
+ untracked: untracked.failed ? [] : untracked.stdout.split("\n").filter((line) => line !== ""),
167
+ };
168
+ }
169
+ const walked = await walkWorkspace(cwd, options);
170
+ const snapshot = {
171
+ mode: "tree",
172
+ cwd,
173
+ files: walked.files,
174
+ blobs: walked.blobs,
175
+ walk: walked.walk,
176
+ ...(walked.blobCapped && { blobCapped: true }),
177
+ ...(walked.error !== undefined && { error: walked.error }),
178
+ };
179
+ return snapshot;
180
+ }
181
+ function walkCaps(options) {
182
+ return {
183
+ maxFiles: options.maxFiles ?? RUN_SNAPSHOT_MAX_FILES,
184
+ maxDepth: options.maxDepth ?? RUN_SNAPSHOT_MAX_DEPTH,
185
+ };
186
+ }
187
+ /**
188
+ * Walks the workspace tree: every file's size + sha256, plus baseline bytes
189
+ * (base64) up to the blob cap. Deterministic — sorted entry order, so the
190
+ * file cap cuts the same set on every walk of the same tree.
191
+ */
192
+ async function walkWorkspace(root, options) {
193
+ const maxFiles = options.maxFiles ?? RUN_SNAPSHOT_MAX_FILES;
194
+ const maxDepth = options.maxDepth ?? RUN_SNAPSHOT_MAX_DEPTH;
195
+ const blobMaxBytes = options.blobMaxBytes ?? RUN_SNAPSHOT_BLOB_MAX_BYTES;
196
+ const files = {};
197
+ const blobs = {};
198
+ let fileCount = 0;
199
+ let blobBytes = 0;
200
+ let blobCapped = false;
201
+ let hitFileCap = false;
202
+ // The walk probes the root listing up front so an unreadable root is an
203
+ // ERROR on the snapshot, not a silent empty manifest (an empty baseline
204
+ // would diff every surviving file as "added" — wrong in the dangerous
205
+ // direction).
206
+ try {
207
+ await readdir(root, { withFileTypes: true });
208
+ }
209
+ catch (err) {
210
+ return {
211
+ files: {},
212
+ blobs: {},
213
+ walk: { maxFiles, maxDepth },
214
+ blobCapped: false,
215
+ truncated: false,
216
+ error: `workspace "${root}" is not readable: ${String(err)}`,
217
+ };
218
+ }
219
+ const walk = async (dir, rel, depth) => {
220
+ if (depth > maxDepth)
221
+ return;
222
+ let entries;
223
+ try {
224
+ entries = await readdir(dir, { withFileTypes: true });
225
+ }
226
+ catch {
227
+ return; // an unreadable subdirectory drops out of the manifest
228
+ }
229
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
230
+ for (const entry of entries) {
231
+ if (fileCount >= maxFiles) {
232
+ hitFileCap = true;
233
+ return;
234
+ }
235
+ if (WALK_SKIP_DIRECTORIES.has(entry.name))
236
+ continue;
237
+ // Symlinks are skipped ENTIRELY: following one could step outside the
238
+ // root or into a cycle, and "never touch outside the root" is cheaper
239
+ // to keep absolute than to carve symlink exceptions. A workspace that
240
+ // leans on symlinked files simply has a smaller review surface.
241
+ if (entry.isSymbolicLink())
242
+ continue;
243
+ const abs = join(dir, entry.name);
244
+ const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
245
+ if (entry.isDirectory()) {
246
+ await walk(abs, relPath, depth + 1);
247
+ continue;
248
+ }
249
+ if (!entry.isFile())
250
+ continue;
251
+ fileCount += 1;
252
+ let info;
253
+ try {
254
+ info = await stat(abs);
255
+ }
256
+ catch {
257
+ continue;
258
+ }
259
+ if (!info.isFile())
260
+ continue;
261
+ const sha256 = await sha256File(abs);
262
+ if (sha256 === null)
263
+ continue;
264
+ files[relPath] = { size: info.size, sha256 };
265
+ // Baseline bytes, first-come within sorted order, up to the total cap.
266
+ // A file that does NOT fit is refused ITS blob only — `blobCapped`
267
+ // records that at least one file lacks one, and later (smaller) files
268
+ // that still fit keep theirs.
269
+ if (info.size > 0) {
270
+ if (info.size <= blobMaxBytes - blobBytes) {
271
+ try {
272
+ const buf = await readFile(abs);
273
+ blobs[relPath] = buf.toString("base64");
274
+ blobBytes += buf.length;
275
+ }
276
+ catch {
277
+ // Hash recorded, no blob — this file's diff/revert degrades per-file.
278
+ }
279
+ }
280
+ else {
281
+ blobCapped = true;
282
+ }
283
+ }
284
+ }
285
+ };
286
+ await walk(root, "", 0);
287
+ return {
288
+ files,
289
+ blobs,
290
+ walk: { maxFiles, maxDepth },
291
+ blobCapped,
292
+ truncated: hitFileCap,
293
+ };
294
+ }
295
+ async function sha256File(abs) {
296
+ try {
297
+ const buf = await readFile(abs);
298
+ return createHash("sha256").update(buf).digest("hex");
299
+ }
300
+ catch {
301
+ return null;
302
+ }
303
+ }
304
+ // ---------------------------------------------------------------------------
305
+ // Sidecar files — keyed on the run, pruned with the run's event log
306
+ // ---------------------------------------------------------------------------
307
+ const SNAPSHOT_SUFFIX = ".snapshot.json";
308
+ const CHANGES_SUFFIX = ".changes.json";
309
+ function sidecarName(sessionId, runId, suffix) {
310
+ return `${normalizeIdentifier(sessionId)}.run-${runEventLogSegment(runId)}${suffix}`;
311
+ }
312
+ /** The run's baseline snapshot path — a sibling of its event log. */
313
+ export function runSnapshotPath(projectDir, sessionId, runId) {
314
+ return join(projectDir, "sessions", sidecarName(sessionId, runId, SNAPSHOT_SUFFIX));
315
+ }
316
+ /** The run's settle-time changes manifest path — a sibling of its event log. */
317
+ export function runChangesPath(projectDir, sessionId, runId) {
318
+ return join(projectDir, "sessions", sidecarName(sessionId, runId, CHANGES_SUFFIX));
319
+ }
320
+ /** Persists the baseline snapshot. Callers wrap this — a failed persist is a
321
+ * degraded review surface, never a failed turn. */
322
+ export async function persistRunSnapshot(projectDir, sessionId, runId, snapshot) {
323
+ await ensureDir(dirname(runSnapshotPath(projectDir, sessionId, runId)));
324
+ await writeJson(runSnapshotPath(projectDir, sessionId, runId), snapshot);
325
+ }
326
+ export async function readRunSnapshot(projectDir, sessionId, runId) {
327
+ return readJsonSafe(runSnapshotPath(projectDir, sessionId, runId));
328
+ }
329
+ export async function readRunChanges(projectDir, sessionId, runId) {
330
+ return readJsonSafe(runChangesPath(projectDir, sessionId, runId));
331
+ }
332
+ // ---------------------------------------------------------------------------
333
+ // Diff — the settle-time comparison against the snapshot
334
+ // ---------------------------------------------------------------------------
335
+ /**
336
+ * What the workspace changed since the snapshot.
337
+ *
338
+ * Total by contract (never throws): the git-status failure path returns an
339
+ * empty list — a workspace whose git broke mid-run degrades to "no changes"
340
+ * rather than failing the settle. Every path it returns is root-relative, so
341
+ * downstream reads, diffs and reverts all go through `resolveInside`.
342
+ */
343
+ export async function computeWorkspaceChanges(snapshot) {
344
+ if (snapshot.error !== undefined)
345
+ return [];
346
+ return snapshot.mode === "git"
347
+ ? await gitWorkspaceChanges(snapshot)
348
+ : await treeWorkspaceChanges(snapshot);
349
+ }
350
+ /**
351
+ * Splits `git status --porcelain=v1` lines into entries. Handles the
352
+ * C-quoted paths porcelain emits for spaces/unicode (`"a\tb"`), via a small
353
+ * unquoter matching git's core.quotePath escapes.
354
+ */
355
+ function parseGitStatus(stdout) {
356
+ const entries = [];
357
+ for (const line of stdout.split("\n")) {
358
+ if (line.length < 4)
359
+ continue;
360
+ const index = line[0];
361
+ const worktree = line[1];
362
+ const rawPath = line.slice(3);
363
+ if (rawPath === "")
364
+ continue;
365
+ const arrow = rawPath.indexOf(" -> ");
366
+ const entry = {
367
+ index,
368
+ worktree,
369
+ path: unquoteGitPath(arrow === -1 ? rawPath : rawPath.slice(arrow + 4)),
370
+ ...(arrow !== -1 && { oldPath: unquoteGitPath(rawPath.slice(0, arrow)) }),
371
+ };
372
+ entries.push(entry);
373
+ }
374
+ return entries;
375
+ }
376
+ function unquoteGitPath(raw) {
377
+ if (!raw.startsWith('"'))
378
+ return raw;
379
+ let out = "";
380
+ for (let i = 1; i < raw.length - 1; i++) {
381
+ const ch = raw[i];
382
+ if (ch !== "\\") {
383
+ out += ch;
384
+ continue;
385
+ }
386
+ const next = raw[i + 1];
387
+ if (next === undefined)
388
+ break;
389
+ const simple = {
390
+ a: "\x07",
391
+ b: "\b",
392
+ t: "\t",
393
+ n: "\n",
394
+ v: "\v",
395
+ f: "\f",
396
+ r: "\r",
397
+ '"': '"',
398
+ "\\": "\\",
399
+ };
400
+ if (simple[next] !== undefined) {
401
+ out += simple[next];
402
+ i += 1;
403
+ continue;
404
+ }
405
+ // Octal escape `\ooo` (non-ASCII paths).
406
+ if (/^[0-7]/.test(next)) {
407
+ const octal = raw.slice(i + 1, i + 4);
408
+ out += String.fromCharCode(Number.parseInt(octal, 8));
409
+ i += 3;
410
+ continue;
411
+ }
412
+ out += next;
413
+ i += 1;
414
+ }
415
+ return out;
416
+ }
417
+ async function gitWorkspaceChanges(snapshot) {
418
+ const status = runGit(snapshot.cwd, ["status", "--porcelain=v1"]);
419
+ if (status.failed)
420
+ return [];
421
+ const preexistingUntracked = new Set(snapshot.untracked);
422
+ const changes = [];
423
+ for (const entry of parseGitStatus(status.stdout)) {
424
+ // Untracked (`??`). A path that was ALREADY untracked at snapshot time is
425
+ // the baseline, not a run change — git has no baseline for it, so the run
426
+ // is neither credited with it nor blamed for it.
427
+ if (entry.index === "?") {
428
+ if (preexistingUntracked.has(entry.path))
429
+ continue;
430
+ changes.push(await trackedChange(snapshot, "added", entry.path, true));
431
+ continue;
432
+ }
433
+ // A rename decomposes into a deletion + an addition — the two half-diffs
434
+ // are each revertable, and together they undo the move exactly.
435
+ if (entry.oldPath !== undefined && (entry.index === "R" || entry.worktree === "R")) {
436
+ changes.push(await trackedChange(snapshot, "deleted", entry.oldPath, false));
437
+ changes.push(await trackedChange(snapshot, "added", entry.path, false));
438
+ continue;
439
+ }
440
+ // A copy is an addition at the new path; the source is untouched.
441
+ if (entry.index === "C" || entry.worktree === "C") {
442
+ changes.push(await trackedChange(snapshot, "added", entry.path, false));
443
+ continue;
444
+ }
445
+ if (entry.index === "D" || entry.worktree === "D") {
446
+ changes.push(await trackedChange(snapshot, "deleted", entry.path, false));
447
+ }
448
+ else if (entry.index === "A" || entry.worktree === "A") {
449
+ changes.push(await trackedChange(snapshot, "added", entry.path, false));
450
+ }
451
+ else if (entry.index === "M" ||
452
+ entry.worktree === "M" ||
453
+ entry.index === "U" ||
454
+ entry.worktree === "U") {
455
+ changes.push(await trackedChange(snapshot, "modified", entry.path, false));
456
+ }
457
+ }
458
+ return changes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
459
+ }
460
+ /**
461
+ * One change's diff for git mode. Tracked paths diff with `git diff HEAD --`
462
+ * (which covers index AND worktree changes against HEAD in one call, staged
463
+ * counts included); only a settle-time-untracked file is synthesized by hand
464
+ * (`+N `-numbered lines — git has nothing to diff it against).
465
+ */
466
+ async function trackedChange(snapshot, status, path, untracked) {
467
+ if (status === "added" && untracked) {
468
+ const rendered = await untrackedFileDiff(resolveInside(snapshot.cwd, path));
469
+ return {
470
+ path,
471
+ status,
472
+ linesAdded: rendered?.linesAdded ?? 0,
473
+ linesRemoved: 0,
474
+ diff: rendered?.diff ?? null,
475
+ };
476
+ }
477
+ const out = runGit(snapshot.cwd, ["diff", "HEAD", "--", path]);
478
+ if (out.failed) {
479
+ return { path, status, linesAdded: 0, linesRemoved: 0, diff: null };
480
+ }
481
+ const counts = countDiffLines(out.stdout);
482
+ return { path, status, ...counts, diff: capDiff(out.stdout) };
483
+ }
484
+ async function treeWorkspaceChanges(snapshot) {
485
+ const walked = await walkWorkspace(snapshot.cwd, {
486
+ maxFiles: snapshot.walk.maxFiles,
487
+ maxDepth: snapshot.walk.maxDepth,
488
+ });
489
+ const changes = [];
490
+ const paths = new Set([...Object.keys(snapshot.files), ...Object.keys(walked.files)]);
491
+ for (const path of [...paths].sort()) {
492
+ const was = snapshot.files[path];
493
+ const now = walked.files[path];
494
+ if (was !== undefined && now !== undefined) {
495
+ // Size differs, or size matches but the content hash does not.
496
+ if (was.size === now.size && was.sha256 === now.sha256)
497
+ continue;
498
+ const rendered = await treeModifiedDiff(snapshot, path);
499
+ changes.push({
500
+ path,
501
+ status: "modified",
502
+ linesAdded: rendered?.added ?? 0,
503
+ linesRemoved: rendered?.removed ?? 0,
504
+ diff: rendered?.diff ?? null,
505
+ ...(rendered === null && { capped: true }),
506
+ });
507
+ }
508
+ else if (was !== undefined) {
509
+ const blob = snapshot.blobs[path];
510
+ if (blob === undefined) {
511
+ // Deleted beyond the blob cap: no baseline bytes, no diff, no revert.
512
+ changes.push({
513
+ path,
514
+ status: "deleted",
515
+ linesAdded: 0,
516
+ linesRemoved: 0,
517
+ diff: null,
518
+ capped: true,
519
+ });
520
+ continue;
521
+ }
522
+ const lines = splitLines(Buffer.from(blob, "base64").toString("utf-8"));
523
+ const body = lines.map((line, i) => `-${i + 1} ${line}`).join("\n");
524
+ changes.push({
525
+ path,
526
+ status: "deleted",
527
+ linesAdded: 0,
528
+ linesRemoved: lines.length,
529
+ diff: capDiff(body),
530
+ });
531
+ }
532
+ else {
533
+ const rendered = await untrackedFileDiff(resolveInside(snapshot.cwd, path));
534
+ changes.push({
535
+ path,
536
+ status: "added",
537
+ linesAdded: rendered?.linesAdded ?? 0,
538
+ linesRemoved: 0,
539
+ diff: rendered?.diff ?? null,
540
+ });
541
+ }
542
+ }
543
+ return changes;
544
+ }
545
+ /**
546
+ * Non-git modified-file diff: the snapshot's baseline bytes vs the file's
547
+ * current bytes, rendered as numbered `-` old lines then numbered `+` new
548
+ * lines. Not a line-aligned LCS diff — an honest whole-file before/after is
549
+ * the degradation contract (and both halves are capped anyway).
550
+ */
551
+ async function treeModifiedDiff(snapshot, path) {
552
+ const blob = snapshot.blobs[path];
553
+ if (blob === undefined)
554
+ return null;
555
+ const abs = resolveInside(snapshot.cwd, path);
556
+ if (abs === null)
557
+ return null;
558
+ let newText;
559
+ try {
560
+ newText = await readFile(abs, "utf-8");
561
+ }
562
+ catch {
563
+ return null;
564
+ }
565
+ const oldLines = splitLines(Buffer.from(blob, "base64").toString("utf-8"));
566
+ const newLines = splitLines(newText);
567
+ const body = [
568
+ ...oldLines.map((line, i) => `-${i + 1} ${line}`),
569
+ ...newLines.map((line, i) => `+${i + 1} ${line}`),
570
+ ].join("\n");
571
+ return { diff: capDiff(body), added: newLines.length, removed: oldLines.length };
572
+ }
573
+ /** The settle-time-untracked file's whole content, `+N`-numbered. */
574
+ async function untrackedFileDiff(absPath) {
575
+ if (absPath === null)
576
+ return null;
577
+ try {
578
+ const text = await readFile(absPath, "utf-8");
579
+ const lines = splitLines(text);
580
+ const body = lines.map((line, i) => `+${i + 1} ${line}`).join("\n");
581
+ return { diff: capDiff(body), linesAdded: lines.length };
582
+ }
583
+ catch {
584
+ return null;
585
+ }
586
+ }
587
+ function splitLines(text) {
588
+ if (text === "")
589
+ return [];
590
+ const lines = text.split("\n");
591
+ if (lines[lines.length - 1] === "")
592
+ lines.pop();
593
+ return lines;
594
+ }
595
+ /**
596
+ * `+`/`-` record-line counts from a FULL diff: `+++`/`---` headers and `@@`
597
+ * hunk headers are markers, not record lines. Used for git's native output
598
+ * and only counts what the diff actually shows for the changed regions.
599
+ */
600
+ function countDiffLines(text) {
601
+ let linesAdded = 0;
602
+ let linesRemoved = 0;
603
+ for (const line of text.split("\n")) {
604
+ if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@"))
605
+ continue;
606
+ if (line.startsWith("+"))
607
+ linesAdded += 1;
608
+ else if (line.startsWith("-"))
609
+ linesRemoved += 1;
610
+ }
611
+ return { linesAdded, linesRemoved };
612
+ }
613
+ /** First RUN_DIFF_MAX_LINES lines, then at most RUN_DIFF_MAX_BYTES bytes. */
614
+ function capDiff(text) {
615
+ const lines = text.split("\n");
616
+ const kept = lines.length > RUN_DIFF_MAX_LINES ? lines.slice(0, RUN_DIFF_MAX_LINES) : lines;
617
+ let out = kept.join("\n");
618
+ if (out.length > RUN_DIFF_MAX_BYTES)
619
+ out = out.slice(0, RUN_DIFF_MAX_BYTES);
620
+ return out;
621
+ }
622
+ // ---------------------------------------------------------------------------
623
+ // Settle-time orchestration — called from the write-back
624
+ // ---------------------------------------------------------------------------
625
+ /**
626
+ * Computes the run's workspace diff and persists it as changes.json beside
627
+ * the run's event log. Never throws: a missing/errored snapshot leaves the
628
+ * changes file absent (GET answers `changes: []`), and a diff failure writes
629
+ * `{ changes: [], error }` so the run still reads as "no changes".
630
+ */
631
+ export async function writeSettledRunChanges(projectDir, sessionId, runId) {
632
+ const snapshot = await readRunSnapshot(projectDir, sessionId, runId);
633
+ if (snapshot === undefined || snapshot.error !== undefined)
634
+ return;
635
+ let changes = [];
636
+ let error;
637
+ try {
638
+ changes = await computeWorkspaceChanges(snapshot);
639
+ }
640
+ catch (err) {
641
+ error = String(err);
642
+ }
643
+ await writeJson(runChangesPath(projectDir, sessionId, runId), {
644
+ runId,
645
+ computedAt: Date.now(),
646
+ changes,
647
+ ...(error !== undefined && { error }),
648
+ });
649
+ }
650
+ // ---------------------------------------------------------------------------
651
+ // Revert — the reject half of the approve/revert surface
652
+ // ---------------------------------------------------------------------------
653
+ /**
654
+ * Restores the workspace to the snapshot baseline for THIS run's changes.
655
+ *
656
+ * Scoped strictly: every path goes through `resolveInside` against the
657
+ * snapshot root, and the paths acted on are exactly those in `changes` (never
658
+ * a bare `git clean`, never a walk). Returns the paths actually restored;
659
+ * un-restorable entries (a modified/deleted file whose baseline bytes were
660
+ * beyond the blob cap) are skipped and NOT reported as restored.
661
+ */
662
+ export async function revertWorkspaceChanges(snapshot, changes) {
663
+ if (snapshot.error !== undefined)
664
+ return [];
665
+ return snapshot.mode === "git" ? gitRevert(snapshot, changes) : treeRevert(snapshot, changes);
666
+ }
667
+ async function gitRevert(snapshot, changes) {
668
+ const restored = [];
669
+ for (const change of changes) {
670
+ if (change.status === "added") {
671
+ // Never delete a path that existed before the run — the settle-time
672
+ // compute already excludes pre-existing untracked paths, so this check
673
+ // is defensive depth against a hand-edited changes file, not the
674
+ // load-bearing guard.
675
+ if (snapshot.untracked.includes(change.path))
676
+ continue;
677
+ const abs = resolveInside(snapshot.cwd, change.path);
678
+ if (abs === null)
679
+ continue;
680
+ try {
681
+ rmSync(abs, { force: true });
682
+ restored.push(change.path);
683
+ }
684
+ catch {
685
+ // Already gone or undeletable — keep going.
686
+ }
687
+ continue;
688
+ }
689
+ // Tracked modified/deleted: restore the worktree (AND index, so staged
690
+ // changes ride along) from HEAD. The bare `git restore -- <path>` form is
691
+ // the fallback for hosts/index states the combined form refuses.
692
+ const primary = runGit(snapshot.cwd, [
693
+ "restore",
694
+ "--source=HEAD",
695
+ "--staged",
696
+ "--worktree",
697
+ "--",
698
+ change.path,
699
+ ]);
700
+ if (primary.failed) {
701
+ const fallback = runGit(snapshot.cwd, ["restore", "--", change.path]);
702
+ if (fallback.failed)
703
+ continue;
704
+ }
705
+ restored.push(change.path);
706
+ }
707
+ return restored;
708
+ }
709
+ async function treeRevert(snapshot, changes) {
710
+ const restored = [];
711
+ for (const change of changes) {
712
+ const abs = resolveInside(snapshot.cwd, change.path);
713
+ if (abs === null)
714
+ continue;
715
+ if (change.status === "added") {
716
+ try {
717
+ await rm(abs, { force: true });
718
+ restored.push(change.path);
719
+ }
720
+ catch {
721
+ // Already gone or undeletable — keep going.
722
+ }
723
+ continue;
724
+ }
725
+ const blob = snapshot.blobs[change.path];
726
+ if (blob === undefined)
727
+ continue; // beyond the blob cap — cannot rebuild
728
+ try {
729
+ await mkdir(dirname(abs), { recursive: true });
730
+ await writeFile(abs, Buffer.from(blob, "base64"));
731
+ restored.push(change.path);
732
+ }
733
+ catch {
734
+ // Unwritable — keep going.
735
+ }
736
+ }
737
+ return restored;
738
+ }
739
+ //# sourceMappingURL=run-diff.js.map