@piagent/platform 1.3.0 → 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 (304) hide show
  1. package/CHANGELOG.md +56 -3
  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 +10 -2
  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 +11 -7
  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/capability-verification-cache.js +280 -0
  140. package/packages/piagent-core/capabilities/runtime-integrity.js +153 -1
  141. package/packages/piagent-core/extensions/document-intake.ts +24 -10
  142. package/packages/piagent-core/extensions/piagent-guard.ts +90 -73
  143. package/packages/piagent-core/extensions/redaction-core.d.ts +2 -0
  144. package/packages/piagent-core/extensions/state-retention.js +77 -44
  145. package/packages/piagent-core/extensions/task-journal.js +30 -1
  146. package/packages/piagent-core/extensions/task-state.js +33 -36
  147. package/packages/piagent-core/mcp/mcp-server-catalog.js +5 -5
  148. package/packages/piagent-core/package.json +1 -1
  149. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -2
  150. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +5 -2
  151. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +20 -19
  152. package/packages/piagent-core/runtime/hooks/tool-result-metadata.ts +13 -0
  153. package/packages/piagent-core/runtime/input/chat-images.ts +2 -2
  154. package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +94 -0
  155. package/packages/piagent-core/runtime/inspection/approval-broker.ts +318 -0
  156. package/packages/piagent-core/runtime/inspection/commit-summary-projection.ts +59 -0
  157. package/packages/piagent-core/runtime/inspection/context-telemetry-inspection.ts +37 -0
  158. package/packages/piagent-core/runtime/inspection/criteria-links.ts +295 -0
  159. package/packages/piagent-core/runtime/inspection/diff-projection.ts +499 -0
  160. package/packages/piagent-core/runtime/inspection/git-filter-safety.ts +38 -0
  161. package/packages/piagent-core/runtime/inspection/git-numstat-adapter.ts +71 -0
  162. package/packages/piagent-core/runtime/inspection/git-status-adapter.ts +493 -0
  163. package/packages/piagent-core/runtime/inspection/git-tree-adapter.ts +108 -0
  164. package/packages/piagent-core/runtime/inspection/mcp-control.ts +10 -0
  165. package/packages/piagent-core/runtime/inspection/mutation-provenance-contract.ts +131 -0
  166. package/packages/piagent-core/runtime/inspection/mutation-provenance-recorder.ts +48 -0
  167. package/packages/piagent-core/runtime/inspection/mutation-provenance-store.ts +161 -0
  168. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +36 -0
  169. package/packages/piagent-core/runtime/inspection/review-state-contract.ts +108 -0
  170. package/packages/piagent-core/runtime/inspection/review-state-projection.ts +93 -0
  171. package/packages/piagent-core/runtime/inspection/review-state-store.ts +112 -0
  172. package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +262 -0
  173. package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +103 -0
  174. package/packages/piagent-core/runtime/inspection/source-change-projection.ts +471 -0
  175. package/packages/piagent-core/runtime/inspection/source-evidence-contract.ts +139 -0
  176. package/packages/piagent-core/runtime/inspection/source-evidence-store.ts +315 -0
  177. package/packages/piagent-core/runtime/inspection/source-handoff-store.ts +70 -0
  178. package/packages/piagent-core/runtime/inspection/source-inspection-plan.ts +34 -0
  179. package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +164 -0
  180. package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +168 -0
  181. package/packages/piagent-core/runtime/inspection/source-open-target.ts +38 -0
  182. package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +147 -0
  183. package/packages/piagent-core/runtime/inspection/task-baseline-start-capture.ts +22 -0
  184. package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +160 -0
  185. package/packages/piagent-core/runtime/inspection/task-control-journal.ts +131 -0
  186. package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +95 -0
  187. package/packages/piagent-core/runtime/inspection/task-provenance-projection.ts +60 -0
  188. package/packages/piagent-core/runtime/inspection/task-recovery-timeline.ts +88 -0
  189. package/packages/piagent-core/runtime/inspection/task-run-index.ts +87 -0
  190. package/packages/piagent-core/runtime/inspection/task-source-projection.ts +401 -0
  191. package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +101 -0
  192. package/packages/piagent-core/runtime/inspection/text-diff.ts +60 -0
  193. package/packages/piagent-core/runtime/inspection/verifier-snapshot-contract.ts +114 -0
  194. package/packages/piagent-core/runtime/inspection/verifier-snapshot-store.ts +189 -0
  195. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +320 -0
  196. package/packages/piagent-core/runtime/inspection/workspace-file-reader.ts +132 -0
  197. package/packages/piagent-core/runtime/inspection/workspace-source-projection.ts +96 -0
  198. package/packages/piagent-core/runtime/inspection/zero-turn-conformance.ts +175 -0
  199. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +68 -4
  200. package/packages/piagent-core/runtime/policy/source-index-transaction.ts +173 -0
  201. package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +42 -0
  202. package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +184 -0
  203. package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +105 -0
  204. package/packages/piagent-core/runtime/product/activity-inspector-footer.ts +5 -3
  205. package/packages/piagent-core/runtime/product/activity-inspector.ts +85 -343
  206. package/packages/piagent-core/runtime/recovery/resume-state.ts +38 -1
  207. package/packages/piagent-core/runtime/registration/activity-inspector-command.ts +68 -14
  208. package/packages/piagent-core/runtime/registration/task-start-tool.ts +13 -14
  209. package/packages/piagent-core/runtime/session/model-authorship-state.ts +31 -3
  210. package/packages/piagent-core/runtime/session/runtime-state.ts +11 -1
  211. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +172 -0
  212. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +1 -0
  213. package/packages/piagent-webui/dist/client/index.html +16 -0
  214. package/packages/piagent-webui/extension/attachment-store.ts +323 -0
  215. package/packages/piagent-webui/extension/held-message-queue.ts +311 -0
  216. package/packages/piagent-webui/extension/lifecycle-controller.ts +408 -0
  217. package/packages/piagent-webui/extension/lifecycle-event-adapter.ts +23 -0
  218. package/packages/piagent-webui/extension/piagent-webui.ts +470 -0
  219. package/packages/piagent-webui/extension/review-controller.ts +160 -0
  220. package/packages/piagent-webui/extension/same-session-bridge.ts +500 -0
  221. package/packages/piagent-webui/extension/session-options-controller.ts +281 -0
  222. package/packages/piagent-webui/extension/session-stream-adapter.ts +249 -0
  223. package/packages/piagent-webui/extension/source-mutation-controller.ts +215 -0
  224. package/packages/piagent-webui/extension/source-open-controller.ts +136 -0
  225. package/packages/piagent-webui/extension/source-revert-controller.ts +189 -0
  226. package/packages/piagent-webui/extension/terminal-session-adapter.ts +73 -0
  227. package/packages/piagent-webui/extension/vscode-handoff.ts +51 -0
  228. package/packages/piagent-webui/gateway/control-socket.ts +92 -0
  229. package/packages/piagent-webui/gateway/gateway-events.ts +60 -0
  230. package/packages/piagent-webui/gateway/gateway-protocol-service.ts +64 -0
  231. package/packages/piagent-webui/gateway/gateway-service.ts +215 -0
  232. package/packages/piagent-webui/gateway/gateway-session-stream.ts +110 -0
  233. package/packages/piagent-webui/gateway/mcp-auth-broker.ts +158 -0
  234. package/packages/piagent-webui/gateway/native-project-picker.ts +29 -0
  235. package/packages/piagent-webui/gateway/pi-host.ts +41 -0
  236. package/packages/piagent-webui/gateway/profile-state.ts +1 -0
  237. package/packages/piagent-webui/gateway/project-registry.ts +108 -0
  238. package/packages/piagent-webui/gateway/provider-auth-broker.ts +172 -0
  239. package/packages/piagent-webui/gateway/session-catalog.ts +128 -0
  240. package/packages/piagent-webui/gateway/session-command-controller.ts +237 -0
  241. package/packages/piagent-webui/gateway/session-command-store.ts +224 -0
  242. package/packages/piagent-webui/gateway/session-inspection-registry.ts +204 -0
  243. package/packages/piagent-webui/gateway/session-lease-store.ts +1 -0
  244. package/packages/piagent-webui/gateway/session-metadata-store.ts +152 -0
  245. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +485 -0
  246. package/packages/piagent-webui/ownership/gateway-runtime-context.ts +8 -0
  247. package/packages/piagent-webui/ownership/profile-state.ts +117 -0
  248. package/packages/piagent-webui/ownership/session-lease-store.ts +329 -0
  249. package/packages/piagent-webui/ownership/session-refs.ts +8 -0
  250. package/packages/piagent-webui/package.json +33 -0
  251. package/packages/piagent-webui/server/README.md +4 -0
  252. package/packages/piagent-webui/server/benchmark-release-monitor.ts +230 -0
  253. package/packages/piagent-webui/server/core-inspection-provider.ts +403 -0
  254. package/packages/piagent-webui/server/gateway-websocket.ts +143 -0
  255. package/packages/piagent-webui/server/http-security.ts +27 -0
  256. package/packages/piagent-webui/server/ipc-read-model-client.ts +89 -0
  257. package/packages/piagent-webui/server/loopback-server.ts +368 -0
  258. package/packages/piagent-webui/server/read-model-provider.ts +37 -0
  259. package/packages/piagent-webui/server/read-only-router.ts +105 -0
  260. package/packages/piagent-webui/server/session-auth.ts +88 -0
  261. package/packages/piagent-webui/server/sidecar-main.ts +86 -0
  262. package/packages/piagent-webui/server/sse-hub.ts +76 -0
  263. package/packages/piagent-webui/server/static-bundle.ts +40 -0
  264. package/packages/piagent-webui/server/transcript-projection.ts +110 -0
  265. package/schemas/mutation-provenance-record.schema.json +100 -0
  266. package/schemas/piagent-webui/approval-v1.schema.json +654 -0
  267. package/schemas/piagent-webui/attachment-v1.schema.json +267 -0
  268. package/schemas/piagent-webui/capabilities-v1.schema.json +729 -0
  269. package/schemas/piagent-webui/catalog-v1.json +240 -0
  270. package/schemas/piagent-webui/commit-summary-v1.schema.json +63 -0
  271. package/schemas/piagent-webui/common-v1.schema.json +479 -0
  272. package/schemas/piagent-webui/control-command-v1.schema.json +1780 -0
  273. package/schemas/piagent-webui/diff-v1.schema.json +826 -0
  274. package/schemas/piagent-webui/gateway-capabilities-v1.schema.json +136 -0
  275. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +456 -0
  276. package/schemas/piagent-webui/handoff-history-v1.schema.json +92 -0
  277. package/schemas/piagent-webui/model-catalog-v1.schema.json +95 -0
  278. package/schemas/piagent-webui/queue-v1.schema.json +99 -0
  279. package/schemas/piagent-webui/recovery-history-v1.schema.json +144 -0
  280. package/schemas/piagent-webui/release-monitor-v1.schema.json +173 -0
  281. package/schemas/piagent-webui/review-state-v1.schema.json +206 -0
  282. package/schemas/piagent-webui/runtime-event-v2.schema.json +5206 -0
  283. package/schemas/piagent-webui/session-catalog-v1.schema.json +222 -0
  284. package/schemas/piagent-webui/session-command-v1.schema.json +390 -0
  285. package/schemas/piagent-webui/snapshot-v1.schema.json +2622 -0
  286. package/schemas/piagent-webui/source-change-v1.schema.json +763 -0
  287. package/schemas/piagent-webui/source-mutation-v1.schema.json +96 -0
  288. package/schemas/piagent-webui/source-revert-v1.schema.json +130 -0
  289. package/schemas/piagent-webui/subagent-tree-v1.schema.json +88 -0
  290. package/schemas/piagent-webui/task-index-v1.schema.json +74 -0
  291. package/schemas/piagent-webui/task-timeline-v1.schema.json +49 -0
  292. package/schemas/piagent-webui/transcript-v1.schema.json +243 -0
  293. package/schemas/task-baseline-manifest.schema.json +122 -0
  294. package/schemas/verifier-file-snapshot.schema.json +81 -0
  295. package/scripts/check-architecture.mjs +1 -0
  296. package/scripts/check-doc-languages.mjs +19 -2
  297. package/scripts/check-third-party-neutrality.mjs +43 -0
  298. package/scripts/install-global.sh +1 -1
  299. package/scripts/piagent-cli.mjs +17 -3
  300. package/scripts/piagent-dashboard.mjs +208 -0
  301. package/scripts/piagent-webui-launcher.mjs +121 -0
  302. package/scripts/typescript-loader.mjs +26 -1
  303. package/scripts/verify-local.sh +253 -0
  304. package/scripts/verify-release-identity.mjs +4 -1
@@ -0,0 +1,471 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import {
4
+ GitInspectionError,
5
+ type GitStatusRecord,
6
+ type GitStatusSnapshot,
7
+ collectGitStatus,
8
+ collectGitStatusForPaths,
9
+ readGitBlobDigests
10
+ } from "./git-status-adapter.ts";
11
+ import { collectNumstats, type LineStat } from "./git-numstat-adapter.ts";
12
+ import { hashWorkspaceFile, inspectWorkspaceEntry, readWorkspaceFile, readWorkspaceLink } from "./workspace-file-reader.ts";
13
+ import { collectTaskSourceChangeView } from "./task-source-projection.ts";
14
+ import { sourceInspectionPlan } from "./source-inspection-plan.ts";
15
+ const DEFAULT_PAGE_LIMIT = 300;
16
+ const MAX_PAGE_LIMIT = 2_000;
17
+ const MAX_TEXT_BYTES = 4 * 1024 * 1024;
18
+ export type WebUiIdentity = {
19
+ projectRef: string;
20
+ runtimeInstanceId: string;
21
+ sessionRef: string;
22
+ taskId: string | null;
23
+ taskRunId: string | null;
24
+ agentOperationId: string | null;
25
+ toolCallId: string | null;
26
+ };
27
+ export type SourceProjectionOptions = {
28
+ cwd: string;
29
+ identity: WebUiIdentity;
30
+ generatedAt?: string;
31
+ pageLimit?: number;
32
+ taskRevision?: string | null;
33
+ isProtectedPath?: (repoRoot: string, repoPath: string) => boolean;
34
+ timeoutMs?: number;
35
+ maxGitBytes?: number;
36
+ };
37
+ export type SourceChangeDocument = Record<string, unknown> & {
38
+ view: "task" | "working-tree" | "staged";
39
+ viewRevision: string;
40
+ files: Array<Record<string, unknown>>;
41
+ page: { cursor: string | null; nextCursor: string | null; total: number; returned: number; truncated: boolean };
42
+ availability: { state: "current" | "stale" | "unavailable"; reasonCode: string | null; message: string | null };
43
+ };
44
+ export type SourceChangeViews = {
45
+ task: SourceChangeDocument | null;
46
+ workingTree: SourceChangeDocument;
47
+ staged: SourceChangeDocument;
48
+ };
49
+ export type SelectedSourceFileState = {
50
+ snapshot: GitStatusSnapshot;
51
+ record: GitStatusRecord;
52
+ file: Record<string, unknown>;
53
+ };
54
+ function visibleRecords(snapshot: GitStatusSnapshot): GitStatusRecord[] {
55
+ return snapshot.records.filter((record) => {
56
+ const paths = [record.path.value, record.oldPath?.value].filter((value): value is string => Boolean(value));
57
+ return !paths.some((value) => value === ".pi/piagent-state" || value.startsWith(".pi/piagent-state/"));
58
+ });
59
+ }
60
+ function hash(value: string | Buffer): string {
61
+ return createHash("sha256").update(value).digest("hex");
62
+ }
63
+ function token(prefix: string, value: string | Buffer): string {
64
+ return `${prefix}.${hash(value)}`;
65
+ }
66
+ export function sourceRepoRef(snapshot: GitStatusSnapshot): string {
67
+ return token("repo", snapshot.repoRoot);
68
+ }
69
+ export function sourceFileRef(snapshot: GitStatusSnapshot, record: GitStatusRecord): string {
70
+ return token("file", `${snapshot.repoDigest}\0${record.path.digest}`);
71
+ }
72
+ function digest(value: Buffer): string {
73
+ return `sha256:${hash(value)}`;
74
+ }
75
+ function cleanTimestamp(value: string | undefined): string {
76
+ const timestamp = value ?? new Date().toISOString();
77
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(timestamp)) {
78
+ throw new Error("generatedAt must be a millisecond UTC timestamp");
79
+ }
80
+ return timestamp;
81
+ }
82
+ function indexRevision(snapshot: GitStatusSnapshot): string {
83
+ const evidence = visibleRecords(snapshot)
84
+ .filter((record) => record.kind === "unmerged" || ![".", "?", "!"].includes(record.indexStatus))
85
+ .map((record) => [record.path.digest, record.oldPath?.digest ?? null, record.indexStatus, record.indexMode, record.indexObject]);
86
+ return token("index", JSON.stringify([snapshot.headOid, evidence]));
87
+ }
88
+ function workspaceRevision(snapshot: GitStatusSnapshot, isProtectedPath?: SourceProjectionOptions["isProtectedPath"], digests?: Map<string, string | null>): string {
89
+ const evidence = visibleRecords(snapshot)
90
+ .filter((record) => record.kind !== "ignored")
91
+ .map((record) => {
92
+ const repoPath = record.path.value;
93
+ const status = [record.kind, record.indexStatus, record.worktreeStatus, record.oldPath?.digest ?? null,
94
+ record.headMode, record.indexMode, record.worktreeMode, record.headObject, record.indexObject];
95
+ if (!repoPath) return [record.path.digest, ...status, "unsafe-path"];
96
+ try {
97
+ const stat = inspectWorkspaceEntry(snapshot.repoRoot, repoPath);
98
+ if (isProtectedPath?.(snapshot.repoRoot, repoPath) === true
99
+ || (record.oldPath?.value && isProtectedPath?.(snapshot.repoRoot, record.oldPath.value) === true)) {
100
+ return [record.path.digest, ...status, "protected", stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs];
101
+ }
102
+ if (stat.isSymbolicLink()) return [record.path.digest, ...status, "symlink", readWorkspaceLink(snapshot.repoRoot, repoPath)];
103
+ if (stat.isFile()) {
104
+ const raw = hashWorkspaceFile(snapshot.repoRoot, repoPath);
105
+ const value = `sha256:${raw}`;
106
+ digests?.set(repoPath, value);
107
+ return [record.path.digest, ...status, "file", raw];
108
+ }
109
+ return [record.path.digest, ...status, "other", stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs];
110
+ } catch {
111
+ return [record.path.digest, ...status, "absent"];
112
+ }
113
+ });
114
+ return token("workspace", JSON.stringify([snapshot.repoDigest, snapshot.headOid, evidence]));
115
+ }
116
+ function isConflict(record: GitStatusRecord): boolean {
117
+ return record.kind === "unmerged" || record.indexStatus === "U" || record.worktreeStatus === "U";
118
+ }
119
+ function displayStatus(record: GitStatusRecord, view: "working-tree" | "staged"): "A" | "M" | "D" | "R" | "U" | "C" | null {
120
+ if (isConflict(record)) return "C";
121
+ if (view === "staged") {
122
+ if ([".", "?", "!"].includes(record.indexStatus)) return null;
123
+ if (record.indexStatus === "R") return "R";
124
+ if (record.indexStatus === "A" || record.indexStatus === "C") return "A";
125
+ if (record.indexStatus === "D") return "D";
126
+ return "M";
127
+ }
128
+ if (record.kind === "ignored") return null;
129
+ if (record.kind === "untracked") return "U";
130
+ if (record.kind === "renamed" || record.indexStatus === "R" || record.worktreeStatus === "R") return "R";
131
+ if (record.worktreeStatus === "D" || record.indexStatus === "D") return "D";
132
+ if (record.indexStatus === "A" || record.worktreeStatus === "A" || record.indexStatus === "C" || record.worktreeStatus === "C") return "A";
133
+ return "M";
134
+ }
135
+ function inspectUntracked(repoRoot: string, repoPath: string): { stat: LineStat; kind: "text" | "binary" | "symlink" | "unknown"; digest: string | null } {
136
+ try {
137
+ const fileStat = inspectWorkspaceEntry(repoRoot, repoPath);
138
+ if (fileStat.isSymbolicLink()) return { stat: { additions: null, deletions: null, binary: false }, kind: "symlink", digest: null };
139
+ if (!fileStat.isFile() || fileStat.size > MAX_TEXT_BYTES) {
140
+ return { stat: { additions: null, deletions: null, binary: false }, kind: "unknown", digest: null };
141
+ }
142
+ const content = readWorkspaceFile(repoRoot, repoPath, MAX_TEXT_BYTES);
143
+ if (content.includes(0)) return { stat: { additions: null, deletions: null, binary: true }, kind: "binary", digest: digest(content) };
144
+ const text = content.toString("utf8");
145
+ const additions = text.length === 0 ? 0 : text.split(/\r?\n/).length - (text.endsWith("\n") ? 1 : 0);
146
+ return { stat: { additions, deletions: 0, binary: false }, kind: "text", digest: digest(content) };
147
+ } catch {
148
+ return { stat: { additions: null, deletions: null, binary: false }, kind: "unknown", digest: null };
149
+ }
150
+ }
151
+ function currentFileDigest(repoRoot: string, repoPath: string): string | null {
152
+ try {
153
+ const stat = inspectWorkspaceEntry(repoRoot, repoPath);
154
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TEXT_BYTES) return null;
155
+ return digest(readWorkspaceFile(repoRoot, repoPath, MAX_TEXT_BYTES));
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ function modeKind(record: GitStatusRecord, view: "working-tree" | "staged", binary: boolean): "text" | "binary" | "symlink" | "submodule" | "unknown" {
162
+ if (record.submodule?.startsWith("S") || record.headMode === "160000" || record.indexMode === "160000" || record.worktreeMode === "160000") return "submodule";
163
+ const selected = view === "staged" ? record.indexMode : record.worktreeMode;
164
+ const mode = selected === "000000" ? record.headMode : selected;
165
+ if (mode === "120000") return "symlink";
166
+ if (binary) return "binary";
167
+ return mode === "000000" ? "unknown" : "text";
168
+ }
169
+
170
+ function unavailableStats(reasonCode: string) {
171
+ return { state: "unavailable", additions: null, deletions: null, reasonCode };
172
+ }
173
+
174
+ function projectFile(
175
+ snapshot: GitStatusSnapshot,
176
+ record: GitStatusRecord,
177
+ view: "working-tree" | "staged",
178
+ basisRef: string,
179
+ stats: Map<string, LineStat>,
180
+ blobDigests: Map<string, string>,
181
+ isProtectedPath?: SourceProjectionOptions["isProtectedPath"],
182
+ workspaceDigests?: Map<string, string | null>
183
+ ): Record<string, unknown> | null {
184
+ const status = displayStatus(record, view);
185
+ const repoPath = record.path.value;
186
+ if (!status || !repoPath || !record.path.display || (status === "R" && !record.oldPath?.display)) return null;
187
+ const conflict = isConflict(record);
188
+ const protectedPath = isProtectedPath?.(snapshot.repoRoot, repoPath) === true
189
+ || (record.oldPath?.value ? isProtectedPath?.(snapshot.repoRoot, record.oldPath.value) === true : false);
190
+ let stat = stats.get(repoPath);
191
+ let untracked: ReturnType<typeof inspectUntracked> | null = null;
192
+ if (record.kind === "untracked" && !protectedPath) {
193
+ untracked = inspectUntracked(snapshot.repoRoot, repoPath);
194
+ stat = untracked.stat;
195
+ }
196
+ const kind = untracked?.kind ?? modeKind(record, view, stat?.binary === true);
197
+ const statsProjection = conflict
198
+ ? unavailableStats("git-conflict")
199
+ : protectedPath
200
+ ? unavailableStats("protected-path")
201
+ : kind === "binary" || kind === "symlink" || kind === "submodule" || kind === "unknown" || !stat || stat.additions === null || stat.deletions === null
202
+ ? unavailableStats(kind === "binary" ? "binary-content" : kind === "symlink" ? "symlink-content" : kind === "submodule" ? "submodule-content" : "line-stats-unavailable")
203
+ : { state: "exact", additions: stat.additions, deletions: stat.deletions, reasonCode: null };
204
+ const baseDigest = status === "A" || status === "U" || protectedPath || !record.headObject
205
+ ? null
206
+ : blobDigests.get(record.headObject) ?? null;
207
+ const currentDigest = status === "D" || protectedPath
208
+ ? null
209
+ : untracked?.digest
210
+ ?? (view === "working-tree"
211
+ ? workspaceDigests?.has(repoPath) ? workspaceDigests.get(repoPath) ?? null : currentFileDigest(snapshot.repoRoot, repoPath)
212
+ : record.indexObject ? blobDigests.get(record.indexObject) ?? null : null);
213
+ const repoRef = sourceRepoRef(snapshot);
214
+ const fileRef = sourceFileRef(snapshot, record);
215
+ const fileRevision = token("file-rev", JSON.stringify([view, status, record.indexObject, currentDigest, stat]));
216
+ return {
217
+ repoRef,
218
+ basisRef,
219
+ fileRef,
220
+ fileRevision,
221
+ path: record.path.display,
222
+ oldPath: status === "R" ? record.oldPath?.display ?? null : null,
223
+ pathDisplay: record.path.displayMode,
224
+ status,
225
+ git: { indexStatus: record.indexStatus, worktreeStatus: record.worktreeStatus, conflict },
226
+ baseDigest,
227
+ currentDigest,
228
+ content: protectedPath
229
+ ? { kind: "unknown", access: "protected", reasonCode: "protected-path" }
230
+ : kind === "unknown"
231
+ ? { kind, access: "unavailable", reasonCode: "content-kind-unavailable" }
232
+ : { kind, access: "available", reasonCode: null },
233
+ stats: statsProjection,
234
+ provenance: {
235
+ classification: "post-baseline-unattributed",
236
+ evidence: "unavailable",
237
+ baselineEvidenceRef: null,
238
+ mutationEvidenceRefs: [],
239
+ reasonCode: "provenance-evidence-unavailable"
240
+ },
241
+ criterionIds: [],
242
+ verifierAttemptIds: [],
243
+ health: protectedPath
244
+ ? { state: "degraded", reasonCode: "protected-path", message: "Content is protected by policy" }
245
+ : { state: "ok", reasonCode: null, message: null }
246
+ };
247
+ }
248
+
249
+ export async function collectSelectedSourceFileState(
250
+ options: SourceProjectionOptions,
251
+ view: "working-tree" | "staged",
252
+ fileRef: string,
253
+ basisRef: string,
254
+ targetRepoPaths?: string[]
255
+ ): Promise<SelectedSourceFileState | null> {
256
+ const snapshot = targetRepoPaths?.length
257
+ ? await collectGitStatusForPaths(options.cwd, targetRepoPaths, { timeoutMs: options.timeoutMs, maxBytes: options.maxGitBytes })
258
+ : await collectGitStatus(options.cwd, { timeoutMs: options.timeoutMs, maxBytes: options.maxGitBytes });
259
+ const record = visibleRecords(snapshot).find((candidate) => sourceFileRef(snapshot, candidate) === fileRef);
260
+ if (!record || !displayStatus(record, view) || !record.path.value) return null;
261
+ const repoPaths = [record.oldPath?.value, record.path.value].filter((value): value is string => Boolean(value));
262
+ const protectedPath = repoPaths.some((repoPath) => options.isProtectedPath?.(snapshot.repoRoot, repoPath) === true);
263
+ const stats = await collectNumstats(snapshot, view, protectedPath ? [] : repoPaths, options.timeoutMs, options.maxGitBytes);
264
+ const objectIds = protectedPath ? [] : [record.headObject, record.indexObject].filter((value): value is string => Boolean(value));
265
+ const blobDigests = await readGitBlobDigests(snapshot.repoRoot, objectIds, { timeoutMs: options.timeoutMs, maxBlobBytes: MAX_TEXT_BYTES });
266
+ const file = projectFile(snapshot, record, view, basisRef, stats, blobDigests, options.isProtectedPath);
267
+ return file ? { snapshot, record, file } : null;
268
+ }
269
+
270
+ function baseDocument(
271
+ identity: WebUiIdentity,
272
+ view: SourceChangeDocument["view"],
273
+ viewRevision: string,
274
+ generatedAt: string,
275
+ basis: Record<string, unknown>,
276
+ files: Array<Record<string, unknown>>,
277
+ pageLimit: number,
278
+ issues: Array<Record<string, unknown>> = []
279
+ ): SourceChangeDocument {
280
+ const ordered = [...files].sort((left, right) => String(left.path).localeCompare(String(right.path), "en"));
281
+ const returned = ordered.slice(0, pageLimit);
282
+ const truncated = returned.length < ordered.length;
283
+ const restricted = ordered.filter((file) => (file.content as any)?.access === "protected").length;
284
+ const degraded = issues.length > 0 || restricted > 0;
285
+ return {
286
+ schemaVersion: 1,
287
+ version: "piagent-webui-source-change-v1",
288
+ generatedAt,
289
+ identity,
290
+ view,
291
+ viewRevision,
292
+ bases: [basis],
293
+ availability: { state: "current", reasonCode: null, message: null },
294
+ files: returned,
295
+ page: { cursor: null, nextCursor: truncated ? token("page", `${viewRevision}:${pageLimit}`) : null, total: ordered.length, returned: returned.length, truncated },
296
+ truncationReason: truncated ? "page-limit" : null,
297
+ redaction: { applied: restricted > 0, valuesRemoved: restricted, truncated: false },
298
+ health: degraded
299
+ ? { state: "degraded", reasonCode: issues.length ? "path-projection-unavailable" : "source-content-restricted", message: issues.length ? "Some Git paths could not be projected safely" : "Some source content is protected by policy" }
300
+ : { state: "ok", reasonCode: null, message: null },
301
+ issues
302
+ };
303
+ }
304
+
305
+ function unavailableTaskDocument(options: SourceProjectionOptions, generatedAt: string, workspaceRev: string, repoRef: string): SourceChangeDocument | null {
306
+ if (!options.identity.taskId || !options.identity.taskRunId) return null;
307
+ const basisRef = token("basis", `${options.identity.taskRunId}:task:unavailable`);
308
+ const viewRevision = token("task", `${options.identity.taskRunId}:baseline-unavailable:${workspaceRev}`);
309
+ return {
310
+ schemaVersion: 1,
311
+ version: "piagent-webui-source-change-v1",
312
+ generatedAt,
313
+ identity: options.identity,
314
+ view: "task",
315
+ viewRevision,
316
+ bases: [{
317
+ basisRef,
318
+ repoRef,
319
+ view: "task",
320
+ state: "unavailable",
321
+ reasonCode: "task-baseline-content-unavailable",
322
+ basisRevision: token("basis-rev", `${options.identity.taskRunId}:unavailable`),
323
+ taskRunId: options.identity.taskRunId,
324
+ taskRevision: options.taskRevision ?? "task-revision.unavailable",
325
+ workspaceRevision: workspaceRev,
326
+ baselineManifestRef: null,
327
+ baselineTreeDigest: null
328
+ }],
329
+ availability: { state: "unavailable", reasonCode: "task-baseline-content-unavailable", message: "Exact task changes require Task Baseline Manifest evidence" },
330
+ files: [],
331
+ page: { cursor: null, nextCursor: null, total: 0, returned: 0, truncated: false },
332
+ truncationReason: null,
333
+ redaction: { applied: false, valuesRemoved: 0, truncated: false },
334
+ health: { state: "unavailable", reasonCode: "task-baseline-content-unavailable", message: "Task baseline content evidence is not available" },
335
+ issues: []
336
+ };
337
+ }
338
+
339
+ export async function collectSourceChangeViewsStrict(options: SourceProjectionOptions): Promise<SourceChangeViews> {
340
+ const generatedAt = cleanTimestamp(options.generatedAt);
341
+ const pageLimit = Number.isInteger(options.pageLimit) ? Math.max(1, Math.min(MAX_PAGE_LIMIT, options.pageLimit as number)) : DEFAULT_PAGE_LIMIT;
342
+ const gitOptions = { timeoutMs: options.timeoutMs, maxBytes: options.maxGitBytes };
343
+ const before = await collectGitStatus(options.cwd, gitOptions);
344
+ const beforeWorkspaceRev = workspaceRevision(before, options.isProtectedPath);
345
+ const beforeIndexRev = indexRevision(before);
346
+ const inspection = sourceInspectionPlan(before, options.isProtectedPath);
347
+ const [workingStats, stagedStats, blobDigests] = await Promise.all([
348
+ collectNumstats(before, "working-tree", inspection.workingTreePaths, options.timeoutMs, options.maxGitBytes),
349
+ collectNumstats(before, "staged", inspection.stagedPaths, options.timeoutMs, options.maxGitBytes),
350
+ readGitBlobDigests(before.repoRoot, inspection.objectIds, { timeoutMs: options.timeoutMs, maxBlobBytes: MAX_TEXT_BYTES })
351
+ ]);
352
+ const after = await collectGitStatus(before.repoRoot, gitOptions);
353
+ const repoRef = sourceRepoRef(before);
354
+ const workspaceDigests = new Map<string, string | null>();
355
+ const workspaceRev = workspaceRevision(after, options.isProtectedPath, workspaceDigests);
356
+ const indexRev = indexRevision(after);
357
+ const stale = beforeWorkspaceRev !== workspaceRev || beforeIndexRev !== indexRev;
358
+ const project = (view: "working-tree" | "staged", stats: Map<string, LineStat>) => {
359
+ const basisRef = token("basis", `${repoRef}:${view}:${before.headOid ?? "unborn"}:${view === "staged" ? indexRev : workspaceRev}`);
360
+ const viewRevision = view === "staged" ? indexRev : workspaceRev;
361
+ const basis = {
362
+ basisRef,
363
+ repoRef,
364
+ view,
365
+ state: stale ? "stale" : "current",
366
+ reasonCode: stale ? "git-race" : null,
367
+ basisRevision: token("basis-rev", `${basisRef}:${viewRevision}`),
368
+ headState: before.headState,
369
+ headRef: before.headOid ? `git.${before.headOid}` : null,
370
+ ...(view === "working-tree"
371
+ ? { workspaceRevision: workspaceRev, indexRevision: indexRev }
372
+ : { indexRevision: indexRev, workspaceRevision: workspaceRev })
373
+ };
374
+ if (stale) {
375
+ const document = baseDocument(options.identity, view, viewRevision, generatedAt, basis, [], pageLimit);
376
+ document.availability = { state: "stale", reasonCode: "git-race", message: "Git state changed during collection; retry the projection" };
377
+ document.health = { state: "degraded", reasonCode: "git-race", message: "Git state changed during collection" };
378
+ return document;
379
+ }
380
+ const issues: Array<Record<string, unknown>> = [];
381
+ const files = visibleRecords(before).flatMap((record) => {
382
+ const file = projectFile(before, record, view, basisRef, stats, blobDigests, options.isProtectedPath, workspaceDigests);
383
+ if (file) return [file];
384
+ if (displayStatus(record, view) && !record.path.display) {
385
+ issues.push({
386
+ issueRef: token("issue", `${view}:${record.path.digest}`),
387
+ severity: "warning",
388
+ code: "path-projection-unavailable",
389
+ message: "A Git path could not be represented safely",
390
+ relatedRefs: []
391
+ });
392
+ }
393
+ return [];
394
+ });
395
+ return baseDocument(options.identity, view, viewRevision, generatedAt, basis, files, pageLimit, issues);
396
+ };
397
+
398
+ return {
399
+ task: await collectTaskSourceChangeView(options) ?? unavailableTaskDocument(options, generatedAt, workspaceRev, repoRef),
400
+ workingTree: project("working-tree", workingStats),
401
+ staged: project("staged", stagedStats)
402
+ };
403
+ }
404
+
405
+ function unavailableGitDocument(
406
+ options: SourceProjectionOptions,
407
+ generatedAt: string,
408
+ view: "working-tree" | "staged",
409
+ repoRef: string,
410
+ workspaceRev: string,
411
+ indexRev: string,
412
+ reasonCode: string,
413
+ message: string
414
+ ): SourceChangeDocument {
415
+ const basisRef = token("basis", `${repoRef}:${view}:${reasonCode}`);
416
+ const basis = {
417
+ basisRef,
418
+ repoRef,
419
+ view,
420
+ state: "unavailable",
421
+ reasonCode,
422
+ basisRevision: token("basis-rev", `${basisRef}:unavailable`),
423
+ headState: "unavailable",
424
+ headRef: null,
425
+ ...(view === "working-tree"
426
+ ? { workspaceRevision: workspaceRev, indexRevision: indexRev }
427
+ : { indexRevision: indexRev, workspaceRevision: workspaceRev })
428
+ };
429
+ return {
430
+ schemaVersion: 1,
431
+ version: "piagent-webui-source-change-v1",
432
+ generatedAt,
433
+ identity: options.identity,
434
+ view,
435
+ viewRevision: view === "working-tree" ? workspaceRev : indexRev,
436
+ bases: [basis],
437
+ availability: { state: "unavailable", reasonCode, message },
438
+ files: [],
439
+ page: { cursor: null, nextCursor: null, total: 0, returned: 0, truncated: false },
440
+ truncationReason: null,
441
+ redaction: { applied: false, valuesRemoved: 0, truncated: false },
442
+ health: { state: "unavailable", reasonCode, message },
443
+ issues: []
444
+ };
445
+ }
446
+
447
+ export async function collectSourceChangeViews(options: SourceProjectionOptions): Promise<SourceChangeViews> {
448
+ try {
449
+ return await collectSourceChangeViewsStrict(options);
450
+ } catch (error) {
451
+ const generatedAt = cleanTimestamp(options.generatedAt);
452
+ const reasonCode = error instanceof GitInspectionError ? error.code : "git-collection-failed";
453
+ const message = ({
454
+ "not-git": "The selected workspace is not a Git repository",
455
+ timeout: "Git source collection timed out",
456
+ "output-limit": "Git source collection exceeded its output cap",
457
+ "git-failed": "Git source collection failed",
458
+ "invalid-output": "Git returned an invalid source status",
459
+ "git-collection-failed": "Workspace source evidence could not be read safely"
460
+ } as Record<string, string>)[reasonCode] ?? "Git source collection failed";
461
+ const root = path.resolve(options.cwd);
462
+ const repoRef = token("repo", root);
463
+ const workspaceRev = token("workspace", `${root}:${reasonCode}:unavailable`);
464
+ const indexRev = token("index", `${root}:${reasonCode}:unavailable`);
465
+ return {
466
+ task: unavailableTaskDocument(options, generatedAt, workspaceRev, repoRef),
467
+ workingTree: unavailableGitDocument(options, generatedAt, "working-tree", repoRef, workspaceRev, indexRev, reasonCode, message),
468
+ staged: unavailableGitDocument(options, generatedAt, "staged", repoRef, workspaceRev, indexRev, reasonCode, message)
469
+ };
470
+ }
471
+ }
@@ -0,0 +1,139 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const TASK_BASELINE_MANIFEST_VERSION = "piagent-task-baseline-manifest-v1" as const;
4
+ const DIGEST = /^sha256:[a-f0-9]{64}$/;
5
+ const TREE_DIGEST = /^wt-content-v2:[a-f0-9]{64}$/;
6
+ const OID = /^[a-f0-9]{40,64}$/;
7
+ const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
8
+ const PUBLIC_ID = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
9
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
10
+
11
+ export type BaselineEntryState = "blob" | "absent" | "symlink" | "submodule" | "protected" | "oversized" | "unavailable";
12
+
13
+ export type TaskBaselineEntry = {
14
+ pathRef: string;
15
+ pathDigest: string;
16
+ repoPathBase64: string | null;
17
+ state: BaselineEntryState;
18
+ reasonCode: string | null;
19
+ contentRef: string | null;
20
+ byteLength: number;
21
+ mode: string;
22
+ headObject: string | null;
23
+ indexObject: string | null;
24
+ };
25
+
26
+ export type TaskBaselineRoot = {
27
+ repoRef: string;
28
+ projectPath: string;
29
+ headState: "head" | "unborn" | "unavailable";
30
+ headOid: string | null;
31
+ workspaceRevision: string;
32
+ indexRevision: string;
33
+ state: "current" | "unavailable";
34
+ reasonCode: string | null;
35
+ entries: TaskBaselineEntry[];
36
+ };
37
+
38
+ export type TaskBaselineManifest = {
39
+ schemaVersion: 1;
40
+ version: typeof TASK_BASELINE_MANIFEST_VERSION;
41
+ taskId: string;
42
+ taskRunId: string;
43
+ sessionIdentityHash: string;
44
+ capturedAt: string;
45
+ retentionUntil: string;
46
+ baselineDigestAlgorithm: "wt-content-v2";
47
+ baselineTreeDigest: string;
48
+ captureState: "current" | "degraded" | "unavailable";
49
+ reasonCode: string | null;
50
+ limits: { maxEntries: number; maxFileBytes: number; maxTotalBytes: number; capturedBytes: number };
51
+ roots: TaskBaselineRoot[];
52
+ integrityDigest: string;
53
+ };
54
+
55
+ function plain(value: unknown): value is Record<string, unknown> {
56
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
57
+ const prototype = Object.getPrototypeOf(value);
58
+ return prototype === Object.prototype || prototype === null;
59
+ }
60
+
61
+ function canonical(value: unknown): unknown {
62
+ if (Array.isArray(value)) return value.map(canonical);
63
+ if (!plain(value)) return value;
64
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
65
+ }
66
+
67
+ export function taskBaselineManifestDigest(value: Omit<TaskBaselineManifest, "integrityDigest"> | TaskBaselineManifest): string {
68
+ const { integrityDigest: _ignored, ...payload } = value as TaskBaselineManifest;
69
+ return `sha256:${createHash("sha256").update(`task-baseline-manifest-v1\0${JSON.stringify(canonical(payload))}`).digest("hex")}`;
70
+ }
71
+
72
+ function exactKeys(value: Record<string, unknown>, keys: string[]): boolean {
73
+ const actual = Object.keys(value).sort();
74
+ const expected = [...keys].sort();
75
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
76
+ }
77
+
78
+ function reason(value: unknown): boolean {
79
+ return typeof value === "string" && /^[a-z0-9][a-z0-9.-]{0,95}$/.test(value);
80
+ }
81
+
82
+ function validEntry(value: unknown): value is TaskBaselineEntry {
83
+ if (!plain(value) || !exactKeys(value, ["pathRef", "pathDigest", "repoPathBase64", "state", "reasonCode", "contentRef", "byteLength", "mode", "headObject", "indexObject"])) return false;
84
+ if (!TOKEN.test(String(value.pathRef)) || !DIGEST.test(String(value.pathDigest))) return false;
85
+ if (value.repoPathBase64 !== null && (typeof value.repoPathBase64 !== "string" || value.repoPathBase64.length > 4096 || !/^[A-Za-z0-9_-]*$/.test(value.repoPathBase64))) return false;
86
+ const states: BaselineEntryState[] = ["blob", "absent", "symlink", "submodule", "protected", "oversized", "unavailable"];
87
+ if (!states.includes(value.state as BaselineEntryState)) return false;
88
+ if (!Number.isInteger(value.byteLength) || (value.byteLength as number) < 0 || (value.byteLength as number) > 16 * 1024 * 1024) return false;
89
+ if (!/^[0-7]{6}$/.test(String(value.mode))) return false;
90
+ if (value.headObject !== null && !OID.test(String(value.headObject))) return false;
91
+ if (value.indexObject !== null && !OID.test(String(value.indexObject))) return false;
92
+ if (["blob", "symlink"].includes(String(value.state))) return DIGEST.test(String(value.contentRef)) && value.reasonCode === null;
93
+ if (value.contentRef !== null) return false;
94
+ return ["protected", "oversized", "unavailable"].includes(String(value.state)) ? reason(value.reasonCode) : value.reasonCode === null;
95
+ }
96
+
97
+ function validRoot(value: unknown): value is TaskBaselineRoot {
98
+ if (!plain(value) || !exactKeys(value, ["repoRef", "projectPath", "headState", "headOid", "workspaceRevision", "indexRevision", "state", "reasonCode", "entries"])) return false;
99
+ if (!TOKEN.test(String(value.repoRef)) || typeof value.projectPath !== "string" || !value.projectPath || value.projectPath.length > 1024) return false;
100
+ if (!TOKEN.test(String(value.workspaceRevision)) || !TOKEN.test(String(value.indexRevision))) return false;
101
+ if (!Array.isArray(value.entries) || value.entries.length > 2000 || !value.entries.every(validEntry)) return false;
102
+ if (value.headState === "head") { if (!OID.test(String(value.headOid))) return false; }
103
+ else if (!["unborn", "unavailable"].includes(String(value.headState)) || value.headOid !== null) return false;
104
+ if (value.state === "current") return value.reasonCode === null;
105
+ return value.state === "unavailable" && reason(value.reasonCode);
106
+ }
107
+
108
+ export function taskBaselineManifestErrors(value: unknown): string[] {
109
+ const errors: string[] = [];
110
+ const keys = ["schemaVersion", "version", "taskId", "taskRunId", "sessionIdentityHash", "capturedAt", "retentionUntil", "baselineDigestAlgorithm", "baselineTreeDigest", "captureState", "reasonCode", "limits", "roots", "integrityDigest"];
111
+ if (!plain(value) || !exactKeys(value, keys)) return ["manifest shape is invalid"];
112
+ if (value.schemaVersion !== 1 || value.version !== TASK_BASELINE_MANIFEST_VERSION) errors.push("manifest version is invalid");
113
+ if (!PUBLIC_ID.test(String(value.taskId)) || !PUBLIC_ID.test(String(value.taskRunId))) errors.push("task identity is invalid");
114
+ if (!DIGEST.test(String(value.sessionIdentityHash))) errors.push("session identity hash is invalid");
115
+ const capturedAt = Date.parse(String(value.capturedAt));
116
+ const retentionUntil = Date.parse(String(value.retentionUntil));
117
+ if (!TIMESTAMP.test(String(value.capturedAt)) || !TIMESTAMP.test(String(value.retentionUntil))
118
+ || !Number.isFinite(capturedAt) || !Number.isFinite(retentionUntil)
119
+ || retentionUntil <= capturedAt || retentionUntil - capturedAt > 365 * 24 * 60 * 60 * 1000) errors.push("manifest timestamps are invalid");
120
+ if (value.baselineDigestAlgorithm !== "wt-content-v2" || !TREE_DIGEST.test(String(value.baselineTreeDigest))) errors.push("baseline tree digest is invalid");
121
+ if (!plain(value.limits) || !exactKeys(value.limits, ["maxEntries", "maxFileBytes", "maxTotalBytes", "capturedBytes"])) errors.push("manifest limits are invalid");
122
+ else if (
123
+ !Number.isInteger(value.limits.maxEntries) || (value.limits.maxEntries as number) < 1 || (value.limits.maxEntries as number) > 2000
124
+ || !Number.isInteger(value.limits.maxFileBytes) || (value.limits.maxFileBytes as number) < 1024 || (value.limits.maxFileBytes as number) > 16 * 1024 * 1024
125
+ || !Number.isInteger(value.limits.maxTotalBytes) || (value.limits.maxTotalBytes as number) < 1024 * 1024 || (value.limits.maxTotalBytes as number) > 256 * 1024 * 1024
126
+ || !Number.isInteger(value.limits.capturedBytes) || (value.limits.capturedBytes as number) < 0 || (value.limits.capturedBytes as number) > (value.limits.maxTotalBytes as number)
127
+ ) errors.push("manifest limit values are invalid");
128
+ if (!Array.isArray(value.roots) || value.roots.length < 1 || value.roots.length > 32 || !value.roots.every(validRoot)) errors.push("manifest roots are invalid");
129
+ if (!["current", "degraded", "unavailable"].includes(String(value.captureState))) errors.push("capture state is invalid");
130
+ else if (value.captureState === "current" ? value.reasonCode !== null : !reason(value.reasonCode)) errors.push("capture reason is invalid");
131
+ if (!DIGEST.test(String(value.integrityDigest)) || value.integrityDigest !== taskBaselineManifestDigest(value as TaskBaselineManifest)) errors.push("manifest integrity digest is invalid");
132
+ return errors;
133
+ }
134
+
135
+ export function validateTaskBaselineManifest(value: unknown): TaskBaselineManifest {
136
+ const errors = taskBaselineManifestErrors(value);
137
+ if (errors.length) throw new Error(`Invalid task baseline manifest: ${errors.join("; ")}`);
138
+ return structuredClone(value) as TaskBaselineManifest;
139
+ }