@piagent/platform 1.3.1 → 1.4.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 (301) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +3 -3
  3. package/architecture/layers.json +96 -1
  4. package/docs/README.md +1 -0
  5. package/docs/architecture.md +1 -0
  6. package/docs/authority-manifest.md +1 -0
  7. package/docs/auto-delegation-policy.md +1 -0
  8. package/docs/capability-packs.md +1 -0
  9. package/docs/command-reference-vietnamese.md +5 -4
  10. package/docs/context-engine.md +1 -0
  11. package/docs/context-window-policy.md +1 -0
  12. package/docs/distribution-standard.md +8 -7
  13. package/docs/herdr-workflow.md +1 -0
  14. package/docs/mcp-and-tools.md +14 -9
  15. package/docs/memory-policy.md +1 -0
  16. package/docs/model-options.md +1 -0
  17. package/docs/oauth-providers.md +1 -0
  18. package/docs/operator-manual-vietnamese.md +15 -12
  19. package/docs/package-architecture-notes.md +1 -0
  20. package/docs/project-adapters.md +1 -0
  21. package/docs/project-onboarding.md +1 -0
  22. package/docs/publishing-for-teams.md +1 -0
  23. package/docs/quality-benchmark.md +1 -0
  24. package/docs/quickstart-vietnamese.md +3 -2
  25. package/docs/release-install-policy.md +7 -6
  26. package/docs/runtime-harness-standard.md +1 -0
  27. package/docs/runtime-policy-design.md +1 -0
  28. package/docs/runtime-quality-baseline.md +1 -0
  29. package/docs/security-redaction-benchmark.md +1 -0
  30. package/docs/security-threat-model.md +2 -1
  31. package/docs/subagent-orchestration-capabilities.md +1 -0
  32. package/docs/subagents-and-multiagent.md +1 -0
  33. package/docs/task-implementation-contract.md +1 -0
  34. package/docs/task-lifecycle-vietnamese.md +1 -0
  35. package/docs/team-onboarding.md +3 -2
  36. package/docs/third-party-neutrality.md +26 -0
  37. package/docs/usage-observability.md +1 -0
  38. package/docs/vercel-docs-site.md +1 -0
  39. package/docs/workflow-recipes.md +1 -0
  40. package/docs-site/architecture.html +1 -1
  41. package/docs-site/benchmark.html +1 -1
  42. package/docs-site/commands.html +1 -1
  43. package/docs-site/context-engine.html +1 -1
  44. package/docs-site/en/architecture.html +1 -1
  45. package/docs-site/en/benchmark.html +1 -1
  46. package/docs-site/en/commands.html +1 -1
  47. package/docs-site/en/context-engine.html +1 -1
  48. package/docs-site/en/index.html +1 -1
  49. package/docs-site/en/mcp-approval.html +1 -1
  50. package/docs-site/en/mcp-auth.html +1 -1
  51. package/docs-site/en/mcp-servers.html +1 -1
  52. package/docs-site/en/mcp.html +1 -1
  53. package/docs-site/en/permissions.html +1 -1
  54. package/docs-site/en/profiles.html +1 -1
  55. package/docs-site/en/quickstart.html +1 -1
  56. package/docs-site/en/runtime.html +1 -1
  57. package/docs-site/en/scope.html +1 -1
  58. package/docs-site/en/security.html +1 -1
  59. package/docs-site/en/team.html +1 -1
  60. package/docs-site/en/whats-new.html +1 -1
  61. package/docs-site/en/workflows.html +1 -1
  62. package/docs-site/index.html +1 -1
  63. package/docs-site/mcp-approval.html +1 -1
  64. package/docs-site/mcp-auth.html +1 -1
  65. package/docs-site/mcp-servers.html +1 -1
  66. package/docs-site/mcp.html +1 -1
  67. package/docs-site/permissions.html +1 -1
  68. package/docs-site/profiles.html +1 -1
  69. package/docs-site/quickstart.html +1 -1
  70. package/docs-site/runtime.html +1 -1
  71. package/docs-site/scope.html +1 -1
  72. package/docs-site/security.html +1 -1
  73. package/docs-site/team.html +1 -1
  74. package/docs-site/whats-new.html +1 -1
  75. package/docs-site/workflows.html +1 -1
  76. package/evals/fixtures/mutation-provenance-record.invalid.json +17 -0
  77. package/evals/fixtures/mutation-provenance-record.valid.json +27 -0
  78. package/evals/fixtures/piagent-webui/approval-v1.invalid.json +54 -0
  79. package/evals/fixtures/piagent-webui/approval-v1.valid.json +58 -0
  80. package/evals/fixtures/piagent-webui/attachment-v1.invalid.json +25 -0
  81. package/evals/fixtures/piagent-webui/attachment-v1.valid.json +25 -0
  82. package/evals/fixtures/piagent-webui/capabilities-v1.invalid.json +51 -0
  83. package/evals/fixtures/piagent-webui/capabilities-v1.valid.json +122 -0
  84. package/evals/fixtures/piagent-webui/commit-summary-v1.invalid.json +10 -0
  85. package/evals/fixtures/piagent-webui/commit-summary-v1.valid.json +25 -0
  86. package/evals/fixtures/piagent-webui/control-command-v1.invalid.json +37 -0
  87. package/evals/fixtures/piagent-webui/control-command-v1.valid.json +35 -0
  88. package/evals/fixtures/piagent-webui/diff-v1.invalid.json +13 -0
  89. package/evals/fixtures/piagent-webui/diff-v1.valid.json +141 -0
  90. package/evals/fixtures/piagent-webui/gateway-capabilities-v1.invalid.json +35 -0
  91. package/evals/fixtures/piagent-webui/gateway-capabilities-v1.valid.json +35 -0
  92. package/evals/fixtures/piagent-webui/gateway-protocol-v1.invalid.json +14 -0
  93. package/evals/fixtures/piagent-webui/gateway-protocol-v1.valid.json +14 -0
  94. package/evals/fixtures/piagent-webui/handoff-history-v1.invalid.json +16 -0
  95. package/evals/fixtures/piagent-webui/handoff-history-v1.valid.json +16 -0
  96. package/evals/fixtures/piagent-webui/model-catalog-v1.invalid.json +31 -0
  97. package/evals/fixtures/piagent-webui/model-catalog-v1.valid.json +43 -0
  98. package/evals/fixtures/piagent-webui/queue-v1.invalid.json +29 -0
  99. package/evals/fixtures/piagent-webui/queue-v1.valid.json +44 -0
  100. package/evals/fixtures/piagent-webui/recovery-history-v1.invalid.json +17 -0
  101. package/evals/fixtures/piagent-webui/recovery-history-v1.valid.json +20 -0
  102. package/evals/fixtures/piagent-webui/release-monitor-v1.invalid.json +12 -0
  103. package/evals/fixtures/piagent-webui/release-monitor-v1.valid.json +29 -0
  104. package/evals/fixtures/piagent-webui/review-state-v1.invalid.json +25 -0
  105. package/evals/fixtures/piagent-webui/review-state-v1.valid.json +38 -0
  106. package/evals/fixtures/piagent-webui/runtime-event-v2.invalid.json +49 -0
  107. package/evals/fixtures/piagent-webui/runtime-event-v2.valid.json +49 -0
  108. package/evals/fixtures/piagent-webui/session-catalog-v1.invalid.json +17 -0
  109. package/evals/fixtures/piagent-webui/session-catalog-v1.valid.json +58 -0
  110. package/evals/fixtures/piagent-webui/session-command-v1.invalid.json +19 -0
  111. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +19 -0
  112. package/evals/fixtures/piagent-webui/snapshot-v1.invalid.json +5 -0
  113. package/evals/fixtures/piagent-webui/snapshot-v1.valid.json +191 -0
  114. package/evals/fixtures/piagent-webui/source-change-v1.invalid.json +57 -0
  115. package/evals/fixtures/piagent-webui/source-change-v1.valid.json +98 -0
  116. package/evals/fixtures/piagent-webui/source-mutation-v1.invalid.json +19 -0
  117. package/evals/fixtures/piagent-webui/source-mutation-v1.valid.json +38 -0
  118. package/evals/fixtures/piagent-webui/source-revert-v1.invalid.json +20 -0
  119. package/evals/fixtures/piagent-webui/source-revert-v1.valid.json +62 -0
  120. package/evals/fixtures/piagent-webui/subagent-tree-v1.invalid.json +26 -0
  121. package/evals/fixtures/piagent-webui/subagent-tree-v1.valid.json +39 -0
  122. package/evals/fixtures/piagent-webui/task-index-v1.invalid.json +15 -0
  123. package/evals/fixtures/piagent-webui/task-index-v1.valid.json +15 -0
  124. package/evals/fixtures/piagent-webui/task-timeline-v1.invalid.json +14 -0
  125. package/evals/fixtures/piagent-webui/task-timeline-v1.valid.json +16 -0
  126. package/evals/fixtures/piagent-webui/transcript-v1.invalid.json +35 -0
  127. package/evals/fixtures/piagent-webui/transcript-v1.valid.json +55 -0
  128. package/evals/fixtures/task-baseline-manifest.invalid.json +16 -0
  129. package/evals/fixtures/task-baseline-manifest.valid.json +41 -0
  130. package/evals/fixtures/verifier-file-snapshot.invalid.json +21 -0
  131. package/evals/fixtures/verifier-file-snapshot.valid.json +21 -0
  132. package/package.json +30 -3
  133. package/packages/piagent-core/benchmark/benchmark-candidate.d.ts +1 -0
  134. package/packages/piagent-core/benchmark/benchmark-forensics.d.ts +1 -0
  135. package/packages/piagent-core/benchmark/benchmark-ledger.d.ts +4 -0
  136. package/packages/piagent-core/benchmark/benchmark-record-validation.d.ts +1 -0
  137. package/packages/piagent-core/benchmark/benchmark-runtime.d.ts +1 -0
  138. package/packages/piagent-core/capabilities/capability-core.js +2 -2
  139. package/packages/piagent-core/capabilities/runtime-integrity.js +143 -0
  140. package/packages/piagent-core/extensions/piagent-guard.ts +62 -37
  141. package/packages/piagent-core/extensions/redaction-core.d.ts +2 -0
  142. package/packages/piagent-core/extensions/task-journal.js +30 -1
  143. package/packages/piagent-core/extensions/task-state.js +33 -36
  144. package/packages/piagent-core/mcp/mcp-server-catalog.js +5 -5
  145. package/packages/piagent-core/package.json +1 -1
  146. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -2
  147. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +5 -2
  148. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +20 -19
  149. package/packages/piagent-core/runtime/hooks/tool-result-metadata.ts +13 -0
  150. package/packages/piagent-core/runtime/input/chat-images.ts +2 -2
  151. package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +94 -0
  152. package/packages/piagent-core/runtime/inspection/approval-broker.ts +318 -0
  153. package/packages/piagent-core/runtime/inspection/commit-summary-projection.ts +59 -0
  154. package/packages/piagent-core/runtime/inspection/context-telemetry-inspection.ts +37 -0
  155. package/packages/piagent-core/runtime/inspection/criteria-links.ts +295 -0
  156. package/packages/piagent-core/runtime/inspection/diff-projection.ts +499 -0
  157. package/packages/piagent-core/runtime/inspection/git-filter-safety.ts +38 -0
  158. package/packages/piagent-core/runtime/inspection/git-numstat-adapter.ts +71 -0
  159. package/packages/piagent-core/runtime/inspection/git-status-adapter.ts +493 -0
  160. package/packages/piagent-core/runtime/inspection/git-tree-adapter.ts +108 -0
  161. package/packages/piagent-core/runtime/inspection/mcp-control.ts +10 -0
  162. package/packages/piagent-core/runtime/inspection/mutation-provenance-contract.ts +131 -0
  163. package/packages/piagent-core/runtime/inspection/mutation-provenance-recorder.ts +48 -0
  164. package/packages/piagent-core/runtime/inspection/mutation-provenance-store.ts +161 -0
  165. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +36 -0
  166. package/packages/piagent-core/runtime/inspection/review-state-contract.ts +108 -0
  167. package/packages/piagent-core/runtime/inspection/review-state-projection.ts +93 -0
  168. package/packages/piagent-core/runtime/inspection/review-state-store.ts +112 -0
  169. package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +262 -0
  170. package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +103 -0
  171. package/packages/piagent-core/runtime/inspection/source-change-projection.ts +471 -0
  172. package/packages/piagent-core/runtime/inspection/source-evidence-contract.ts +139 -0
  173. package/packages/piagent-core/runtime/inspection/source-evidence-store.ts +315 -0
  174. package/packages/piagent-core/runtime/inspection/source-handoff-store.ts +70 -0
  175. package/packages/piagent-core/runtime/inspection/source-inspection-plan.ts +34 -0
  176. package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +164 -0
  177. package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +168 -0
  178. package/packages/piagent-core/runtime/inspection/source-open-target.ts +38 -0
  179. package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +147 -0
  180. package/packages/piagent-core/runtime/inspection/task-baseline-start-capture.ts +22 -0
  181. package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +160 -0
  182. package/packages/piagent-core/runtime/inspection/task-control-journal.ts +131 -0
  183. package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +95 -0
  184. package/packages/piagent-core/runtime/inspection/task-provenance-projection.ts +60 -0
  185. package/packages/piagent-core/runtime/inspection/task-recovery-timeline.ts +88 -0
  186. package/packages/piagent-core/runtime/inspection/task-run-index.ts +87 -0
  187. package/packages/piagent-core/runtime/inspection/task-source-projection.ts +401 -0
  188. package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +101 -0
  189. package/packages/piagent-core/runtime/inspection/text-diff.ts +60 -0
  190. package/packages/piagent-core/runtime/inspection/verifier-snapshot-contract.ts +114 -0
  191. package/packages/piagent-core/runtime/inspection/verifier-snapshot-store.ts +189 -0
  192. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +320 -0
  193. package/packages/piagent-core/runtime/inspection/workspace-file-reader.ts +132 -0
  194. package/packages/piagent-core/runtime/inspection/workspace-source-projection.ts +96 -0
  195. package/packages/piagent-core/runtime/inspection/zero-turn-conformance.ts +175 -0
  196. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +68 -4
  197. package/packages/piagent-core/runtime/policy/source-index-transaction.ts +173 -0
  198. package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +42 -0
  199. package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +184 -0
  200. package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +105 -0
  201. package/packages/piagent-core/runtime/product/activity-inspector-footer.ts +5 -3
  202. package/packages/piagent-core/runtime/product/activity-inspector.ts +85 -343
  203. package/packages/piagent-core/runtime/recovery/resume-state.ts +38 -1
  204. package/packages/piagent-core/runtime/registration/activity-inspector-command.ts +68 -14
  205. package/packages/piagent-core/runtime/registration/task-start-tool.ts +13 -14
  206. package/packages/piagent-core/runtime/session/model-authorship-state.ts +31 -3
  207. package/packages/piagent-core/runtime/session/runtime-state.ts +11 -1
  208. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +172 -0
  209. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +1 -0
  210. package/packages/piagent-webui/dist/client/index.html +16 -0
  211. package/packages/piagent-webui/extension/attachment-store.ts +323 -0
  212. package/packages/piagent-webui/extension/held-message-queue.ts +311 -0
  213. package/packages/piagent-webui/extension/lifecycle-controller.ts +408 -0
  214. package/packages/piagent-webui/extension/lifecycle-event-adapter.ts +23 -0
  215. package/packages/piagent-webui/extension/piagent-webui.ts +470 -0
  216. package/packages/piagent-webui/extension/review-controller.ts +160 -0
  217. package/packages/piagent-webui/extension/same-session-bridge.ts +500 -0
  218. package/packages/piagent-webui/extension/session-options-controller.ts +281 -0
  219. package/packages/piagent-webui/extension/session-stream-adapter.ts +249 -0
  220. package/packages/piagent-webui/extension/source-mutation-controller.ts +215 -0
  221. package/packages/piagent-webui/extension/source-open-controller.ts +136 -0
  222. package/packages/piagent-webui/extension/source-revert-controller.ts +189 -0
  223. package/packages/piagent-webui/extension/terminal-session-adapter.ts +73 -0
  224. package/packages/piagent-webui/extension/vscode-handoff.ts +51 -0
  225. package/packages/piagent-webui/gateway/control-socket.ts +92 -0
  226. package/packages/piagent-webui/gateway/gateway-events.ts +60 -0
  227. package/packages/piagent-webui/gateway/gateway-protocol-service.ts +64 -0
  228. package/packages/piagent-webui/gateway/gateway-service.ts +215 -0
  229. package/packages/piagent-webui/gateway/gateway-session-stream.ts +110 -0
  230. package/packages/piagent-webui/gateway/mcp-auth-broker.ts +158 -0
  231. package/packages/piagent-webui/gateway/native-project-picker.ts +29 -0
  232. package/packages/piagent-webui/gateway/pi-host.ts +41 -0
  233. package/packages/piagent-webui/gateway/profile-state.ts +1 -0
  234. package/packages/piagent-webui/gateway/project-registry.ts +108 -0
  235. package/packages/piagent-webui/gateway/provider-auth-broker.ts +172 -0
  236. package/packages/piagent-webui/gateway/session-catalog.ts +128 -0
  237. package/packages/piagent-webui/gateway/session-command-controller.ts +237 -0
  238. package/packages/piagent-webui/gateway/session-command-store.ts +224 -0
  239. package/packages/piagent-webui/gateway/session-inspection-registry.ts +204 -0
  240. package/packages/piagent-webui/gateway/session-lease-store.ts +1 -0
  241. package/packages/piagent-webui/gateway/session-metadata-store.ts +152 -0
  242. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +485 -0
  243. package/packages/piagent-webui/ownership/gateway-runtime-context.ts +8 -0
  244. package/packages/piagent-webui/ownership/profile-state.ts +117 -0
  245. package/packages/piagent-webui/ownership/session-lease-store.ts +329 -0
  246. package/packages/piagent-webui/ownership/session-refs.ts +8 -0
  247. package/packages/piagent-webui/package.json +33 -0
  248. package/packages/piagent-webui/server/README.md +4 -0
  249. package/packages/piagent-webui/server/benchmark-release-monitor.ts +230 -0
  250. package/packages/piagent-webui/server/core-inspection-provider.ts +403 -0
  251. package/packages/piagent-webui/server/gateway-websocket.ts +143 -0
  252. package/packages/piagent-webui/server/http-security.ts +27 -0
  253. package/packages/piagent-webui/server/ipc-read-model-client.ts +89 -0
  254. package/packages/piagent-webui/server/loopback-server.ts +368 -0
  255. package/packages/piagent-webui/server/read-model-provider.ts +37 -0
  256. package/packages/piagent-webui/server/read-only-router.ts +105 -0
  257. package/packages/piagent-webui/server/session-auth.ts +88 -0
  258. package/packages/piagent-webui/server/sidecar-main.ts +86 -0
  259. package/packages/piagent-webui/server/sse-hub.ts +76 -0
  260. package/packages/piagent-webui/server/static-bundle.ts +40 -0
  261. package/packages/piagent-webui/server/transcript-projection.ts +110 -0
  262. package/schemas/mutation-provenance-record.schema.json +100 -0
  263. package/schemas/piagent-webui/approval-v1.schema.json +654 -0
  264. package/schemas/piagent-webui/attachment-v1.schema.json +267 -0
  265. package/schemas/piagent-webui/capabilities-v1.schema.json +729 -0
  266. package/schemas/piagent-webui/catalog-v1.json +240 -0
  267. package/schemas/piagent-webui/commit-summary-v1.schema.json +63 -0
  268. package/schemas/piagent-webui/common-v1.schema.json +479 -0
  269. package/schemas/piagent-webui/control-command-v1.schema.json +1780 -0
  270. package/schemas/piagent-webui/diff-v1.schema.json +826 -0
  271. package/schemas/piagent-webui/gateway-capabilities-v1.schema.json +136 -0
  272. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +456 -0
  273. package/schemas/piagent-webui/handoff-history-v1.schema.json +92 -0
  274. package/schemas/piagent-webui/model-catalog-v1.schema.json +95 -0
  275. package/schemas/piagent-webui/queue-v1.schema.json +99 -0
  276. package/schemas/piagent-webui/recovery-history-v1.schema.json +144 -0
  277. package/schemas/piagent-webui/release-monitor-v1.schema.json +173 -0
  278. package/schemas/piagent-webui/review-state-v1.schema.json +206 -0
  279. package/schemas/piagent-webui/runtime-event-v2.schema.json +5206 -0
  280. package/schemas/piagent-webui/session-catalog-v1.schema.json +222 -0
  281. package/schemas/piagent-webui/session-command-v1.schema.json +390 -0
  282. package/schemas/piagent-webui/snapshot-v1.schema.json +2622 -0
  283. package/schemas/piagent-webui/source-change-v1.schema.json +763 -0
  284. package/schemas/piagent-webui/source-mutation-v1.schema.json +96 -0
  285. package/schemas/piagent-webui/source-revert-v1.schema.json +130 -0
  286. package/schemas/piagent-webui/subagent-tree-v1.schema.json +88 -0
  287. package/schemas/piagent-webui/task-index-v1.schema.json +74 -0
  288. package/schemas/piagent-webui/task-timeline-v1.schema.json +49 -0
  289. package/schemas/piagent-webui/transcript-v1.schema.json +243 -0
  290. package/schemas/task-baseline-manifest.schema.json +122 -0
  291. package/schemas/verifier-file-snapshot.schema.json +81 -0
  292. package/scripts/check-architecture.mjs +1 -0
  293. package/scripts/check-doc-languages.mjs +19 -2
  294. package/scripts/check-third-party-neutrality.mjs +43 -0
  295. package/scripts/install-global.sh +1 -1
  296. package/scripts/piagent-cli.mjs +17 -3
  297. package/scripts/piagent-dashboard.mjs +208 -0
  298. package/scripts/piagent-webui-launcher.mjs +121 -0
  299. package/scripts/typescript-loader.mjs +26 -1
  300. package/scripts/verify-local.sh +253 -0
  301. package/scripts/verify-release-identity.mjs +4 -1
@@ -0,0 +1,315 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { ensurePrivateStateDirectory, resolveLocalStatePath } from "../../extensions/local-state-path.js";
6
+ import { workingTreeSnapshot } from "../../extensions/task-state.js";
7
+ import { workingTreeEvidenceDigest } from "../../extensions/working-tree-digest.js";
8
+ import { collectGitStatus, type GitStatusRecord, type GitStatusSnapshot } from "./git-status-adapter.ts";
9
+ import { collectSourceChangeViews, sourceFileRef, type WebUiIdentity } from "./source-change-projection.ts";
10
+ import {
11
+ TASK_BASELINE_MANIFEST_VERSION,
12
+ taskBaselineManifestDigest,
13
+ validateTaskBaselineManifest,
14
+ type TaskBaselineEntry,
15
+ type TaskBaselineManifest,
16
+ type TaskBaselineRoot
17
+ } from "./source-evidence-contract.ts";
18
+ import { inspectWorkspaceEntry, readWorkspaceFile, readWorkspaceLink } from "./workspace-file-reader.ts";
19
+
20
+ const DEFAULT_MAX_ENTRIES = 2_000;
21
+ const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
22
+ const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
23
+ const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
24
+
25
+ export type BaselineCaptureOptions = {
26
+ projectRoot: string;
27
+ taskId: string;
28
+ taskRunId: string;
29
+ sessionId: string;
30
+ capturedAt: string;
31
+ baselineTreeDigest: string;
32
+ isProtectedProjectPath?: (projectPath: string) => boolean;
33
+ maxEntries?: number;
34
+ maxFileBytes?: number;
35
+ maxTotalBytes?: number;
36
+ retentionMs?: number;
37
+ };
38
+
39
+ type EvidenceRoot = { cwd: string; projectPath: string };
40
+ type Candidate = { record: GitStatusRecord; repoPath: string | null; absent: boolean };
41
+
42
+ function hash(value: string | Buffer): string {
43
+ return createHash("sha256").update(value).digest("hex");
44
+ }
45
+
46
+ function digest(value: string | Buffer): string {
47
+ return `sha256:${hash(value)}`;
48
+ }
49
+
50
+ function bounded(value: number | undefined, fallback: number, minimum: number, maximum: number): number {
51
+ return Number.isInteger(value) ? Math.max(minimum, Math.min(maximum, value as number)) : fallback;
52
+ }
53
+
54
+ function runDirectory(projectRoot: string, taskRunId: string): string {
55
+ return path.join(projectRoot, ".pi", "piagent-state", "source-evidence", `run-${hash(taskRunId)}`);
56
+ }
57
+
58
+ export function taskBaselineManifestPath(projectRoot: string, taskRunId: string): string {
59
+ return path.join(runDirectory(projectRoot, taskRunId), "manifest.json");
60
+ }
61
+
62
+ function blobPath(projectRoot: string, taskRunId: string, contentRef: string): string {
63
+ if (!/^sha256:[a-f0-9]{64}$/.test(contentRef)) throw new Error("Invalid baseline content ref");
64
+ return path.join(runDirectory(projectRoot, taskRunId), "blobs", contentRef.slice(7));
65
+ }
66
+
67
+ function readPrivateFile(projectRoot: string, file: string, maxBytes: number): Buffer {
68
+ const safe = resolveLocalStatePath(projectRoot, file, { label: "Source evidence", kind: "file" });
69
+ const descriptor = fs.openSync(safe, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
70
+ try {
71
+ const stat = fs.fstatSync(descriptor);
72
+ if (!stat.isFile() || stat.size > maxBytes) throw new Error("Source evidence file is oversized or not regular");
73
+ return fs.readFileSync(descriptor);
74
+ } finally {
75
+ fs.closeSync(descriptor);
76
+ }
77
+ }
78
+
79
+ function publishPrivateFile(projectRoot: string, target: string, content: Buffer): void {
80
+ const parent = ensurePrivateStateDirectory(projectRoot, path.dirname(target), "Source evidence directory");
81
+ const safeTarget = resolveLocalStatePath(projectRoot, target, { label: "Source evidence" });
82
+ const temporary = path.join(parent, `${path.basename(safeTarget)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
83
+ const descriptor = fs.openSync(temporary, "wx", 0o600);
84
+ try {
85
+ fs.writeFileSync(descriptor, content);
86
+ fs.fsyncSync(descriptor);
87
+ fs.fchmodSync(descriptor, 0o600);
88
+ } finally {
89
+ fs.closeSync(descriptor);
90
+ }
91
+ try {
92
+ fs.linkSync(temporary, safeTarget);
93
+ } catch (error) {
94
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
95
+ const existing = readPrivateFile(projectRoot, safeTarget, Math.max(1024, content.length));
96
+ if (!existing.equals(content)) throw new Error("Existing source evidence does not match the captured bytes");
97
+ } finally {
98
+ fs.rmSync(temporary, { force: true });
99
+ }
100
+ try { fs.chmodSync(parent, 0o700); fs.chmodSync(safeTarget, 0o600); } catch {}
101
+ }
102
+
103
+ function writeBlob(projectRoot: string, taskRunId: string, content: Buffer): string {
104
+ const contentRef = digest(content);
105
+ publishPrivateFile(projectRoot, blobPath(projectRoot, taskRunId, contentRef), content);
106
+ return contentRef;
107
+ }
108
+
109
+ async function discoverEvidenceRoots(projectRoot: string): Promise<EvidenceRoot[]> {
110
+ const absolute = fs.realpathSync.native(projectRoot);
111
+ try {
112
+ await collectGitStatus(absolute);
113
+ return [{ cwd: absolute, projectPath: "." }];
114
+ } catch {
115
+ const roots: EvidenceRoot[] = [];
116
+ for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
117
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
118
+ const child = path.join(absolute, entry.name);
119
+ try {
120
+ if (fs.lstatSync(child).isSymbolicLink()) continue;
121
+ const status = await collectGitStatus(child);
122
+ if (status.repoRoot !== fs.realpathSync.native(child)) continue;
123
+ roots.push({ cwd: status.repoRoot, projectPath: entry.name });
124
+ } catch {}
125
+ }
126
+ return roots.sort((left, right) => left.projectPath.localeCompare(right.projectPath, "en"));
127
+ }
128
+ }
129
+
130
+ function projectPath(root: EvidenceRoot, repoPath: string): string {
131
+ return root.projectPath === "." ? repoPath : `${root.projectPath}/${repoPath}`;
132
+ }
133
+
134
+ function entryMode(record: GitStatusRecord, absent: boolean): string {
135
+ if (absent) return "000000";
136
+ const mode = record.worktreeMode === "000000" ? record.indexMode : record.worktreeMode;
137
+ return /^[0-7]{6}$/.test(mode ?? "") ? mode as string : "000000";
138
+ }
139
+
140
+ function candidates(snapshot: GitStatusSnapshot): Candidate[] {
141
+ const selected = new Map<string, Candidate>();
142
+ for (const record of snapshot.records) {
143
+ if (record.kind === "ignored") continue;
144
+ const paths = [record.path.value, record.oldPath?.value].filter((value): value is string => Boolean(value));
145
+ if (paths.some((value) => value === ".pi/piagent-state" || value.startsWith(".pi/piagent-state/"))) continue;
146
+ if (record.oldPath?.value) selected.set(record.oldPath.value, { record, repoPath: record.oldPath.value, absent: true });
147
+ if (record.path.value) selected.set(record.path.value, { record, repoPath: record.path.value, absent: record.worktreeMode === "000000" && record.kind !== "untracked" });
148
+ else selected.set(`unavailable:${record.path.digest}`, { record, repoPath: null, absent: false });
149
+ }
150
+ return [...selected.values()].sort((left, right) => Buffer.compare(Buffer.from(left.repoPath ?? left.record.path.digest), Buffer.from(right.repoPath ?? right.record.path.digest)));
151
+ }
152
+
153
+ function unavailableEntry(candidate: Candidate, snapshot: GitStatusSnapshot, reasonCode: string): TaskBaselineEntry {
154
+ const raw = candidate.repoPath ? Buffer.from(candidate.repoPath, "utf8") : null;
155
+ const pathDigest = candidate.repoPath ? digest(raw as Buffer) : candidate.record.path.digest;
156
+ return {
157
+ pathRef: `path.${hash(`${snapshot.repoDigest}\0${pathDigest}`)}`,
158
+ pathDigest,
159
+ repoPathBase64: raw?.toString("base64url") ?? null,
160
+ state: "unavailable",
161
+ reasonCode,
162
+ contentRef: null,
163
+ byteLength: 0,
164
+ mode: entryMode(candidate.record, candidate.absent),
165
+ headObject: candidate.record.headObject && !/^0+$/.test(candidate.record.headObject) ? candidate.record.headObject : null,
166
+ indexObject: candidate.record.indexObject && !/^0+$/.test(candidate.record.indexObject) ? candidate.record.indexObject : null
167
+ };
168
+ }
169
+
170
+ function captureEntry(
171
+ options: BaselineCaptureOptions,
172
+ root: EvidenceRoot,
173
+ snapshot: GitStatusSnapshot,
174
+ candidate: Candidate,
175
+ quota: { captured: number; refs: Set<string>; maxFile: number; maxTotal: number }
176
+ ): TaskBaselineEntry {
177
+ if (!candidate.repoPath) return unavailableEntry(candidate, snapshot, "path-unavailable");
178
+ const base = unavailableEntry(candidate, snapshot, "capture-unavailable");
179
+ const encoded = Buffer.from(candidate.repoPath, "utf8").toString("base64url");
180
+ const exact = (state: TaskBaselineEntry["state"], contentRef: string | null, byteLength: number): TaskBaselineEntry => ({ ...base, repoPathBase64: encoded, state, reasonCode: null, contentRef, byteLength });
181
+ if (options.isProtectedProjectPath?.(projectPath(root, candidate.repoPath))) {
182
+ return { ...base, repoPathBase64: encoded, state: "protected", reasonCode: "protected-path" };
183
+ }
184
+ if (candidate.absent) return exact("absent", null, 0);
185
+ if (base.mode === "160000" || candidate.record.submodule?.startsWith("S")) return exact("submodule", null, 0);
186
+ try {
187
+ const stat = inspectWorkspaceEntry(snapshot.repoRoot, candidate.repoPath);
188
+ let content: Buffer;
189
+ let state: "blob" | "symlink";
190
+ if (stat.isSymbolicLink()) {
191
+ content = Buffer.from(readWorkspaceLink(snapshot.repoRoot, candidate.repoPath), "utf8");
192
+ state = "symlink";
193
+ } else if (stat.isFile()) {
194
+ if (stat.size > quota.maxFile) return { ...base, repoPathBase64: encoded, state: "oversized", reasonCode: "file-size-limit", byteLength: Math.min(stat.size, 16 * 1024 * 1024) };
195
+ content = readWorkspaceFile(snapshot.repoRoot, candidate.repoPath, quota.maxFile);
196
+ state = "blob";
197
+ } else return { ...base, repoPathBase64: encoded, reasonCode: "unsupported-file-type" };
198
+ const contentRef = digest(content);
199
+ const additional = quota.refs.has(contentRef) ? 0 : content.length;
200
+ if (quota.captured + additional > quota.maxTotal) return { ...base, repoPathBase64: encoded, reasonCode: "task-byte-quota" };
201
+ writeBlob(options.projectRoot, options.taskRunId, content);
202
+ quota.refs.add(contentRef);
203
+ quota.captured += additional;
204
+ return exact(state, contentRef, content.length);
205
+ } catch {
206
+ return { ...base, repoPathBase64: encoded, reasonCode: "content-read-failed" };
207
+ }
208
+ }
209
+
210
+ function identity(options: BaselineCaptureOptions): WebUiIdentity {
211
+ return { projectRef: "baseline-capture", runtimeInstanceId: "baseline-capture", sessionRef: "baseline-capture", taskId: options.taskId, taskRunId: options.taskRunId, agentOperationId: null, toolCallId: null };
212
+ }
213
+
214
+ async function captureRoot(options: BaselineCaptureOptions, root: EvidenceRoot, quota: { captured: number; refs: Set<string>; maxFile: number; maxTotal: number }, maxEntries: number): Promise<TaskBaselineRoot> {
215
+ const snapshot = await collectGitStatus(root.cwd);
216
+ const projected = await collectSourceChangeViews({ cwd: root.cwd, identity: identity(options), generatedAt: options.capturedAt, pageLimit: 2000 });
217
+ const basis = projected.workingTree.bases[0] as Record<string, any>;
218
+ const all = candidates(snapshot);
219
+ const limited = all.slice(0, maxEntries);
220
+ const entries = limited.map((candidate) => captureEntry(options, root, snapshot, candidate, quota));
221
+ const bad = entries.find((entry) => ["protected", "oversized", "unavailable"].includes(entry.state));
222
+ const state = all.length > limited.length || bad ? "unavailable" : "current";
223
+ const reasonCode = all.length > limited.length ? "entry-limit" : bad?.reasonCode ?? null;
224
+ return {
225
+ repoRef: String(basis.repoRef),
226
+ projectPath: root.projectPath,
227
+ headState: snapshot.headState,
228
+ headOid: snapshot.headOid,
229
+ workspaceRevision: String(basis.workspaceRevision),
230
+ indexRevision: String(basis.indexRevision),
231
+ state,
232
+ reasonCode,
233
+ entries
234
+ };
235
+ }
236
+
237
+ export async function captureTaskBaselineManifest(options: BaselineCaptureOptions): Promise<TaskBaselineManifest> {
238
+ const existing = readTaskBaselineManifest(options.projectRoot, options.taskRunId);
239
+ if (existing) {
240
+ if (existing.taskId !== options.taskId || existing.baselineTreeDigest !== options.baselineTreeDigest) throw new Error("Task baseline manifest identity collision");
241
+ return existing;
242
+ }
243
+ if (workingTreeEvidenceDigest(workingTreeSnapshot(options.projectRoot)) !== options.baselineTreeDigest) {
244
+ throw new Error("Workspace changed before Task Baseline Manifest capture");
245
+ }
246
+ const maxEntries = bounded(options.maxEntries, DEFAULT_MAX_ENTRIES, 1, DEFAULT_MAX_ENTRIES);
247
+ const maxFileBytes = bounded(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 1024, 16 * 1024 * 1024);
248
+ const maxTotalBytes = bounded(options.maxTotalBytes, DEFAULT_MAX_TOTAL_BYTES, 1024 * 1024, 256 * 1024 * 1024);
249
+ const retentionMs = bounded(options.retentionMs, DEFAULT_RETENTION_MS, 60_000, 365 * 24 * 60 * 60 * 1000);
250
+ const roots = await discoverEvidenceRoots(options.projectRoot);
251
+ if (roots.length === 0 || roots.length > 32) throw new Error("Task baseline capture requires between one and 32 Git roots");
252
+ const quota = { captured: 0, refs: new Set<string>(), maxFile: maxFileBytes, maxTotal: maxTotalBytes };
253
+ let remaining = maxEntries;
254
+ const capturedRoots: TaskBaselineRoot[] = [];
255
+ for (const root of roots) {
256
+ const captured = await captureRoot(options, root, quota, remaining);
257
+ capturedRoots.push(captured);
258
+ remaining = Math.max(0, remaining - captured.entries.length);
259
+ }
260
+ const badRoot = capturedRoots.find((root) => root.state !== "current");
261
+ const payload: Omit<TaskBaselineManifest, "integrityDigest"> = {
262
+ schemaVersion: 1,
263
+ version: TASK_BASELINE_MANIFEST_VERSION,
264
+ taskId: options.taskId,
265
+ taskRunId: options.taskRunId,
266
+ sessionIdentityHash: digest(options.sessionId),
267
+ capturedAt: options.capturedAt,
268
+ retentionUntil: new Date(Date.parse(options.capturedAt) + retentionMs).toISOString(),
269
+ baselineDigestAlgorithm: "wt-content-v2",
270
+ baselineTreeDigest: options.baselineTreeDigest,
271
+ captureState: badRoot ? "unavailable" : "current",
272
+ reasonCode: badRoot?.reasonCode ?? null,
273
+ limits: { maxEntries, maxFileBytes, maxTotalBytes, capturedBytes: quota.captured },
274
+ roots: capturedRoots
275
+ };
276
+ if (workingTreeEvidenceDigest(workingTreeSnapshot(options.projectRoot)) !== options.baselineTreeDigest) {
277
+ throw new Error("Workspace changed during Task Baseline Manifest capture");
278
+ }
279
+ const manifest = validateTaskBaselineManifest({ ...payload, integrityDigest: taskBaselineManifestDigest(payload) });
280
+ publishPrivateFile(options.projectRoot, taskBaselineManifestPath(options.projectRoot, options.taskRunId), Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
281
+ return manifest;
282
+ }
283
+
284
+ export function readTaskBaselineManifest(projectRoot: string, taskRunId: string): TaskBaselineManifest | undefined {
285
+ const file = taskBaselineManifestPath(projectRoot, taskRunId);
286
+ try {
287
+ return validateTaskBaselineManifest(JSON.parse(readPrivateFile(projectRoot, file, 8 * 1024 * 1024).toString("utf8")));
288
+ } catch (error) {
289
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
290
+ throw error;
291
+ }
292
+ }
293
+
294
+ export function readTaskBaselineBlob(projectRoot: string, taskRunId: string, contentRef: string, maxBytes = DEFAULT_MAX_FILE_BYTES): Buffer {
295
+ const content = readPrivateFile(projectRoot, blobPath(projectRoot, taskRunId, contentRef), bounded(maxBytes, DEFAULT_MAX_FILE_BYTES, 1024, 16 * 1024 * 1024));
296
+ if (digest(content) !== contentRef) throw new Error("Task baseline blob integrity mismatch");
297
+ return content;
298
+ }
299
+
300
+ export function taskBaselineManifestRef(manifest: TaskBaselineManifest): string {
301
+ return `baseline.${manifest.integrityDigest.slice(7)}`;
302
+ }
303
+
304
+ export function taskBaselineRetentionState(
305
+ manifest: TaskBaselineManifest,
306
+ at = new Date()
307
+ ): "active" | "expired" {
308
+ return at.getTime() < Date.parse(manifest.retentionUntil) ? "active" : "expired";
309
+ }
310
+
311
+ export function decodeBaselineRepoPath(entry: TaskBaselineEntry): string | null {
312
+ if (entry.repoPathBase64 === null) return null;
313
+ const value = Buffer.from(entry.repoPathBase64, "base64url").toString("utf8");
314
+ return value && !path.posix.isAbsolute(value) && !value.split("/").includes("..") ? value : null;
315
+ }
@@ -0,0 +1,70 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { ensurePrivateStateDirectory, resolveLocalStatePath } from "../../extensions/local-state-path.js";
6
+ import { canonicalReviewValue } from "./review-state-contract.ts";
7
+ import { readTaskBaselineManifest, taskBaselineRetentionState } from "./source-evidence-store.ts";
8
+
9
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/, DIGEST = /^sha256:[a-f0-9]{64}$/,
10
+ REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/, MAX_RECORDS = 2_000, MAX_BYTES = 32 * 1024;
11
+ export const SOURCE_HANDOFF_EVIDENCE_VERSION = "piagent-webui-source-handoff-v1";
12
+
13
+ export type SourceHandoffRecord = { schemaVersion: 1; version: typeof SOURCE_HANDOFF_EVIDENCE_VERSION; recordId: string; evidenceRef: string;
14
+ sequence: number; phase: "requested" | "settled" | "rejected" | "uncertain"; resultCode: "handoff-requested" | "opened" | "handoff-rejected" | "effect-unknown";
15
+ failureCode: string | null; projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string; taskRunId: string;
16
+ commandId: string; idempotencyKeyDigest: string; actionDigest: string; fileRef: string; line: number | null; column: number | null;
17
+ taskRevision: string; workspaceRevision: string; contentDigest: string; requestedAt: string; recordedAt: string; retentionUntil: string; integrityDigest: string };
18
+ type Append = Omit<SourceHandoffRecord, "schemaVersion" | "version" | "recordId" | "evidenceRef" | "sequence" | "retentionUntil" | "integrityDigest"> & { projectRoot: string };
19
+
20
+ function sha(value: string): string { return createHash("sha256").update(value).digest("hex"); }
21
+ function digest(value: unknown): string { return `sha256:${sha(canonicalReviewValue(value))}`; }
22
+ function timestamp(value: unknown): value is string { if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
23
+ const parsed = Date.parse(value); return Number.isFinite(parsed) && new Date(parsed).toISOString() === value; }
24
+ function directory(root: string, run: string): string { return path.join(root, ".pi", "piagent-state", "source-evidence", `run-${sha(run)}`, "handoffs"); }
25
+ function readPrivate(root: string, file: string): Buffer { const safe = resolveLocalStatePath(root, file, { label: "Source handoff evidence", kind: "file" });
26
+ const descriptor = fs.openSync(safe, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); try { const stat = fs.fstatSync(descriptor);
27
+ if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error("source-handoff-record-invalid"); return fs.readFileSync(descriptor); } finally { fs.closeSync(descriptor); } }
28
+ function publish(root: string, target: string, bytes: Buffer): void { const parent = ensurePrivateStateDirectory(root, path.dirname(target), "Source handoff evidence directory");
29
+ const safe = resolveLocalStatePath(root, target, { label: "Source handoff evidence" }), temporary = path.join(parent, `.${path.basename(target)}.${process.pid}.${randomBytes(8).toString("hex")}`);
30
+ const descriptor = fs.openSync(temporary, "wx", 0o600); try { fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor); fs.fchmodSync(descriptor, 0o600); } finally { fs.closeSync(descriptor); }
31
+ try { fs.linkSync(temporary, safe); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !readPrivate(root, safe).equals(bytes)) throw error; }
32
+ finally { fs.rmSync(temporary, { force: true }); } }
33
+
34
+ export function validateSourceHandoffRecord(value: unknown): SourceHandoffRecord {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("source-handoff-record-invalid"); const record = value as SourceHandoffRecord;
36
+ const keys = ["schemaVersion", "version", "recordId", "evidenceRef", "sequence", "phase", "resultCode", "failureCode", "projectRef", "runtimeInstanceId",
37
+ "sessionRef", "taskId", "taskRunId", "commandId", "idempotencyKeyDigest", "actionDigest", "fileRef", "line", "column", "taskRevision",
38
+ "workspaceRevision", "contentDigest", "requestedAt", "recordedAt", "retentionUntil", "integrityDigest"];
39
+ if (canonicalReviewValue(Object.keys(record).sort()) !== canonicalReviewValue(keys.sort()) || record.schemaVersion !== 1 || record.version !== SOURCE_HANDOFF_EVIDENCE_VERSION
40
+ || ![record.recordId, record.evidenceRef, record.projectRef, record.runtimeInstanceId, record.sessionRef, record.taskId, record.taskRunId, record.commandId, record.fileRef].every((item) => REF.test(item))
41
+ || ![record.idempotencyKeyDigest, record.actionDigest, record.contentDigest, record.integrityDigest].every((item) => DIGEST.test(item))
42
+ || ![record.taskRevision, record.workspaceRevision].every((item) => REVISION.test(item)) || ![record.requestedAt, record.recordedAt, record.retentionUntil].every(timestamp)
43
+ || !Number.isInteger(record.sequence) || record.sequence < 1 || record.sequence > MAX_RECORDS || record.line !== null && (!Number.isInteger(record.line) || record.line < 1 || record.line > 100_000_000)
44
+ || record.column !== null && (!Number.isInteger(record.column) || record.column < 1 || record.column > 1_000_000) || record.column !== null && record.line === null
45
+ || !(record.failureCode === null || /^[a-z][a-z0-9.-]{0,95}$/.test(record.failureCode))) throw new Error("source-handoff-record-invalid");
46
+ const expected = { requested: "handoff-requested", settled: "opened", rejected: "handoff-rejected", uncertain: "effect-unknown" }[record.phase];
47
+ if (record.resultCode !== expected || ["requested", "settled"].includes(record.phase) !== (record.failureCode === null)) throw new Error("source-handoff-record-invalid");
48
+ const { integrityDigest, ...payload } = record; if (digest(payload) !== integrityDigest) throw new Error("source-handoff-record-invalid"); return structuredClone(record);
49
+ }
50
+
51
+ export function readSourceHandoffEvidence(root: string, run: string): { records: SourceHandoffRecord[]; corruptions: string[] } {
52
+ let files: string[]; const folder = directory(root, run); try { files = fs.readdirSync(resolveLocalStatePath(root, folder, { label: "Source handoff evidence", kind: "directory" })).filter((file) => file.endsWith(".json")).sort(); }
53
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { records: [], corruptions: [] } : { records: [], corruptions: ["source-handoff-evidence-unavailable"] }; }
54
+ if (files.length > MAX_RECORDS) return { records: [], corruptions: ["source-handoff-record-limit"] }; const records: SourceHandoffRecord[] = [], corruptions: string[] = [];
55
+ for (const file of files) try { const record = validateSourceHandoffRecord(JSON.parse(readPrivate(root, path.join(folder, file)).toString("utf8")));
56
+ if (file !== `${record.recordId}.json` || record.taskRunId !== run) throw new Error("source-handoff-identity-invalid"); records.push(record); }
57
+ catch { corruptions.push(`handoff.${sha(file)}`); } return { records: corruptions.length ? [] : records.sort((a, b) => a.sequence - b.sequence), corruptions };
58
+ }
59
+
60
+ export function appendSourceHandoffEvidence(options: Append): SourceHandoffRecord {
61
+ const manifest = readTaskBaselineManifest(options.projectRoot, options.taskRunId); if (!manifest || manifest.taskId !== options.taskId || manifest.captureState !== "current"
62
+ || taskBaselineRetentionState(manifest, new Date(options.recordedAt)) !== "active") throw new Error("source-handoff-baseline-unavailable");
63
+ const existing = readSourceHandoffEvidence(options.projectRoot, options.taskRunId); if (existing.corruptions.length || existing.records.length >= MAX_RECORDS) throw new Error("source-handoff-evidence-unavailable");
64
+ const identity = digest({ commandId: options.commandId, actionDigest: options.actionDigest, phase: options.phase }), recordId = `handoff.${identity.slice(7)}`;
65
+ const old = existing.records.find((record) => record.recordId === recordId); if (old) return old;
66
+ const payload = { schemaVersion: 1 as const, version: SOURCE_HANDOFF_EVIDENCE_VERSION, recordId, evidenceRef: `handoff-evidence.${identity.slice(7)}`,
67
+ sequence: (existing.records.at(-1)?.sequence ?? 0) + 1, retentionUntil: manifest.retentionUntil, ...Object.fromEntries(Object.entries(options).filter(([key]) => key !== "projectRoot")) } as Omit<SourceHandoffRecord, "integrityDigest">;
68
+ const record = validateSourceHandoffRecord({ ...payload, integrityDigest: digest(payload) }), bytes = Buffer.from(`${JSON.stringify(record)}\n`);
69
+ if (bytes.length > MAX_BYTES) throw new Error("source-handoff-record-oversized"); publish(options.projectRoot, path.join(directory(options.projectRoot, options.taskRunId), `${record.recordId}.json`), bytes); return record;
70
+ }
@@ -0,0 +1,34 @@
1
+ import type { GitStatusRecord, GitStatusSnapshot } from "./git-status-adapter.ts";
2
+
3
+ type ProtectedPathCheck = (repoRoot: string, repoPath: string) => boolean;
4
+
5
+ export type SourceInspectionPlan = {
6
+ objectIds: string[];
7
+ workingTreePaths: string[];
8
+ stagedPaths: string[];
9
+ };
10
+
11
+ function visible(record: GitStatusRecord): boolean {
12
+ const paths = [record.path.value, record.oldPath?.value].filter((value): value is string => Boolean(value));
13
+ return paths.length > 0 && !paths.some((repoPath) => repoPath === ".pi/piagent-state" || repoPath.startsWith(".pi/piagent-state/"));
14
+ }
15
+
16
+ export function sourceInspectionPlan(
17
+ snapshot: GitStatusSnapshot,
18
+ isProtectedPath?: ProtectedPathCheck
19
+ ): SourceInspectionPlan {
20
+ const safeRecords = snapshot.records.filter((record) => {
21
+ const repoPath = record.path.value;
22
+ const oldPath = record.oldPath?.value;
23
+ return visible(record)
24
+ && isProtectedPath?.(snapshot.repoRoot, repoPath as string) !== true
25
+ && (!oldPath || isProtectedPath?.(snapshot.repoRoot, oldPath) !== true);
26
+ });
27
+ const objectIds = [...new Set(safeRecords.flatMap((record) =>
28
+ [record.headObject, record.indexObject].filter((value): value is string => Boolean(value) && !/^0+$/.test(value))
29
+ ))];
30
+ const paths = [...new Set(safeRecords.flatMap((record) =>
31
+ [record.path.value, record.oldPath?.value].filter((value): value is string => Boolean(value))
32
+ ))];
33
+ return { objectIds, workingTreePaths: paths, stagedPaths: paths };
34
+ }
@@ -0,0 +1,164 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { runReadOnlyGit } from "./git-status-adapter.ts";
4
+ import { collectExactGitPatchAuthority, type ExactGitPatchAuthority } from "./diff-projection.ts";
5
+ import { deriveReviewTarget } from "./review-state-projection.ts";
6
+ import { collectSelectedSourceFileState, type SourceChangeDocument, type SourceProjectionOptions, type WebUiIdentity } from "./source-change-projection.ts";
7
+ import type { DiffDocument } from "./diff-projection.ts";
8
+ import { hashWorkspaceFile, inspectWorkspaceEntry } from "./workspace-file-reader.ts";
9
+
10
+ export type SourceMutationAction = "source.stage" | "source.unstage";
11
+ export type SourceMutationTarget = {
12
+ view: "working-tree" | "staged";
13
+ repoRef: string;
14
+ fileRef: string;
15
+ diffRef: string;
16
+ status: "A" | "M" | "D" | "R" | "U";
17
+ path: string;
18
+ oldPath: string | null;
19
+ effect: "copy-worktree-to-index" | "restore-index-from-head";
20
+ taskRevision: string;
21
+ workspaceRevision: string;
22
+ indexRevision: string;
23
+ viewRevision: string;
24
+ fileRevision: string;
25
+ workspacePreimage: string;
26
+ indexPreimage: string;
27
+ patchPreimage: string;
28
+ contentDigest: string;
29
+ hunkRefs: string[];
30
+ };
31
+ export type SourceMutationProjection = {
32
+ schemaVersion: 1;
33
+ version: "piagent-webui-source-mutation-v1";
34
+ generatedAt: string;
35
+ identity: WebUiIdentity;
36
+ action: SourceMutationAction;
37
+ state: "ready" | "unavailable";
38
+ target: SourceMutationTarget | null;
39
+ reasonCode: string | null;
40
+ health: { state: "ok" | "unavailable" | "error"; reasonCode: string | null; message: string | null };
41
+ };
42
+ export type SourceMutationAuthority = {
43
+ repoRoot: string;
44
+ repoPaths: string[];
45
+ headOid: string | null;
46
+ indexStatus: string;
47
+ worktreeStatus: string;
48
+ target: SourceMutationTarget;
49
+ patchAuthority: ExactGitPatchAuthority | null;
50
+ };
51
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/, REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/,
52
+ DIGEST = /^sha256:[a-f0-9]{64}$/, WORKSPACE = /^wt-content-v2:[a-f0-9]{64}$/,
53
+ SAFE_PATH = /^(?!\/)(?![A-Za-z]:)(?!.*(?:^|\/)\.\.(?:\/|$))[^\u0000-\u001F\u007F\\]{1,1024}$/;
54
+
55
+ type CollectOptions = {
56
+ cwd: string;
57
+ identity: WebUiIdentity;
58
+ action: SourceMutationAction;
59
+ sourceView: SourceChangeDocument;
60
+ diff: DiffDocument;
61
+ taskRevision: string;
62
+ workspaceRevision: string;
63
+ indexRevision: string | null;
64
+ generatedAt?: string;
65
+ isProtectedPath?: SourceProjectionOptions["isProtectedPath"];
66
+ guardAvailable?: boolean;
67
+ timeoutMs?: number;
68
+ maxGitBytes?: number;
69
+ };
70
+
71
+ function sha(value: string | Buffer): string { return createHash("sha256").update(value).digest("hex"); }
72
+ function digest(value: string | Buffer): string { return `sha256:${sha(value)}`; }
73
+ function unavailable(options: CollectOptions, reasonCode: string): { projection: SourceMutationProjection; authority: null } {
74
+ const message = "The exact selected-file Git mutation preview is unavailable";
75
+ return { projection: { schemaVersion: 1, version: "piagent-webui-source-mutation-v1", generatedAt: options.generatedAt ?? new Date().toISOString(),
76
+ identity: structuredClone(options.identity), action: options.action, state: "unavailable", target: null, reasonCode,
77
+ health: { state: "unavailable", reasonCode, message } }, authority: null };
78
+ }
79
+
80
+ export function validateSourceMutationTarget(value: unknown): SourceMutationTarget {
81
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("mutation-target-invalid");
82
+ const target = value as SourceMutationTarget, keys = ["view", "repoRef", "fileRef", "diffRef", "status", "path", "oldPath", "effect",
83
+ "taskRevision", "workspaceRevision", "indexRevision", "viewRevision", "fileRevision", "workspacePreimage", "indexPreimage", "patchPreimage", "contentDigest", "hunkRefs"];
84
+ if (JSON.stringify(Object.keys(target).sort()) !== JSON.stringify(keys.sort()) || !["working-tree", "staged"].includes(target.view)
85
+ || ![target.repoRef, target.fileRef, target.diffRef].every((item) => REF.test(item)) || !["A", "M", "D", "R", "U"].includes(target.status)
86
+ || !SAFE_PATH.test(target.path) || target.oldPath !== null && !SAFE_PATH.test(target.oldPath)
87
+ || ![target.taskRevision, target.workspaceRevision, target.indexRevision, target.viewRevision, target.fileRevision].every((item) => REVISION.test(item))
88
+ || !WORKSPACE.test(target.workspacePreimage) || ![target.indexPreimage, target.patchPreimage, target.contentDigest].every((item) => DIGEST.test(item))
89
+ || target.patchPreimage !== target.contentDigest || !Array.isArray(target.hunkRefs) || target.hunkRefs.length > 128
90
+ || new Set(target.hunkRefs).size !== target.hunkRefs.length || target.hunkRefs.some((item) => !REF.test(item))
91
+ || target.view === "working-tree" && target.effect !== "copy-worktree-to-index"
92
+ || target.view === "staged" && target.effect !== "restore-index-from-head") throw new Error("mutation-target-invalid");
93
+ return structuredClone(target);
94
+ }
95
+
96
+ export function collectSelectedWorkspacePreimage(repoRoot: string, repoPaths: string[]): string {
97
+ const entries = [...new Set(repoPaths)].sort().map((repoPath) => {
98
+ try {
99
+ const stat = inspectWorkspaceEntry(repoRoot, repoPath);
100
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsupported-workspace-entry");
101
+ return [repoPath, `sha256:${hashWorkspaceFile(repoRoot, repoPath)}`];
102
+ } catch (error) {
103
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [repoPath, null];
104
+ throw error;
105
+ }
106
+ });
107
+ const material = JSON.stringify({ schemaVersion: 1, entries });
108
+ return `wt-content-v2:${sha(`piagent-webui-selected-workspace\0${material}`)}`;
109
+ }
110
+
111
+ export async function collectIndexPreimage(repoRoot: string, timeoutMs?: number, maxGitBytes?: number): Promise<string> {
112
+ const index = await runReadOnlyGit(repoRoot, ["ls-files", "--stage", "-z"], { timeoutMs, maxBytes: maxGitBytes ?? 64 * 1024 * 1024 });
113
+ return digest(`piagent-webui-index-v1\0${index.toString("base64")}`);
114
+ }
115
+
116
+ export async function collectSourceMutationPreview(options: CollectOptions): Promise<{ projection: SourceMutationProjection; authority: SourceMutationAuthority | null }> {
117
+ const expectedView = options.action === "source.stage" ? "working-tree" : "staged";
118
+ if (options.guardAvailable !== true) return unavailable(options, "mutation-guard-unavailable");
119
+ if (options.sourceView.view !== expectedView || options.sourceView.availability.state !== "current" || !options.indexRevision
120
+ || !options.identity.taskId || !options.identity.taskRunId || !options.taskRevision || !options.workspaceRevision) return unavailable(options, "mutation-authority-unavailable");
121
+ const file = options.sourceView.files.find((candidate: any) => candidate.fileRef === (options.diff as any).file?.fileRef) as Record<string, any> | undefined;
122
+ if (!file || file.pathDisplay !== "exact-safe" || file.status === "C" || !["A", "M", "D", "R", "U"].includes(file.status)
123
+ || file.content?.kind !== "text" || file.content?.access !== "available") return unavailable(options, "mutation-target-unavailable");
124
+ const review = deriveReviewTarget({ diff: options.diff, taskId: options.identity.taskId, taskRunId: options.identity.taskRunId,
125
+ taskRevision: options.taskRevision, workspaceRevision: options.workspaceRevision, indexRevision: options.indexRevision });
126
+ if (!review || review.view !== expectedView || review.fileRef !== file.fileRef) return unavailable(options, "mutation-preview-incomplete");
127
+ const selected = await collectSelectedSourceFileState({ cwd: options.cwd, identity: options.identity, taskRevision: options.taskRevision,
128
+ generatedAt: options.generatedAt, isProtectedPath: options.isProtectedPath, timeoutMs: options.timeoutMs, maxGitBytes: options.maxGitBytes },
129
+ expectedView, file.fileRef, String(file.basisRef), [file.oldPath, file.path].filter((value): value is string => typeof value === "string"));
130
+ if (!selected || selected.file.fileRevision !== file.fileRevision || selected.file.pathDisplay !== "exact-safe") return unavailable(options, "mutation-target-stale");
131
+ if (options.action === "source.stage" && selected.record.kind !== "untracked" && [".", "!", "?"].includes(selected.record.worktreeStatus))
132
+ return unavailable(options, "no-unstaged-change");
133
+ if (options.action === "source.unstage" && [".", "!", "?"].includes(selected.record.indexStatus)) return unavailable(options, "no-staged-change");
134
+ const repoPaths = [selected.record.oldPath?.value, selected.record.path.value].filter((value): value is string => Boolean(value));
135
+ if (!repoPaths.length || repoPaths.some((repoPath) => options.isProtectedPath?.(selected.snapshot.repoRoot, repoPath) === true)) return unavailable(options, "protected-path");
136
+ const core = { view: expectedView, repoRef: String(file.repoRef), fileRef: String(file.fileRef), status: String(file.status), path: String(file.path),
137
+ oldPath: typeof file.oldPath === "string" ? file.oldPath : null, viewRevision: review.viewRevision, fileRevision: review.fileRevision,
138
+ baseDigest: review.baseDigest, currentDigest: review.currentDigest };
139
+ let workspacePreimage: string, indexPreimage: string, patchAuthority: ExactGitPatchAuthority | null = null;
140
+ try {
141
+ workspacePreimage = collectSelectedWorkspacePreimage(selected.snapshot.repoRoot, repoPaths);
142
+ indexPreimage = await collectIndexPreimage(selected.snapshot.repoRoot, options.timeoutMs, options.maxGitBytes);
143
+ const projectedHunkRefs = (options.diff.hunks as Array<Record<string, unknown>>).map((hunk) => String(hunk.hunkRef));
144
+ if (selected.record.kind === "ordinary" && !selected.record.oldPath && core.status === "M" && projectedHunkRefs.length > 0
145
+ && (options.diff as any).truncation?.truncated === false && (options.diff as any).redaction?.applied === false) {
146
+ const exact = await collectExactGitPatchAuthority({ snapshot: selected.snapshot, record: selected.record,
147
+ view: options.action === "source.stage" ? "unstaged" : "staged",
148
+ fileRef: core.fileRef, timeoutMs: options.timeoutMs, maxBytes: options.maxGitBytes });
149
+ if (exact && exact.hunks.every((hunk) => projectedHunkRefs.includes(hunk.hunkRef))) patchAuthority = exact;
150
+ }
151
+ } catch { return unavailable(options, "mutation-preimage-unavailable"); }
152
+ const target: SourceMutationTarget = { view: expectedView, repoRef: core.repoRef, fileRef: core.fileRef, diffRef: review.diffRef,
153
+ status: core.status as SourceMutationTarget["status"], path: core.path, oldPath: core.oldPath,
154
+ effect: options.action === "source.stage" ? "copy-worktree-to-index" : "restore-index-from-head", taskRevision: options.taskRevision,
155
+ workspaceRevision: options.workspaceRevision, indexRevision: options.indexRevision, viewRevision: review.viewRevision, fileRevision: review.fileRevision,
156
+ workspacePreimage, indexPreimage, patchPreimage: review.patchPreimage, contentDigest: review.contentDigest,
157
+ hunkRefs: patchAuthority?.hunks.map((hunk) => hunk.hunkRef) ?? [] };
158
+ validateSourceMutationTarget(target);
159
+ return { projection: { schemaVersion: 1, version: "piagent-webui-source-mutation-v1", generatedAt: options.generatedAt ?? new Date().toISOString(),
160
+ identity: structuredClone(options.identity), action: options.action, state: "ready", target, reasonCode: null,
161
+ health: { state: "ok", reasonCode: null, message: null } },
162
+ authority: { repoRoot: selected.snapshot.repoRoot, repoPaths, headOid: selected.snapshot.headOid,
163
+ indexStatus: selected.record.indexStatus, worktreeStatus: selected.record.worktreeStatus, target, patchAuthority } };
164
+ }