@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,401 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ collectGitStatus,
6
+ projectGitPath,
7
+ readGitBlob,
8
+ type GitStatusRecord,
9
+ type GitStatusSnapshot
10
+ } from "./git-status-adapter.ts";
11
+ import { emptyGitTreeObjectId, listGitChangesAgainstTree, readGitTreeEntry } from "./git-tree-adapter.ts";
12
+ import {
13
+ decodeBaselineRepoPath,
14
+ readTaskBaselineBlob,
15
+ readTaskBaselineManifest,
16
+ taskBaselineManifestRef,
17
+ taskBaselineRetentionState
18
+ } from "./source-evidence-store.ts";
19
+ import type { TaskBaselineEntry, TaskBaselineManifest, TaskBaselineRoot } from "./source-evidence-contract.ts";
20
+ import type { SourceChangeDocument, SourceProjectionOptions } from "./source-change-projection.ts";
21
+ import { buffersEqual, diffTextBuffers } from "./text-diff.ts";
22
+ import { taskProvenanceResolver } from "./task-provenance-projection.ts";
23
+ import { hashWorkspaceFile, inspectWorkspaceEntry, readWorkspaceFile, readWorkspaceLink } from "./workspace-file-reader.ts";
24
+
25
+ const MAX_CONTENT_BYTES = 4 * 1024 * 1024;
26
+
27
+ type RootState = { manifest: TaskBaselineRoot; root: string; status: GitStatusSnapshot; records: Map<string, GitStatusRecord> };
28
+ type Content = { bytes: Buffer | null; kind: "text" | "binary" | "symlink" | "submodule" | "unknown"; access: "available" | "protected" | "oversized" | "unavailable"; reasonCode: string | null };
29
+ type BaselineItem = { entry: TaskBaselineEntry; root: TaskBaselineRoot; repoPath: string };
30
+ type TaskPathSelection = { paths: string[]; aliases: Map<string, Set<string>> };
31
+
32
+ function hash(value: string | Buffer): string {
33
+ return createHash("sha256").update(value).digest("hex");
34
+ }
35
+
36
+ function token(prefix: string, value: string | Buffer): string {
37
+ return `${prefix}.${hash(value)}`;
38
+ }
39
+
40
+ function digest(value: Buffer | null): string | null {
41
+ return value === null ? null : `sha256:${hash(value)}`;
42
+ }
43
+
44
+ function rootPath(projectRoot: string, projectPath: string): string {
45
+ return projectPath === "." ? projectRoot : path.join(projectRoot, ...projectPath.split("/"));
46
+ }
47
+
48
+ function rootRelative(root: TaskBaselineRoot, projectPath: string): string | null {
49
+ if (root.projectPath === ".") return projectPath;
50
+ return projectPath.startsWith(`${root.projectPath}/`) ? projectPath.slice(root.projectPath.length + 1) : null;
51
+ }
52
+
53
+ function visibleRepoPath(repoPath: string): boolean {
54
+ return repoPath !== ".pi/piagent-state" && !repoPath.startsWith(".pi/piagent-state/");
55
+ }
56
+
57
+ function visibleRecord(record: GitStatusRecord): boolean {
58
+ const paths = [record.path.value, record.oldPath?.value].filter((value): value is string => Boolean(value));
59
+ return paths.length > 0 && paths.every(visibleRepoPath);
60
+ }
61
+
62
+ function recordMap(status: GitStatusSnapshot): Map<string, GitStatusRecord> {
63
+ const result = new Map<string, GitStatusRecord>();
64
+ for (const record of status.records) {
65
+ if (record.path.value) result.set(record.path.value, record);
66
+ if (record.oldPath?.value) result.set(record.oldPath.value, record);
67
+ }
68
+ return result;
69
+ }
70
+
71
+ async function collectRoots(projectRoot: string, manifest: TaskBaselineManifest): Promise<RootState[]> {
72
+ return await Promise.all(manifest.roots.map(async (root) => {
73
+ const absolute = rootPath(projectRoot, root.projectPath);
74
+ const status = await collectGitStatus(absolute);
75
+ return { manifest: root, root: absolute, status, records: recordMap(status) };
76
+ }));
77
+ }
78
+
79
+ function entryMap(manifest: TaskBaselineManifest): Map<string, BaselineItem> {
80
+ const result = new Map<string, BaselineItem>();
81
+ for (const root of manifest.roots) {
82
+ for (const entry of root.entries) {
83
+ const repoPath = decodeBaselineRepoPath(entry);
84
+ if (!repoPath || !visibleRepoPath(repoPath)) continue;
85
+ const projectPath = root.projectPath === "." ? repoPath : `${root.projectPath}/${repoPath}`;
86
+ result.set(projectPath, { entry, root, repoPath });
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+
92
+ function unavailableContent(access: Content["access"], reasonCode: string, kind: Content["kind"] = "unknown"): Content {
93
+ return { bytes: null, kind, access, reasonCode };
94
+ }
95
+
96
+ function contentKind(bytes: Buffer): "text" | "binary" {
97
+ return bytes.includes(0) ? "binary" : "text";
98
+ }
99
+
100
+ function baselineEntryContent(projectRoot: string, taskRunId: string, entry: TaskBaselineEntry): Content {
101
+ if (entry.state === "absent") return { bytes: null, kind: "unknown", access: "available", reasonCode: null };
102
+ if (entry.state === "submodule") return unavailableContent("available", "submodule-content", "submodule");
103
+ if (entry.state === "protected") return unavailableContent("protected", entry.reasonCode ?? "protected-path");
104
+ if (entry.state === "oversized") return unavailableContent("oversized", entry.reasonCode ?? "file-size-limit");
105
+ if (entry.state === "unavailable" || !entry.contentRef) return unavailableContent("unavailable", entry.reasonCode ?? "baseline-content-unavailable");
106
+ const bytes = readTaskBaselineBlob(projectRoot, taskRunId, entry.contentRef, MAX_CONTENT_BYTES);
107
+ return { bytes, kind: entry.state === "symlink" ? "symlink" : contentKind(bytes), access: "available", reasonCode: null };
108
+ }
109
+
110
+ async function headContent(root: RootState, repoPath: string): Promise<Content> {
111
+ if (root.manifest.headState === "unborn" || !root.manifest.headOid) return { bytes: null, kind: "unknown", access: "available", reasonCode: null };
112
+ try {
113
+ const entry = await readGitTreeEntry(root.root, root.manifest.headOid, repoPath);
114
+ if (!entry) return { bytes: null, kind: "unknown", access: "available", reasonCode: null };
115
+ if (entry.mode === "160000" || entry.type === "commit") return unavailableContent("available", "submodule-content", "submodule");
116
+ const bytes = await readGitBlob(root.root, entry.objectId, { maxBytes: MAX_CONTENT_BYTES });
117
+ return { bytes, kind: entry.mode === "120000" ? "symlink" : contentKind(bytes), access: "available", reasonCode: null };
118
+ } catch (error) {
119
+ const reason = (error as { code?: string }).code === "output-limit" ? "baseline-blob-limit" : "baseline-git-object-unavailable";
120
+ return unavailableContent((error as { code?: string }).code === "output-limit" ? "oversized" : "unavailable", reason);
121
+ }
122
+ }
123
+
124
+ function linkAliases(aliases: Map<string, Set<string>>, paths: string[]): void {
125
+ const linked = new Set(paths.flatMap((candidate) => [...(aliases.get(candidate) ?? [candidate])]));
126
+ for (const candidate of linked) aliases.set(candidate, linked);
127
+ }
128
+
129
+ async function taskPaths(roots: RootState[], baseline: ReturnType<typeof entryMap>): Promise<TaskPathSelection> {
130
+ const values = new Set(baseline.keys());
131
+ const aliases = new Map<string, Set<string>>();
132
+ for (const root of roots) {
133
+ const projectPath = (repoPath: string) => root.manifest.projectPath === "." ? repoPath : `${root.manifest.projectPath}/${repoPath}`;
134
+ const hidden = new Set<string>();
135
+ for (const record of root.status.records) {
136
+ const recordPaths = [record.path.value, record.oldPath?.value].filter((value): value is string => Boolean(value));
137
+ const projected = recordPaths.map(projectPath);
138
+ linkAliases(aliases, projected);
139
+ projected.forEach((value) => values.add(value));
140
+ if (!visibleRecord(record)) projected.forEach((value) => hidden.add(value));
141
+ }
142
+ const trees = new Set([
143
+ root.manifest.headOid ?? await emptyGitTreeObjectId(root.root),
144
+ root.status.headOid ?? await emptyGitTreeObjectId(root.root)
145
+ ]);
146
+ for (const tree of trees) {
147
+ for (const change of await listGitChangesAgainstTree(root.root, tree)) {
148
+ const repoPaths = [change.path, change.oldPath].filter((value): value is string => Boolean(value));
149
+ const projected = repoPaths.map(projectPath);
150
+ linkAliases(aliases, projected);
151
+ projected.forEach((value) => values.add(value));
152
+ if (repoPaths.some((value) => !visibleRepoPath(value))) projected.forEach((value) => hidden.add(value));
153
+ }
154
+ }
155
+ for (const candidate of [...hidden]) for (const alias of aliases.get(candidate) ?? []) hidden.add(alias);
156
+ for (const candidate of hidden) values.delete(candidate);
157
+ }
158
+ return { paths: [...values].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))), aliases };
159
+ }
160
+
161
+ function protectedRecordPath(options: SourceProjectionOptions, root: RootState, repoPath: string, record?: GitStatusRecord, historical: string[] = []): boolean {
162
+ const candidates = new Set([repoPath, record?.path.value, record?.oldPath?.value, ...historical].filter((value): value is string => Boolean(value)));
163
+ return [...candidates].some((candidate) => options.isProtectedPath?.(root.root, candidate) === true);
164
+ }
165
+
166
+ function workspaceEvidence(root: RootState, record: GitStatusRecord, options: SourceProjectionOptions): unknown {
167
+ const repoPath = record.path.value;
168
+ const identity = [record.path.digest, record.oldPath?.digest ?? null, record.indexStatus, record.worktreeStatus];
169
+ if (!repoPath) return [...identity, "unsafe-path"];
170
+ const protectedPath = protectedRecordPath(options, root, repoPath, record);
171
+ try {
172
+ const stat = inspectWorkspaceEntry(root.root, repoPath);
173
+ if (protectedPath) return [...identity, "protected", stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs];
174
+ if (stat.isSymbolicLink()) return [...identity, "symlink", readWorkspaceLink(root.root, repoPath)];
175
+ if (stat.isFile() && stat.size <= MAX_CONTENT_BYTES) return [...identity, "file", hashWorkspaceFile(root.root, repoPath)];
176
+ return [...identity, stat.isFile() ? "oversized" : "other", stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs];
177
+ } catch {
178
+ return [...identity, "absent"];
179
+ }
180
+ }
181
+
182
+ function observation(roots: RootState[], options: SourceProjectionOptions): string {
183
+ return token("workspace", JSON.stringify(roots.map((root) => [
184
+ root.manifest.repoRef,
185
+ root.status.headOid,
186
+ root.status.records.filter(visibleRecord).map((record) => workspaceEvidence(root, record, options))
187
+ ])));
188
+ }
189
+
190
+ function workspacePathPresent(root: RootState, repoPath: string): boolean {
191
+ try { inspectWorkspaceEntry(root.root, repoPath); return true; }
192
+ catch { return false; }
193
+ }
194
+
195
+ async function treeEntryIdentity(root: RootState, oid: string | null, repoPath: string): Promise<string | null> {
196
+ if (!oid) return null;
197
+ const entry = await readGitTreeEntry(root.root, oid, repoPath);
198
+ return entry ? `${entry.mode}:${entry.type}:${entry.objectId}` : null;
199
+ }
200
+
201
+ async function protectedChangeState(
202
+ root: RootState,
203
+ repoPath: string,
204
+ record: GitStatusRecord | undefined,
205
+ baselineItem: BaselineItem | undefined
206
+ ): Promise<{ changed: boolean; basePresent: boolean; currentPresent: boolean; exact: boolean }> {
207
+ const currentPresent = workspacePathPresent(root, repoPath);
208
+ if (baselineItem) {
209
+ const basePresent = baselineItem.entry.state !== "absent";
210
+ if (!basePresent || !currentPresent) return { changed: basePresent !== currentPresent, basePresent, currentPresent, exact: true };
211
+ return { changed: true, basePresent, currentPresent, exact: false };
212
+ }
213
+ const baseIdentity = await treeEntryIdentity(root, root.manifest.headOid, repoPath);
214
+ const currentIdentity = await treeEntryIdentity(root, root.status.headOid, repoPath);
215
+ return {
216
+ changed: Boolean(record) || baseIdentity !== currentIdentity,
217
+ basePresent: baseIdentity !== null,
218
+ currentPresent,
219
+ exact: true
220
+ };
221
+ }
222
+
223
+ function currentContent(root: RootState, repoPath: string, record: GitStatusRecord | undefined): Content {
224
+ if (record?.headMode === "160000" || record?.indexMode === "160000" || record?.worktreeMode === "160000" || record?.submodule?.startsWith("S")) {
225
+ return unavailableContent("available", "submodule-content", "submodule");
226
+ }
227
+ try {
228
+ const stat = inspectWorkspaceEntry(root.root, repoPath);
229
+ if (stat.isSymbolicLink()) {
230
+ const bytes = Buffer.from(readWorkspaceLink(root.root, repoPath), "utf8");
231
+ return { bytes, kind: "symlink", access: "available", reasonCode: null };
232
+ }
233
+ if (!stat.isFile()) return unavailableContent("unavailable", "unsupported-file-type");
234
+ if (stat.size > MAX_CONTENT_BYTES) return unavailableContent("oversized", "file-size-limit");
235
+ const bytes = readWorkspaceFile(root.root, repoPath, MAX_CONTENT_BYTES);
236
+ return { bytes, kind: contentKind(bytes), access: "available", reasonCode: null };
237
+ } catch (error) {
238
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { bytes: null, kind: "unknown", access: "available", reasonCode: null };
239
+ return unavailableContent("unavailable", "content-read-failed");
240
+ }
241
+ }
242
+
243
+ function projectUnavailable(options: SourceProjectionOptions, manifest: TaskBaselineManifest, reasonCode: string, message: string, protectedCount = 0): SourceChangeDocument {
244
+ const repoRef = manifest.roots[0]?.repoRef ?? token("repo", options.cwd);
245
+ const basisRef = token("basis", `${manifest.integrityDigest}:unavailable`);
246
+ const viewRevision = token("task", `${manifest.integrityDigest}:unavailable`);
247
+ return {
248
+ schemaVersion: 1, version: "piagent-webui-source-change-v1", generatedAt: options.generatedAt ?? new Date().toISOString(), identity: options.identity,
249
+ view: "task", viewRevision, bases: [{ basisRef, repoRef, view: "task", state: "unavailable", reasonCode,
250
+ basisRevision: token("basis-rev", `${basisRef}:unavailable`), taskRunId: manifest.taskRunId,
251
+ taskRevision: options.taskRevision ?? "task-revision.unavailable", workspaceRevision: token("workspace", `${manifest.integrityDigest}:unavailable`),
252
+ baselineManifestRef: taskBaselineManifestRef(manifest), baselineTreeDigest: manifest.baselineTreeDigest }],
253
+ availability: { state: "unavailable", reasonCode, message }, files: [],
254
+ page: { cursor: null, nextCursor: null, total: 0, returned: 0, truncated: false }, truncationReason: null,
255
+ redaction: { applied: protectedCount > 0 || manifest.roots.some((root) => root.entries.some((entry) => entry.state === "protected")), valuesRemoved: protectedCount + manifest.roots.reduce((count, root) => count + root.entries.filter((entry) => entry.state === "protected").length, 0), truncated: false },
256
+ health: { state: "unavailable", reasonCode, message }, issues: []
257
+ };
258
+ }
259
+
260
+ function stats(base: Content, current: Content, conflict: boolean) {
261
+ if (conflict) return { state: "unavailable", additions: null, deletions: null, reasonCode: "git-conflict" };
262
+ if (base.bytes === null && base.access === "available" && current.bytes !== null) {
263
+ const result = diffTextBuffers(Buffer.alloc(0), current.bytes);
264
+ return result.exact ? { state: "exact", additions: result.additions, deletions: 0, reasonCode: null } : { state: "unavailable", additions: null, deletions: null, reasonCode: result.reasonCode };
265
+ }
266
+ if (current.bytes === null && current.access === "available" && base.bytes !== null) {
267
+ const result = diffTextBuffers(base.bytes, Buffer.alloc(0));
268
+ return result.exact ? { state: "exact", additions: 0, deletions: result.deletions, reasonCode: null } : { state: "unavailable", additions: null, deletions: null, reasonCode: result.reasonCode };
269
+ }
270
+ if (base.bytes !== null && current.bytes !== null) {
271
+ const result = diffTextBuffers(base.bytes, current.bytes);
272
+ return result.exact ? { state: "exact", additions: result.additions, deletions: result.deletions, reasonCode: null } : { state: "unavailable", additions: null, deletions: null, reasonCode: result.reasonCode };
273
+ }
274
+ return { state: "unavailable", additions: null, deletions: null, reasonCode: base.reasonCode ?? current.reasonCode ?? "line-stats-unavailable" };
275
+ }
276
+
277
+ function changed(base: Content, current: Content, inCurrentSnapshot: boolean, inBaseline: boolean): boolean {
278
+ if (base.access !== "available" || current.access !== "available") return inCurrentSnapshot || inBaseline;
279
+ return !buffersEqual(base.bytes, current.bytes);
280
+ }
281
+
282
+ export async function collectTaskSourceChangeView(options: SourceProjectionOptions): Promise<SourceChangeDocument | null> {
283
+ if (!options.identity.taskId || !options.identity.taskRunId) return null;
284
+ let manifest: TaskBaselineManifest | undefined;
285
+ try { manifest = readTaskBaselineManifest(options.cwd, options.identity.taskRunId); }
286
+ catch { return null; }
287
+ if (!manifest) return null;
288
+ if (taskBaselineRetentionState(manifest, new Date(options.generatedAt ?? Date.now())) === "expired") {
289
+ return projectUnavailable(options, manifest, "task-baseline-retention-expired", "Task baseline evidence is outside its retention window");
290
+ }
291
+ if (manifest.taskId !== options.identity.taskId || manifest.captureState !== "current") {
292
+ return projectUnavailable(options, manifest, manifest.reasonCode ?? "task-baseline-content-unavailable", "Exact task baseline evidence is unavailable");
293
+ }
294
+ const roots = await collectRoots(options.cwd, manifest);
295
+ const baseline = entryMap(manifest);
296
+ const selection = await taskPaths(roots, baseline);
297
+ const beforeObservation = observation(roots, options);
298
+ const provenanceFor = taskProvenanceResolver(options.cwd, manifest);
299
+ const basisRefs = new Map(manifest.roots.map((root) => [root.repoRef, token("basis", `${manifest.integrityDigest}:${root.repoRef}`)]));
300
+ const files: Array<Record<string, unknown>> = [];
301
+ for (const projectPath of selection.paths) {
302
+ const root = roots.find((candidate) => rootRelative(candidate.manifest, projectPath) !== null);
303
+ if (!root) continue;
304
+ const repoPath = rootRelative(root.manifest, projectPath) as string;
305
+ const record = root.records.get(repoPath);
306
+ if (!visibleRepoPath(repoPath) || (record && !visibleRecord(record))) continue;
307
+ const baselineItem = baseline.get(projectPath);
308
+ const historical = [...(selection.aliases.get(projectPath) ?? [])]
309
+ .map((candidate) => rootRelative(root.manifest, candidate)).filter((candidate): candidate is string => candidate !== null);
310
+ const protectedPath = protectedRecordPath(options, root, repoPath, record, historical);
311
+ const protectedState = protectedPath ? await protectedChangeState(root, repoPath, record, baselineItem) : null;
312
+ if (protectedState && !protectedState.exact) {
313
+ return projectUnavailable(options, manifest, "protected-baseline-overlap", "Exact task delta overlaps content protected by the current policy", 1);
314
+ }
315
+ const base = protectedPath
316
+ ? unavailableContent("protected", "protected-path")
317
+ : baselineItem ? baselineEntryContent(options.cwd, manifest.taskRunId, baselineItem.entry) : await headContent(root, repoPath);
318
+ const current = protectedPath ? unavailableContent("protected", "protected-path") : currentContent(root, repoPath, record);
319
+ const basePresent = protectedState?.basePresent
320
+ ?? (baselineItem ? baselineItem.entry.state !== "absent" : await treeEntryIdentity(root, root.manifest.headOid, repoPath) !== null);
321
+ const currentPresent = protectedState?.currentPresent ?? workspacePathPresent(root, repoPath);
322
+ if (protectedState ? !protectedState.changed : !changed(base, current, currentPresent, basePresent)) continue;
323
+ const conflict = record?.kind === "unmerged" || record?.indexStatus === "U" || record?.worktreeStatus === "U";
324
+ const status = conflict ? "C" : !basePresent ? (record?.kind === "untracked" ? "U" : "A") : !currentPresent ? "D" : "M";
325
+ const display = projectGitPath(projectPath);
326
+ if (!display.display) continue;
327
+ const fileRef = token("file", `${root.status.repoDigest}\0${display.digest}`);
328
+ const recordState = record ? [record.kind, record.path.digest, record.oldPath?.digest ?? null, record.indexStatus, record.worktreeStatus,
329
+ record.headMode, record.indexMode, record.worktreeMode, record.headObject, record.indexObject] : null;
330
+ const fileRevision = token("file-rev", JSON.stringify([manifest.integrityDigest, projectPath, digest(base.bytes), digest(current.bytes),
331
+ root.status.headOid, recordState, protectedPath]));
332
+ const access = base.access !== "available" ? base.access : current.access;
333
+ const kind = current.bytes === null ? base.kind : current.kind;
334
+ const reasonCode = base.reasonCode ?? current.reasonCode;
335
+ files.push({
336
+ repoRef: root.manifest.repoRef, basisRef: basisRefs.get(root.manifest.repoRef), fileRef, fileRevision,
337
+ path: display.display, oldPath: null, pathDisplay: display.displayMode, status,
338
+ git: { indexStatus: record?.indexStatus ?? ".", worktreeStatus: record?.worktreeStatus ?? ".", conflict },
339
+ baseDigest: protectedPath || status === "A" || status === "U" ? null : digest(base.bytes), currentDigest: protectedPath || status === "D" ? null : digest(current.bytes),
340
+ content: access === "available" && kind !== "unknown" ? { kind, access, reasonCode: null } : { kind: kind === "unknown" ? "unknown" : kind, access, reasonCode: reasonCode ?? "content-unavailable" },
341
+ stats: protectedPath ? { state: "unavailable", additions: null, deletions: null, reasonCode: "protected-path" } : stats(base, current, conflict),
342
+ provenance: provenanceFor(projectPath, digest(current.bytes), baselineItem?.entry),
343
+ criterionIds: [], verifierAttemptIds: [], health: access === "available" ? { state: "ok", reasonCode: null, message: null } : { state: "degraded", reasonCode: reasonCode ?? "content-unavailable", message: "Exact task content is unavailable" }
344
+ });
345
+ }
346
+ const afterRoots = await collectRoots(options.cwd, manifest);
347
+ const afterObservation = observation(afterRoots, options);
348
+ if (beforeObservation !== afterObservation) return projectUnavailable(options, manifest, "git-race", "Workspace changed during task projection; retry");
349
+ const workspaceRevision = afterObservation;
350
+ const viewRevision = token("task", `${manifest.integrityDigest}:${workspaceRevision}`);
351
+ const bases = manifest.roots.map((root) => ({
352
+ basisRef: basisRefs.get(root.repoRef), repoRef: root.repoRef, view: "task", state: "current", reasonCode: null,
353
+ basisRevision: token("basis-rev", `${manifest.integrityDigest}:${root.repoRef}`), taskRunId: manifest.taskRunId,
354
+ taskRevision: options.taskRevision ?? "task-revision.unavailable", workspaceRevision,
355
+ baselineManifestRef: taskBaselineManifestRef(manifest), baselineTreeDigest: manifest.baselineTreeDigest
356
+ }));
357
+ const limit = Number.isInteger(options.pageLimit) ? Math.max(1, Math.min(2000, options.pageLimit as number)) : 300;
358
+ const returned = files.slice(0, limit);
359
+ const truncated = returned.length < files.length;
360
+ const protectedCount = files.filter((file) => (file.content as Record<string, unknown>)?.access === "protected").length;
361
+ return {
362
+ schemaVersion: 1, version: "piagent-webui-source-change-v1", generatedAt: options.generatedAt ?? new Date().toISOString(), identity: options.identity,
363
+ view: "task", viewRevision, bases, availability: { state: "current", reasonCode: null, message: null }, files: returned,
364
+ page: { cursor: null, nextCursor: truncated ? token("page", `${viewRevision}:${limit}`) : null, total: files.length, returned: returned.length, truncated }, truncationReason: truncated ? "page-limit" : null,
365
+ redaction: { applied: protectedCount > 0, valuesRemoved: protectedCount, truncated: false },
366
+ health: protectedCount > 0
367
+ ? { state: "degraded", reasonCode: "source-content-restricted", message: "Some task content is protected by policy" }
368
+ : { state: "ok", reasonCode: null, message: null },
369
+ issues: []
370
+ };
371
+ }
372
+
373
+ export async function readTaskFileContents(options: SourceProjectionOptions, fileRef: string): Promise<{ base: Buffer | null; current: Buffer | null } | null> {
374
+ if (!options.identity.taskRunId) return null;
375
+ let manifest: TaskBaselineManifest | undefined;
376
+ try { manifest = readTaskBaselineManifest(options.cwd, options.identity.taskRunId); }
377
+ catch { return null; }
378
+ if (!manifest || manifest.captureState !== "current"
379
+ || taskBaselineRetentionState(manifest, new Date(options.generatedAt ?? Date.now())) === "expired") return null;
380
+ const roots = await collectRoots(options.cwd, manifest);
381
+ const baseline = entryMap(manifest);
382
+ const selection = await taskPaths(roots, baseline);
383
+ for (const projectPath of selection.paths) {
384
+ const root = roots.find((candidate) => rootRelative(candidate.manifest, projectPath) !== null);
385
+ if (!root) continue;
386
+ const repoPath = rootRelative(root.manifest, projectPath) as string;
387
+ const record = root.records.get(repoPath);
388
+ if (!visibleRepoPath(repoPath) || (record && !visibleRecord(record))) continue;
389
+ const display = projectGitPath(projectPath);
390
+ if (token("file", `${root.status.repoDigest}\0${display.digest}`) !== fileRef) continue;
391
+ const historical = [...(selection.aliases.get(projectPath) ?? [])]
392
+ .map((candidate) => rootRelative(root.manifest, candidate)).filter((candidate): candidate is string => candidate !== null);
393
+ if (protectedRecordPath(options, root, repoPath, record, historical)) return null;
394
+ const item = baseline.get(projectPath);
395
+ const base = item ? baselineEntryContent(options.cwd, manifest.taskRunId, item.entry) : await headContent(root, repoPath);
396
+ const current = currentContent(root, repoPath, record);
397
+ if (base.access !== "available" || current.access !== "available") return null;
398
+ return { base: base.bytes, current: current.bytes };
399
+ }
400
+ return null;
401
+ }
@@ -0,0 +1,101 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { redactSensitiveText } from "../../extensions/redaction-core.js";
4
+ import { inspectOwnedWorkBudget, type OwnedWorkReservation } from "../orchestration/owned-work-budget.ts";
5
+ import { taskRunOpaqueRef } from "./task-run-index.ts";
6
+
7
+ type Task = { taskId: string; taskRunId: string; trace?: { outcome?: unknown }; orchestration?: Record<string, unknown>;
8
+ acceptanceReceipt?: { helperUsage?: { used?: unknown; helpers?: unknown[] } }; [key: string]: any };
9
+ type Identity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string; taskRunId: string;
10
+ agentOperationId: null; toolCallId: null };
11
+
12
+ function opaque(prefix: string, value: string): string {
13
+ return `${prefix}.${createHash("sha256").update(`piagent-webui-subagent-tree-v1\0${value}`).digest("hex").slice(0, 48)}`;
14
+ }
15
+
16
+ function display(value: unknown, maximum = 120): string | null {
17
+ const text = redactSensitiveText(String(value ?? "")).text.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
18
+ return text ? text.slice(0, maximum) : null;
19
+ }
20
+
21
+ function helperReceipts(task: Task): Map<string, Record<string, any>> {
22
+ const helpers = task.acceptanceReceipt?.helperUsage?.helpers;
23
+ const map = new Map<string, Record<string, any>>();
24
+ if (!Array.isArray(helpers)) return map;
25
+ for (const helper of helpers.slice(0, 64)) {
26
+ if (!helper || typeof helper !== "object" || Array.isArray(helper)) continue;
27
+ const item = helper as Record<string, any>;
28
+ if (typeof item.requestRef === "string" && /^[a-f0-9]{64}$/.test(item.requestRef)) map.set(item.requestRef, item);
29
+ }
30
+ return map;
31
+ }
32
+
33
+ function child(taskRunId: string, parentRef: string, item: OwnedWorkReservation, receipt: Record<string, any> | undefined) {
34
+ const nodeRef = opaque("helper", `${taskRunId}\0${item.id}`);
35
+ const disposition = display(receipt?.disposition, 80);
36
+ const staleResult = disposition === "stale-result";
37
+ const resultState = staleResult ? "stale-result" : item.status === "succeeded" && item.usageRef ? "accepted"
38
+ : receipt ? "rejected" : "not-recorded";
39
+ return {
40
+ nodeRef, parentRef, role: item.role, authority: item.authority, lifecycleState: item.status,
41
+ reservedAt: item.reservedAt, expiresAt: item.expiresAt, completedAt: item.completedAt,
42
+ currentWriter: item.status === "active" && item.authority === "single-writer",
43
+ result: { state: resultState, disposition, calls: item.usageRef?.calls ?? null, tokens: item.usageRef?.tokens ?? null }
44
+ };
45
+ }
46
+
47
+ function orchestration(task: Task) {
48
+ const mode = task.orchestration?.mode;
49
+ const subagents = task.orchestration?.subagents;
50
+ return {
51
+ mode: ["solo-first", "bounded-subagents", "parallel-readonly"].includes(String(mode)) ? mode : "unknown",
52
+ subagents: ["not-used", "optional", "used"].includes(String(subagents)) ? subagents : "unknown"
53
+ };
54
+ }
55
+
56
+ function unavailable(identity: Identity, runRef: string, generatedAt: string, reasonCode: string) {
57
+ return { schemaVersion: 1, version: "piagent-webui-subagent-tree-v1", generatedAt, identity: structuredClone(identity), runRef,
58
+ state: "unavailable", treeRevision: null, evidenceState: "unknown", orchestration: { mode: "unknown", subagents: "unknown" },
59
+ parent: null, children: [], writer: { state: "unknown", ownerNodeRef: null },
60
+ nestedLineage: { state: "unavailable", reasonCode: "no-durable-nested-lineage" },
61
+ summary: { total: 0, active: 0, completed: 0, staleResults: 0, readOnly: 0, singleWriter: 0 }, warnings: [],
62
+ health: { state: "error", reasonCode, message: "Helper/subagent evidence is unavailable." } };
63
+ }
64
+
65
+ export function projectTaskSubagentTree(input: { cwd: string; task: Task; identity: Identity; generatedAt?: string }): Record<string, any> {
66
+ const generatedAt = input.generatedAt ?? new Date().toISOString(), runRef = taskRunOpaqueRef(input.task.taskRunId);
67
+ const inspected = inspectOwnedWorkBudget(input.cwd, input.task.taskId, input.task.taskRunId, generatedAt);
68
+ if (inspected.state === "corrupt") return unavailable(input.identity, runRef, generatedAt, inspected.reasonCode ?? "helper-budget-corrupt");
69
+ const policy = orchestration(input.task), parentRef = opaque("parent", input.task.taskRunId), receipts = helperReceipts(input.task);
70
+ const children = inspected.reservations.map((item) => child(input.task.taskRunId, parentRef, item, receipts.get(item.deduplicationKey)));
71
+ const matchedReceipts = new Set(inspected.reservations.map((item) => item.deduplicationKey).filter((key) => receipts.has(key)));
72
+ const unmatchedReceipts = Math.max(0, receipts.size - matchedReceipts.size);
73
+ const activeWriter = children.find((item) => item.currentWriter);
74
+ const outcome = String(input.task.trace?.outcome ?? "unknown");
75
+ const parentState = outcome === "pending" ? "active" : ["completed", "blocked", "partial", "failed"].includes(outcome) ? "terminal" : "unknown";
76
+ const aggregateEvidence = policy.mode !== "unknown" || policy.subagents !== "unknown" || receipts.size > 0;
77
+ const evidenceState = inspected.state === "ready" ? unmatchedReceipts ? "partial" : "complete" : aggregateEvidence ? "aggregate-only" : "missing";
78
+ const warnings: Array<{ code: string; count: number; message: string }> = [];
79
+ if (inspected.state === "missing") warnings.push({ code: "helper-budget-missing", count: 1,
80
+ message: "Only aggregate orchestration evidence is available; helper lifecycle detail cannot be reconstructed." });
81
+ if (inspected.derivedOrphans) warnings.push({ code: "expired-helper-derived-orphan", count: inspected.derivedOrphans,
82
+ message: `${inspected.derivedOrphans} expired active helper reservation(s) are displayed as orphaned without changing runtime state.` });
83
+ if (unmatchedReceipts) warnings.push({ code: "helper-receipt-unmatched", count: unmatchedReceipts,
84
+ message: `${unmatchedReceipts} helper receipt(s) cannot be linked to a durable reservation.` });
85
+ const writer = activeWriter ? { state: "helper", ownerNodeRef: activeWriter.nodeRef }
86
+ : inspected.state === "ready" || policy.subagents === "not-used" ? { state: "parent", ownerNodeRef: parentRef }
87
+ : { state: "unknown", ownerNodeRef: null };
88
+ const summary = { total: children.length, active: children.filter((item) => item.lifecycleState === "active").length,
89
+ completed: children.filter((item) => item.lifecycleState !== "active").length,
90
+ staleResults: children.filter((item) => item.result.state === "stale-result").length,
91
+ readOnly: children.filter((item) => item.authority === "read-only").length,
92
+ singleWriter: children.filter((item) => item.authority === "single-writer").length };
93
+ const treeRevision = `subagent-tree.${createHash("sha256").update(JSON.stringify({ policy, parentState, terminal: inspected.terminal,
94
+ children, writer, evidenceState })).digest("hex")}`;
95
+ return { schemaVersion: 1, version: "piagent-webui-subagent-tree-v1", generatedAt, identity: structuredClone(input.identity), runRef,
96
+ state: "ready", treeRevision, evidenceState, orchestration: policy,
97
+ parent: { nodeRef: parentRef, lifecycleState: parentState, budgetTerminal: inspected.terminal, mergeOwner: "parent" }, children, writer,
98
+ nestedLineage: { state: "unavailable", reasonCode: "no-durable-nested-lineage" }, summary, warnings,
99
+ health: warnings.length ? { state: "degraded", reasonCode: "subagent-tree-incomplete", message: "Some helper/subagent evidence is incomplete." }
100
+ : { state: "ok", reasonCode: null, message: null } };
101
+ }
@@ -0,0 +1,60 @@
1
+ export type TextDiffOperation = { kind: "context" | "added" | "deleted"; text: string };
2
+
3
+ export type TextDiffResult = {
4
+ operations: TextDiffOperation[];
5
+ additions: number;
6
+ deletions: number;
7
+ exact: boolean;
8
+ reasonCode: string | null;
9
+ };
10
+
11
+ function lines(content: Buffer): string[] | null {
12
+ if (content.includes(0)) return null;
13
+ const text = content.toString("utf8");
14
+ if (Buffer.from(text, "utf8").compare(content) !== 0) return null;
15
+ if (!text) return [];
16
+ const result = text.split("\n");
17
+ if (text.endsWith("\n")) result.pop();
18
+ return result.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
19
+ }
20
+
21
+ export function diffTextBuffers(base: Buffer, current: Buffer, maxCells = 4_000_000): TextDiffResult {
22
+ const before = lines(base);
23
+ const after = lines(current);
24
+ if (!before || !after) return { operations: [], additions: 0, deletions: 0, exact: false, reasonCode: "binary-content" };
25
+ const cells = (before.length + 1) * (after.length + 1);
26
+ if (!Number.isSafeInteger(cells) || cells > maxCells) {
27
+ return { operations: [], additions: 0, deletions: 0, exact: false, reasonCode: "diff-complexity-limit" };
28
+ }
29
+ const matrix = Array.from({ length: before.length + 1 }, () => new Uint32Array(after.length + 1));
30
+ for (let left = before.length - 1; left >= 0; left -= 1) {
31
+ for (let right = after.length - 1; right >= 0; right -= 1) {
32
+ matrix[left][right] = before[left] === after[right]
33
+ ? matrix[left + 1][right + 1] + 1
34
+ : Math.max(matrix[left + 1][right], matrix[left][right + 1]);
35
+ }
36
+ }
37
+ const operations: TextDiffOperation[] = [];
38
+ let left = 0;
39
+ let right = 0;
40
+ let additions = 0;
41
+ let deletions = 0;
42
+ while (left < before.length || right < after.length) {
43
+ if (left < before.length && right < after.length && before[left] === after[right]) {
44
+ operations.push({ kind: "context", text: before[left] });
45
+ left += 1;
46
+ right += 1;
47
+ } else if (right < after.length && (left >= before.length || matrix[left][right + 1] >= matrix[left + 1][right])) {
48
+ operations.push({ kind: "added", text: after[right++] });
49
+ additions += 1;
50
+ } else {
51
+ operations.push({ kind: "deleted", text: before[left++] });
52
+ deletions += 1;
53
+ }
54
+ }
55
+ return { operations, additions, deletions, exact: true, reasonCode: null };
56
+ }
57
+
58
+ export function buffersEqual(left: Buffer | null, right: Buffer | null): boolean {
59
+ return left === null ? right === null : right !== null && left.equals(right);
60
+ }