@piagent/platform 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +3 -3
  3. package/architecture/layers.json +96 -1
  4. package/docs/README.md +1 -0
  5. package/docs/architecture.md +1 -0
  6. package/docs/authority-manifest.md +1 -0
  7. package/docs/auto-delegation-policy.md +1 -0
  8. package/docs/capability-packs.md +1 -0
  9. package/docs/command-reference-vietnamese.md +5 -4
  10. package/docs/context-engine.md +1 -0
  11. package/docs/context-window-policy.md +1 -0
  12. package/docs/distribution-standard.md +8 -7
  13. package/docs/herdr-workflow.md +1 -0
  14. package/docs/mcp-and-tools.md +14 -9
  15. package/docs/memory-policy.md +1 -0
  16. package/docs/model-options.md +1 -0
  17. package/docs/oauth-providers.md +1 -0
  18. package/docs/operator-manual-vietnamese.md +15 -12
  19. package/docs/package-architecture-notes.md +1 -0
  20. package/docs/project-adapters.md +1 -0
  21. package/docs/project-onboarding.md +1 -0
  22. package/docs/publishing-for-teams.md +1 -0
  23. package/docs/quality-benchmark.md +1 -0
  24. package/docs/quickstart-vietnamese.md +3 -2
  25. package/docs/release-install-policy.md +7 -6
  26. package/docs/runtime-harness-standard.md +1 -0
  27. package/docs/runtime-policy-design.md +1 -0
  28. package/docs/runtime-quality-baseline.md +1 -0
  29. package/docs/security-redaction-benchmark.md +1 -0
  30. package/docs/security-threat-model.md +2 -1
  31. package/docs/subagent-orchestration-capabilities.md +1 -0
  32. package/docs/subagents-and-multiagent.md +1 -0
  33. package/docs/task-implementation-contract.md +1 -0
  34. package/docs/task-lifecycle-vietnamese.md +1 -0
  35. package/docs/team-onboarding.md +3 -2
  36. package/docs/third-party-neutrality.md +26 -0
  37. package/docs/usage-observability.md +1 -0
  38. package/docs/vercel-docs-site.md +1 -0
  39. package/docs/workflow-recipes.md +1 -0
  40. package/docs-site/architecture.html +1 -1
  41. package/docs-site/benchmark.html +1 -1
  42. package/docs-site/commands.html +1 -1
  43. package/docs-site/context-engine.html +1 -1
  44. package/docs-site/en/architecture.html +1 -1
  45. package/docs-site/en/benchmark.html +1 -1
  46. package/docs-site/en/commands.html +1 -1
  47. package/docs-site/en/context-engine.html +1 -1
  48. package/docs-site/en/index.html +1 -1
  49. package/docs-site/en/mcp-approval.html +1 -1
  50. package/docs-site/en/mcp-auth.html +1 -1
  51. package/docs-site/en/mcp-servers.html +1 -1
  52. package/docs-site/en/mcp.html +1 -1
  53. package/docs-site/en/permissions.html +1 -1
  54. package/docs-site/en/profiles.html +1 -1
  55. package/docs-site/en/quickstart.html +1 -1
  56. package/docs-site/en/runtime.html +1 -1
  57. package/docs-site/en/scope.html +1 -1
  58. package/docs-site/en/security.html +1 -1
  59. package/docs-site/en/team.html +1 -1
  60. package/docs-site/en/whats-new.html +1 -1
  61. package/docs-site/en/workflows.html +1 -1
  62. package/docs-site/index.html +1 -1
  63. package/docs-site/mcp-approval.html +1 -1
  64. package/docs-site/mcp-auth.html +1 -1
  65. package/docs-site/mcp-servers.html +1 -1
  66. package/docs-site/mcp.html +1 -1
  67. package/docs-site/permissions.html +1 -1
  68. package/docs-site/profiles.html +1 -1
  69. package/docs-site/quickstart.html +1 -1
  70. package/docs-site/runtime.html +1 -1
  71. package/docs-site/scope.html +1 -1
  72. package/docs-site/security.html +1 -1
  73. package/docs-site/team.html +1 -1
  74. package/docs-site/whats-new.html +1 -1
  75. package/docs-site/workflows.html +1 -1
  76. package/evals/fixtures/mutation-provenance-record.invalid.json +17 -0
  77. package/evals/fixtures/mutation-provenance-record.valid.json +27 -0
  78. package/evals/fixtures/piagent-webui/approval-v1.invalid.json +54 -0
  79. package/evals/fixtures/piagent-webui/approval-v1.valid.json +58 -0
  80. package/evals/fixtures/piagent-webui/attachment-v1.invalid.json +25 -0
  81. package/evals/fixtures/piagent-webui/attachment-v1.valid.json +25 -0
  82. package/evals/fixtures/piagent-webui/capabilities-v1.invalid.json +51 -0
  83. package/evals/fixtures/piagent-webui/capabilities-v1.valid.json +122 -0
  84. package/evals/fixtures/piagent-webui/commit-summary-v1.invalid.json +10 -0
  85. package/evals/fixtures/piagent-webui/commit-summary-v1.valid.json +25 -0
  86. package/evals/fixtures/piagent-webui/control-command-v1.invalid.json +37 -0
  87. package/evals/fixtures/piagent-webui/control-command-v1.valid.json +35 -0
  88. package/evals/fixtures/piagent-webui/diff-v1.invalid.json +13 -0
  89. package/evals/fixtures/piagent-webui/diff-v1.valid.json +141 -0
  90. package/evals/fixtures/piagent-webui/gateway-capabilities-v1.invalid.json +35 -0
  91. package/evals/fixtures/piagent-webui/gateway-capabilities-v1.valid.json +35 -0
  92. package/evals/fixtures/piagent-webui/gateway-protocol-v1.invalid.json +14 -0
  93. package/evals/fixtures/piagent-webui/gateway-protocol-v1.valid.json +14 -0
  94. package/evals/fixtures/piagent-webui/handoff-history-v1.invalid.json +16 -0
  95. package/evals/fixtures/piagent-webui/handoff-history-v1.valid.json +16 -0
  96. package/evals/fixtures/piagent-webui/model-catalog-v1.invalid.json +31 -0
  97. package/evals/fixtures/piagent-webui/model-catalog-v1.valid.json +43 -0
  98. package/evals/fixtures/piagent-webui/queue-v1.invalid.json +29 -0
  99. package/evals/fixtures/piagent-webui/queue-v1.valid.json +44 -0
  100. package/evals/fixtures/piagent-webui/recovery-history-v1.invalid.json +17 -0
  101. package/evals/fixtures/piagent-webui/recovery-history-v1.valid.json +20 -0
  102. package/evals/fixtures/piagent-webui/release-monitor-v1.invalid.json +12 -0
  103. package/evals/fixtures/piagent-webui/release-monitor-v1.valid.json +29 -0
  104. package/evals/fixtures/piagent-webui/review-state-v1.invalid.json +25 -0
  105. package/evals/fixtures/piagent-webui/review-state-v1.valid.json +38 -0
  106. package/evals/fixtures/piagent-webui/runtime-event-v2.invalid.json +49 -0
  107. package/evals/fixtures/piagent-webui/runtime-event-v2.valid.json +49 -0
  108. package/evals/fixtures/piagent-webui/session-catalog-v1.invalid.json +17 -0
  109. package/evals/fixtures/piagent-webui/session-catalog-v1.valid.json +58 -0
  110. package/evals/fixtures/piagent-webui/session-command-v1.invalid.json +19 -0
  111. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +19 -0
  112. package/evals/fixtures/piagent-webui/snapshot-v1.invalid.json +5 -0
  113. package/evals/fixtures/piagent-webui/snapshot-v1.valid.json +191 -0
  114. package/evals/fixtures/piagent-webui/source-change-v1.invalid.json +57 -0
  115. package/evals/fixtures/piagent-webui/source-change-v1.valid.json +98 -0
  116. package/evals/fixtures/piagent-webui/source-mutation-v1.invalid.json +19 -0
  117. package/evals/fixtures/piagent-webui/source-mutation-v1.valid.json +38 -0
  118. package/evals/fixtures/piagent-webui/source-revert-v1.invalid.json +20 -0
  119. package/evals/fixtures/piagent-webui/source-revert-v1.valid.json +62 -0
  120. package/evals/fixtures/piagent-webui/subagent-tree-v1.invalid.json +26 -0
  121. package/evals/fixtures/piagent-webui/subagent-tree-v1.valid.json +39 -0
  122. package/evals/fixtures/piagent-webui/task-index-v1.invalid.json +15 -0
  123. package/evals/fixtures/piagent-webui/task-index-v1.valid.json +15 -0
  124. package/evals/fixtures/piagent-webui/task-timeline-v1.invalid.json +14 -0
  125. package/evals/fixtures/piagent-webui/task-timeline-v1.valid.json +16 -0
  126. package/evals/fixtures/piagent-webui/transcript-v1.invalid.json +35 -0
  127. package/evals/fixtures/piagent-webui/transcript-v1.valid.json +55 -0
  128. package/evals/fixtures/task-baseline-manifest.invalid.json +16 -0
  129. package/evals/fixtures/task-baseline-manifest.valid.json +41 -0
  130. package/evals/fixtures/verifier-file-snapshot.invalid.json +21 -0
  131. package/evals/fixtures/verifier-file-snapshot.valid.json +21 -0
  132. package/package.json +30 -3
  133. package/packages/piagent-core/benchmark/benchmark-candidate.d.ts +1 -0
  134. package/packages/piagent-core/benchmark/benchmark-forensics.d.ts +1 -0
  135. package/packages/piagent-core/benchmark/benchmark-ledger.d.ts +4 -0
  136. package/packages/piagent-core/benchmark/benchmark-record-validation.d.ts +1 -0
  137. package/packages/piagent-core/benchmark/benchmark-runtime.d.ts +1 -0
  138. package/packages/piagent-core/capabilities/capability-core.js +2 -2
  139. package/packages/piagent-core/capabilities/runtime-integrity.js +143 -0
  140. package/packages/piagent-core/extensions/piagent-guard.ts +62 -37
  141. package/packages/piagent-core/extensions/redaction-core.d.ts +2 -0
  142. package/packages/piagent-core/extensions/task-journal.js +30 -1
  143. package/packages/piagent-core/extensions/task-state.js +33 -36
  144. package/packages/piagent-core/mcp/mcp-server-catalog.js +5 -5
  145. package/packages/piagent-core/package.json +1 -1
  146. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -2
  147. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +5 -2
  148. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +20 -19
  149. package/packages/piagent-core/runtime/hooks/tool-result-metadata.ts +13 -0
  150. package/packages/piagent-core/runtime/input/chat-images.ts +2 -2
  151. package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +94 -0
  152. package/packages/piagent-core/runtime/inspection/approval-broker.ts +318 -0
  153. package/packages/piagent-core/runtime/inspection/commit-summary-projection.ts +59 -0
  154. package/packages/piagent-core/runtime/inspection/context-telemetry-inspection.ts +37 -0
  155. package/packages/piagent-core/runtime/inspection/criteria-links.ts +295 -0
  156. package/packages/piagent-core/runtime/inspection/diff-projection.ts +499 -0
  157. package/packages/piagent-core/runtime/inspection/git-filter-safety.ts +38 -0
  158. package/packages/piagent-core/runtime/inspection/git-numstat-adapter.ts +71 -0
  159. package/packages/piagent-core/runtime/inspection/git-status-adapter.ts +493 -0
  160. package/packages/piagent-core/runtime/inspection/git-tree-adapter.ts +108 -0
  161. package/packages/piagent-core/runtime/inspection/mcp-control.ts +10 -0
  162. package/packages/piagent-core/runtime/inspection/mutation-provenance-contract.ts +131 -0
  163. package/packages/piagent-core/runtime/inspection/mutation-provenance-recorder.ts +48 -0
  164. package/packages/piagent-core/runtime/inspection/mutation-provenance-store.ts +161 -0
  165. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +36 -0
  166. package/packages/piagent-core/runtime/inspection/review-state-contract.ts +108 -0
  167. package/packages/piagent-core/runtime/inspection/review-state-projection.ts +93 -0
  168. package/packages/piagent-core/runtime/inspection/review-state-store.ts +112 -0
  169. package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +262 -0
  170. package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +103 -0
  171. package/packages/piagent-core/runtime/inspection/source-change-projection.ts +471 -0
  172. package/packages/piagent-core/runtime/inspection/source-evidence-contract.ts +139 -0
  173. package/packages/piagent-core/runtime/inspection/source-evidence-store.ts +315 -0
  174. package/packages/piagent-core/runtime/inspection/source-handoff-store.ts +70 -0
  175. package/packages/piagent-core/runtime/inspection/source-inspection-plan.ts +34 -0
  176. package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +164 -0
  177. package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +168 -0
  178. package/packages/piagent-core/runtime/inspection/source-open-target.ts +38 -0
  179. package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +147 -0
  180. package/packages/piagent-core/runtime/inspection/task-baseline-start-capture.ts +22 -0
  181. package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +160 -0
  182. package/packages/piagent-core/runtime/inspection/task-control-journal.ts +131 -0
  183. package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +95 -0
  184. package/packages/piagent-core/runtime/inspection/task-provenance-projection.ts +60 -0
  185. package/packages/piagent-core/runtime/inspection/task-recovery-timeline.ts +88 -0
  186. package/packages/piagent-core/runtime/inspection/task-run-index.ts +87 -0
  187. package/packages/piagent-core/runtime/inspection/task-source-projection.ts +401 -0
  188. package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +101 -0
  189. package/packages/piagent-core/runtime/inspection/text-diff.ts +60 -0
  190. package/packages/piagent-core/runtime/inspection/verifier-snapshot-contract.ts +114 -0
  191. package/packages/piagent-core/runtime/inspection/verifier-snapshot-store.ts +189 -0
  192. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +320 -0
  193. package/packages/piagent-core/runtime/inspection/workspace-file-reader.ts +132 -0
  194. package/packages/piagent-core/runtime/inspection/workspace-source-projection.ts +96 -0
  195. package/packages/piagent-core/runtime/inspection/zero-turn-conformance.ts +175 -0
  196. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +68 -4
  197. package/packages/piagent-core/runtime/policy/source-index-transaction.ts +173 -0
  198. package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +42 -0
  199. package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +184 -0
  200. package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +105 -0
  201. package/packages/piagent-core/runtime/product/activity-inspector-footer.ts +5 -3
  202. package/packages/piagent-core/runtime/product/activity-inspector.ts +85 -343
  203. package/packages/piagent-core/runtime/recovery/resume-state.ts +38 -1
  204. package/packages/piagent-core/runtime/registration/activity-inspector-command.ts +68 -14
  205. package/packages/piagent-core/runtime/registration/task-start-tool.ts +13 -14
  206. package/packages/piagent-core/runtime/session/model-authorship-state.ts +31 -3
  207. package/packages/piagent-core/runtime/session/runtime-state.ts +11 -1
  208. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +172 -0
  209. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +1 -0
  210. package/packages/piagent-webui/dist/client/index.html +16 -0
  211. package/packages/piagent-webui/extension/attachment-store.ts +323 -0
  212. package/packages/piagent-webui/extension/held-message-queue.ts +311 -0
  213. package/packages/piagent-webui/extension/lifecycle-controller.ts +408 -0
  214. package/packages/piagent-webui/extension/lifecycle-event-adapter.ts +23 -0
  215. package/packages/piagent-webui/extension/piagent-webui.ts +470 -0
  216. package/packages/piagent-webui/extension/review-controller.ts +160 -0
  217. package/packages/piagent-webui/extension/same-session-bridge.ts +500 -0
  218. package/packages/piagent-webui/extension/session-options-controller.ts +281 -0
  219. package/packages/piagent-webui/extension/session-stream-adapter.ts +249 -0
  220. package/packages/piagent-webui/extension/source-mutation-controller.ts +215 -0
  221. package/packages/piagent-webui/extension/source-open-controller.ts +136 -0
  222. package/packages/piagent-webui/extension/source-revert-controller.ts +189 -0
  223. package/packages/piagent-webui/extension/terminal-session-adapter.ts +73 -0
  224. package/packages/piagent-webui/extension/vscode-handoff.ts +51 -0
  225. package/packages/piagent-webui/gateway/control-socket.ts +92 -0
  226. package/packages/piagent-webui/gateway/gateway-events.ts +60 -0
  227. package/packages/piagent-webui/gateway/gateway-protocol-service.ts +64 -0
  228. package/packages/piagent-webui/gateway/gateway-service.ts +215 -0
  229. package/packages/piagent-webui/gateway/gateway-session-stream.ts +110 -0
  230. package/packages/piagent-webui/gateway/mcp-auth-broker.ts +158 -0
  231. package/packages/piagent-webui/gateway/native-project-picker.ts +29 -0
  232. package/packages/piagent-webui/gateway/pi-host.ts +41 -0
  233. package/packages/piagent-webui/gateway/profile-state.ts +1 -0
  234. package/packages/piagent-webui/gateway/project-registry.ts +108 -0
  235. package/packages/piagent-webui/gateway/provider-auth-broker.ts +172 -0
  236. package/packages/piagent-webui/gateway/session-catalog.ts +128 -0
  237. package/packages/piagent-webui/gateway/session-command-controller.ts +237 -0
  238. package/packages/piagent-webui/gateway/session-command-store.ts +224 -0
  239. package/packages/piagent-webui/gateway/session-inspection-registry.ts +204 -0
  240. package/packages/piagent-webui/gateway/session-lease-store.ts +1 -0
  241. package/packages/piagent-webui/gateway/session-metadata-store.ts +152 -0
  242. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +485 -0
  243. package/packages/piagent-webui/ownership/gateway-runtime-context.ts +8 -0
  244. package/packages/piagent-webui/ownership/profile-state.ts +117 -0
  245. package/packages/piagent-webui/ownership/session-lease-store.ts +329 -0
  246. package/packages/piagent-webui/ownership/session-refs.ts +8 -0
  247. package/packages/piagent-webui/package.json +33 -0
  248. package/packages/piagent-webui/server/README.md +4 -0
  249. package/packages/piagent-webui/server/benchmark-release-monitor.ts +230 -0
  250. package/packages/piagent-webui/server/core-inspection-provider.ts +403 -0
  251. package/packages/piagent-webui/server/gateway-websocket.ts +143 -0
  252. package/packages/piagent-webui/server/http-security.ts +27 -0
  253. package/packages/piagent-webui/server/ipc-read-model-client.ts +89 -0
  254. package/packages/piagent-webui/server/loopback-server.ts +368 -0
  255. package/packages/piagent-webui/server/read-model-provider.ts +37 -0
  256. package/packages/piagent-webui/server/read-only-router.ts +105 -0
  257. package/packages/piagent-webui/server/session-auth.ts +88 -0
  258. package/packages/piagent-webui/server/sidecar-main.ts +86 -0
  259. package/packages/piagent-webui/server/sse-hub.ts +76 -0
  260. package/packages/piagent-webui/server/static-bundle.ts +40 -0
  261. package/packages/piagent-webui/server/transcript-projection.ts +110 -0
  262. package/schemas/mutation-provenance-record.schema.json +100 -0
  263. package/schemas/piagent-webui/approval-v1.schema.json +654 -0
  264. package/schemas/piagent-webui/attachment-v1.schema.json +267 -0
  265. package/schemas/piagent-webui/capabilities-v1.schema.json +729 -0
  266. package/schemas/piagent-webui/catalog-v1.json +240 -0
  267. package/schemas/piagent-webui/commit-summary-v1.schema.json +63 -0
  268. package/schemas/piagent-webui/common-v1.schema.json +479 -0
  269. package/schemas/piagent-webui/control-command-v1.schema.json +1780 -0
  270. package/schemas/piagent-webui/diff-v1.schema.json +826 -0
  271. package/schemas/piagent-webui/gateway-capabilities-v1.schema.json +136 -0
  272. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +456 -0
  273. package/schemas/piagent-webui/handoff-history-v1.schema.json +92 -0
  274. package/schemas/piagent-webui/model-catalog-v1.schema.json +95 -0
  275. package/schemas/piagent-webui/queue-v1.schema.json +99 -0
  276. package/schemas/piagent-webui/recovery-history-v1.schema.json +144 -0
  277. package/schemas/piagent-webui/release-monitor-v1.schema.json +173 -0
  278. package/schemas/piagent-webui/review-state-v1.schema.json +206 -0
  279. package/schemas/piagent-webui/runtime-event-v2.schema.json +5206 -0
  280. package/schemas/piagent-webui/session-catalog-v1.schema.json +222 -0
  281. package/schemas/piagent-webui/session-command-v1.schema.json +390 -0
  282. package/schemas/piagent-webui/snapshot-v1.schema.json +2622 -0
  283. package/schemas/piagent-webui/source-change-v1.schema.json +763 -0
  284. package/schemas/piagent-webui/source-mutation-v1.schema.json +96 -0
  285. package/schemas/piagent-webui/source-revert-v1.schema.json +130 -0
  286. package/schemas/piagent-webui/subagent-tree-v1.schema.json +88 -0
  287. package/schemas/piagent-webui/task-index-v1.schema.json +74 -0
  288. package/schemas/piagent-webui/task-timeline-v1.schema.json +49 -0
  289. package/schemas/piagent-webui/transcript-v1.schema.json +243 -0
  290. package/schemas/task-baseline-manifest.schema.json +122 -0
  291. package/schemas/verifier-file-snapshot.schema.json +81 -0
  292. package/scripts/check-architecture.mjs +1 -0
  293. package/scripts/check-doc-languages.mjs +19 -2
  294. package/scripts/check-third-party-neutrality.mjs +43 -0
  295. package/scripts/install-global.sh +1 -1
  296. package/scripts/piagent-cli.mjs +17 -3
  297. package/scripts/piagent-dashboard.mjs +208 -0
  298. package/scripts/piagent-webui-launcher.mjs +121 -0
  299. package/scripts/typescript-loader.mjs +26 -1
  300. package/scripts/verify-local.sh +253 -0
  301. package/scripts/verify-release-identity.mjs +4 -1
@@ -0,0 +1,132 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export class WorkspaceReadError extends Error {
6
+ readonly code: "outside-root" | "symlink-ancestor" | "not-file" | "oversized" | "changed-during-read";
7
+
8
+ constructor(code: WorkspaceReadError["code"], message: string) {
9
+ super(message);
10
+ this.name = "WorkspaceReadError";
11
+ this.code = code;
12
+ }
13
+ }
14
+
15
+ type SafeCandidate = { root: string; absolute: string; parent: string };
16
+
17
+ function inside(root: string, candidate: string): boolean {
18
+ const relative = path.relative(root, candidate);
19
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
20
+ }
21
+
22
+ function safeCandidate(repoRoot: string, repoPath: string): SafeCandidate {
23
+ const root = fs.realpathSync.native(repoRoot);
24
+ const segments = repoPath.split("/");
25
+ if (!repoPath || path.posix.isAbsolute(repoPath) || segments.includes("..") || segments.includes("")) {
26
+ throw new WorkspaceReadError("outside-root", "Workspace path is not a safe repository-relative path");
27
+ }
28
+ let parent = root;
29
+ for (const segment of segments.slice(0, -1)) {
30
+ parent = path.join(parent, segment);
31
+ const stat = fs.lstatSync(parent);
32
+ if (stat.isSymbolicLink()) throw new WorkspaceReadError("symlink-ancestor", "Workspace path traverses a symbolic link");
33
+ if (!stat.isDirectory()) throw new WorkspaceReadError("not-file", "Workspace path has a non-directory ancestor");
34
+ }
35
+ const canonicalParent = fs.realpathSync.native(parent);
36
+ if (!inside(root, canonicalParent)) throw new WorkspaceReadError("outside-root", "Workspace path resolves outside its repository root");
37
+ const absolute = path.join(canonicalParent, segments.at(-1) as string);
38
+ if (!inside(root, absolute)) throw new WorkspaceReadError("outside-root", "Workspace path escapes its repository root");
39
+ return { root, absolute, parent: canonicalParent };
40
+ }
41
+
42
+ function sameFile(left: fs.Stats, right: fs.Stats): boolean {
43
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
44
+ }
45
+
46
+ function openRegularFile(repoRoot: string, repoPath: string): { descriptor: number; stat: fs.Stats; candidate: SafeCandidate } {
47
+ const candidate = safeCandidate(repoRoot, repoPath);
48
+ const descriptor = fs.openSync(candidate.absolute, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
49
+ try {
50
+ const stat = fs.fstatSync(descriptor);
51
+ if (!stat.isFile()) throw new WorkspaceReadError("not-file", "Workspace entry is not a regular file");
52
+ const canonical = fs.realpathSync.native(candidate.absolute);
53
+ const current = fs.lstatSync(candidate.absolute);
54
+ if (!inside(candidate.root, canonical) || !sameFile(stat, current)) {
55
+ throw new WorkspaceReadError("changed-during-read", "Workspace path changed while it was opened");
56
+ }
57
+ return { descriptor, stat, candidate };
58
+ } catch (error) {
59
+ fs.closeSync(descriptor);
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ function verifyAfterRead(descriptor: number, before: fs.Stats, candidate: SafeCandidate): void {
65
+ const after = fs.fstatSync(descriptor);
66
+ const current = fs.lstatSync(candidate.absolute);
67
+ const canonical = fs.realpathSync.native(candidate.absolute);
68
+ if (
69
+ !sameFile(before, after)
70
+ || !sameFile(after, current)
71
+ || before.size !== after.size
72
+ || before.mtimeMs !== after.mtimeMs
73
+ || before.ctimeMs !== after.ctimeMs
74
+ || !inside(candidate.root, canonical)
75
+ ) {
76
+ throw new WorkspaceReadError("changed-during-read", "Workspace file changed during inspection");
77
+ }
78
+ }
79
+
80
+ export function inspectWorkspaceEntry(repoRoot: string, repoPath: string): fs.Stats {
81
+ const candidate = safeCandidate(repoRoot, repoPath);
82
+ return fs.lstatSync(candidate.absolute);
83
+ }
84
+
85
+ export function readWorkspaceLink(repoRoot: string, repoPath: string): string {
86
+ const candidate = safeCandidate(repoRoot, repoPath);
87
+ const before = fs.lstatSync(candidate.absolute);
88
+ if (!before.isSymbolicLink()) throw new WorkspaceReadError("not-file", "Workspace entry is not a symbolic link");
89
+ const target = fs.readlinkSync(candidate.absolute);
90
+ const after = fs.lstatSync(candidate.absolute);
91
+ if (!sameFile(before, after) || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
92
+ throw new WorkspaceReadError("changed-during-read", "Workspace link changed during inspection");
93
+ }
94
+ return target;
95
+ }
96
+
97
+ export function readWorkspaceFile(repoRoot: string, repoPath: string, maxBytes: number): Buffer {
98
+ const opened = openRegularFile(repoRoot, repoPath);
99
+ try {
100
+ if (opened.stat.size > maxBytes) throw new WorkspaceReadError("oversized", "Workspace file exceeds its read cap");
101
+ const content = Buffer.allocUnsafe(opened.stat.size);
102
+ let position = 0;
103
+ while (position < content.length) {
104
+ const count = fs.readSync(opened.descriptor, content, position, content.length - position, position);
105
+ if (count <= 0) throw new WorkspaceReadError("changed-during-read", "Workspace file ended during inspection");
106
+ position += count;
107
+ }
108
+ verifyAfterRead(opened.descriptor, opened.stat, opened.candidate);
109
+ return content;
110
+ } finally {
111
+ fs.closeSync(opened.descriptor);
112
+ }
113
+ }
114
+
115
+ export function hashWorkspaceFile(repoRoot: string, repoPath: string): string {
116
+ const opened = openRegularFile(repoRoot, repoPath);
117
+ try {
118
+ const state = createHash("sha256");
119
+ const buffer = Buffer.allocUnsafe(256 * 1024);
120
+ let position = 0;
121
+ while (position < opened.stat.size) {
122
+ const count = fs.readSync(opened.descriptor, buffer, 0, Math.min(buffer.length, opened.stat.size - position), position);
123
+ if (count <= 0) throw new WorkspaceReadError("changed-during-read", "Workspace file ended during hashing");
124
+ state.update(buffer.subarray(0, count));
125
+ position += count;
126
+ }
127
+ verifyAfterRead(opened.descriptor, opened.stat, opened.candidate);
128
+ return state.digest("hex");
129
+ } finally {
130
+ fs.closeSync(opened.descriptor);
131
+ }
132
+ }
@@ -0,0 +1,96 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import {
4
+ collectSourceChangeViews,
5
+ type SourceChangeDocument,
6
+ type SourceChangeViews,
7
+ type SourceProjectionOptions
8
+ } from "./source-change-projection.ts";
9
+
10
+ export type WorkspaceSourceProjectionOptions = Omit<SourceProjectionOptions, "cwd" | "pageLimit"> & {
11
+ roots: string[];
12
+ pageLimit?: number;
13
+ };
14
+
15
+ function token(prefix: string, value: string): string {
16
+ return `${prefix}.${createHash("sha256").update(value).digest("hex")}`;
17
+ }
18
+
19
+ function combineDocuments(
20
+ documents: SourceChangeDocument[],
21
+ view: SourceChangeDocument["view"],
22
+ pageLimit: number
23
+ ): SourceChangeDocument {
24
+ const template = structuredClone(documents[0]);
25
+ const viewRevision = token("workspace-view", JSON.stringify(documents.map((document) => document.viewRevision)));
26
+ const bases = documents.flatMap((document) => document.bases as Array<Record<string, unknown>>);
27
+ const states = documents.map((document) => document.availability.state);
28
+ const anyUnavailable = states.includes("unavailable");
29
+ const anyStale = states.includes("stale");
30
+ const exact = !anyUnavailable && !anyStale;
31
+ const allFiles = exact
32
+ ? documents.flatMap((document) => document.files).sort((left, right) => {
33
+ const repo = String(left.repoRef).localeCompare(String(right.repoRef), "en");
34
+ return repo || String(left.path).localeCompare(String(right.path), "en");
35
+ })
36
+ : [];
37
+ const files = allFiles.slice(0, pageLimit);
38
+ const childTruncated = documents.some((document) => (document.page as any).truncated === true);
39
+ const rawTotal = exact ? documents.reduce((total, document) => total + Number((document.page as any).total ?? 0), 0) : 0;
40
+ const total = Math.min(1_000_000, rawTotal);
41
+ const truncated = exact && (childTruncated || files.length < allFiles.length || rawTotal > total);
42
+ const reasonCode = anyUnavailable ? "repository-view-unavailable" : anyStale ? "git-race" : null;
43
+ const rawRemoved = documents.reduce((sum, document) => sum + Number((document.redaction as any).valuesRemoved ?? 0), 0);
44
+ const valuesRemoved = Math.min(10_000, rawRemoved);
45
+ return {
46
+ ...template,
47
+ view,
48
+ viewRevision,
49
+ bases,
50
+ availability: exact
51
+ ? { state: "current", reasonCode: null, message: null }
52
+ : anyUnavailable
53
+ ? { state: "unavailable", reasonCode, message: "At least one repository view is unavailable" }
54
+ : { state: "stale", reasonCode, message: "At least one repository changed during collection" },
55
+ files,
56
+ page: {
57
+ cursor: null,
58
+ nextCursor: truncated ? token("workspace-page", `${viewRevision}:${pageLimit}`) : null,
59
+ total,
60
+ returned: files.length,
61
+ truncated
62
+ },
63
+ truncationReason: truncated ? "page-limit" : null,
64
+ redaction: {
65
+ applied: documents.some((document) => (document.redaction as any).applied === true),
66
+ valuesRemoved,
67
+ truncated: rawRemoved > valuesRemoved || documents.some((document) => (document.redaction as any).truncated === true)
68
+ },
69
+ health: exact
70
+ ? documents.some((document) => (document.health as any).state !== "ok")
71
+ ? { state: "degraded", reasonCode: "repository-view-degraded", message: "At least one repository view is degraded" }
72
+ : { state: "ok", reasonCode: null, message: null }
73
+ : anyUnavailable
74
+ ? { state: "unavailable", reasonCode, message: "At least one repository view is unavailable" }
75
+ : { state: "degraded", reasonCode, message: "At least one repository view is stale" },
76
+ issues: documents.flatMap((document) => document.issues as Array<Record<string, unknown>>).slice(0, 64)
77
+ };
78
+ }
79
+
80
+ export async function collectWorkspaceSourceChangeViews(options: WorkspaceSourceProjectionOptions): Promise<SourceChangeViews> {
81
+ const roots = [...new Set(options.roots)];
82
+ if (roots.length === 0) throw new Error("At least one workspace Git root is required");
83
+ if (roots.length > 32) throw new Error("Workspace source projection supports at most 32 Git roots");
84
+ const pageLimit = Number.isInteger(options.pageLimit) ? Math.max(1, Math.min(2_000, options.pageLimit as number)) : 300;
85
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
86
+ const raw = await Promise.all(roots.map((cwd) => collectSourceChangeViews({ ...options, cwd, pageLimit: 2_000, generatedAt })));
87
+ const collected = [...new Map(raw.map((views) => [String((views.workingTree.bases as any[])[0]?.repoRef), views])).values()];
88
+ const working = collected.map((views) => views.workingTree);
89
+ const staged = collected.map((views) => views.staged);
90
+ const task = collected.map((views) => views.task).filter((document): document is SourceChangeDocument => document !== null);
91
+ return {
92
+ workingTree: combineDocuments(working, "working-tree", pageLimit),
93
+ staged: combineDocuments(staged, "staged", pageLimit),
94
+ task: task.length === collected.length ? combineDocuments(task, "task", pageLimit) : null
95
+ };
96
+ }
@@ -0,0 +1,175 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const ZERO_TURN_CONFORMANCE_VERSION = "piagent-webui-zero-turn-v1" as const;
4
+
5
+ const EFFECT_KEYS = [
6
+ "providerRequests", "userMessages", "assistantMessages", "inputTokens", "outputTokens",
7
+ "cacheReadTokens", "cacheWriteTokens", "costMicros", "continuationConsumed", "turnTriggers"
8
+ ] as const;
9
+ type EffectKey = typeof EFFECT_KEYS[number];
10
+ export type ZeroTurnEffects = Record<EffectKey, number>;
11
+ export type ZeroTurnCausalEvent = {
12
+ sequence: number;
13
+ correlationId: string | null;
14
+ attribution: "ui-command" | "unrelated-operation" | "unknown";
15
+ effects: Partial<ZeroTurnEffects>;
16
+ };
17
+ export type ZeroTurnObservation = {
18
+ providerRequests: number;
19
+ userMessages: number;
20
+ assistantMessages: number;
21
+ usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; costMicros: number };
22
+ continuationConsumed: number;
23
+ turnTriggers: number;
24
+ sessionRef: string;
25
+ leafMessageRef: string | null;
26
+ messageSetDigest: string;
27
+ taskContractDigest: string | null;
28
+ journalHead: string | null;
29
+ promptDigest: string;
30
+ toolSchemaDigest: string;
31
+ latestCausalSequence: number;
32
+ causalEvents: ZeroTurnCausalEvent[];
33
+ };
34
+ export type ZeroTurnOptions = {
35
+ action: string;
36
+ commandId: string;
37
+ concurrency: "quiescent" | "concurrent";
38
+ mutationClass: "view" | "control";
39
+ allowedDigestChanges?: Array<"taskContractDigest" | "journalHead">;
40
+ };
41
+ export type ZeroTurnReport<T = unknown> = {
42
+ version: typeof ZERO_TURN_CONFORMANCE_VERSION;
43
+ passed: boolean;
44
+ action: string;
45
+ commandId: string;
46
+ concurrency: ZeroTurnOptions["concurrency"];
47
+ mutationClass: ZeroTurnOptions["mutationClass"];
48
+ violations: string[];
49
+ delta: ZeroTurnEffects;
50
+ result?: T;
51
+ };
52
+
53
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
54
+ const DIGEST = /^sha256:[a-f0-9]{64}$/;
55
+
56
+ function hash(value: string): string { return createHash("sha256").update(value).digest("hex"); }
57
+ function plain(value: unknown): value is Record<string, unknown> {
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
59
+ const prototype = Object.getPrototypeOf(value);
60
+ return prototype === Object.prototype || prototype === null;
61
+ }
62
+ function canonical(value: unknown, seen = new Set<object>()): unknown {
63
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
64
+ if (typeof value === "number" && Number.isFinite(value)) return value;
65
+ if (Array.isArray(value)) {
66
+ if (seen.has(value)) throw new Error("Cyclic value cannot be digested");
67
+ seen.add(value); const output = value.map((item) => canonical(item, seen)); seen.delete(value); return output;
68
+ }
69
+ if (!plain(value)) throw new Error("Only JSON-compatible facts can be digested");
70
+ if (seen.has(value)) throw new Error("Cyclic value cannot be digested");
71
+ seen.add(value);
72
+ const output = Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key], seen)]));
73
+ seen.delete(value); return output;
74
+ }
75
+ function nonnegativeInteger(value: unknown): value is number {
76
+ return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= Number.MAX_SAFE_INTEGER;
77
+ }
78
+ function zeroEffects(): ZeroTurnEffects {
79
+ return { providerRequests: 0, userMessages: 0, assistantMessages: 0, inputTokens: 0, outputTokens: 0,
80
+ cacheReadTokens: 0, cacheWriteTokens: 0, costMicros: 0, continuationConsumed: 0, turnTriggers: 0 };
81
+ }
82
+ function counters(value: ZeroTurnObservation): ZeroTurnEffects {
83
+ return { providerRequests: value.providerRequests, userMessages: value.userMessages, assistantMessages: value.assistantMessages,
84
+ inputTokens: value.usage.inputTokens, outputTokens: value.usage.outputTokens, cacheReadTokens: value.usage.cacheReadTokens,
85
+ cacheWriteTokens: value.usage.cacheWriteTokens, costMicros: value.usage.costMicros,
86
+ continuationConsumed: value.continuationConsumed, turnTriggers: value.turnTriggers };
87
+ }
88
+ function observationErrors(value: ZeroTurnObservation): string[] {
89
+ const errors: string[] = [], values = counters(value);
90
+ for (const key of EFFECT_KEYS) if (!nonnegativeInteger(values[key])) errors.push(`invalid-observation:${key}`);
91
+ if (!REF.test(value.sessionRef) || !(value.leafMessageRef === null || REF.test(value.leafMessageRef))) errors.push("invalid-observation:session-identity");
92
+ for (const [key, digest] of [["messageSetDigest", value.messageSetDigest], ["promptDigest", value.promptDigest], ["toolSchemaDigest", value.toolSchemaDigest]] as const) {
93
+ if (!DIGEST.test(digest)) errors.push(`invalid-observation:${key}`);
94
+ }
95
+ for (const [key, digest] of [["taskContractDigest", value.taskContractDigest], ["journalHead", value.journalHead]] as const) {
96
+ if (!(digest === null || DIGEST.test(digest) || REF.test(digest))) errors.push(`invalid-observation:${key}`);
97
+ }
98
+ if (!nonnegativeInteger(value.latestCausalSequence)) errors.push("invalid-observation:causal-sequence");
99
+ let previous = -1;
100
+ for (const event of value.causalEvents) {
101
+ if (!nonnegativeInteger(event.sequence) || event.sequence <= previous || event.sequence > value.latestCausalSequence) errors.push("invalid-observation:causal-event-order");
102
+ previous = event.sequence;
103
+ if (!(event.correlationId === null || REF.test(event.correlationId)) || !["ui-command", "unrelated-operation", "unknown"].includes(event.attribution)) errors.push("invalid-observation:causal-event-identity");
104
+ for (const [key, effect] of Object.entries(event.effects)) {
105
+ if (!EFFECT_KEYS.includes(key as EffectKey) || !nonnegativeInteger(effect)) errors.push(`invalid-observation:causal-effect:${key}`);
106
+ }
107
+ }
108
+ return errors;
109
+ }
110
+
111
+ export function digestZeroTurnFact(domain: string, value: unknown): string {
112
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(domain)) throw new Error("Invalid fact digest domain");
113
+ return `sha256:${hash(`piagent-webui-zero-turn:${domain}\n${JSON.stringify(canonical(value))}`)}`;
114
+ }
115
+
116
+ export function providerVisibleToolSchemaDigest(tools: Array<{ name: string; description?: string; parameters?: unknown }>): string {
117
+ const projected = tools.map((tool) => {
118
+ if (!REF.test(tool.name)) throw new Error(`Invalid provider-visible tool name: ${tool.name}`);
119
+ return { name: tool.name, description: String(tool.description ?? ""), parameters: canonical(tool.parameters ?? {}) };
120
+ }).sort((left, right) => left.name.localeCompare(right.name));
121
+ if (new Set(projected.map((tool) => tool.name)).size !== projected.length) throw new Error("Provider-visible tool names must be unique");
122
+ return digestZeroTurnFact("tool-schema", projected);
123
+ }
124
+
125
+ export function evaluateZeroTurn<T>(options: ZeroTurnOptions, before: ZeroTurnObservation, after: ZeroTurnObservation, result?: T): ZeroTurnReport<T> {
126
+ const violations = [...observationErrors(before).map((error) => `before:${error}`), ...observationErrors(after).map((error) => `after:${error}`)];
127
+ const beforeCounters = counters(before), afterCounters = counters(after), delta = zeroEffects();
128
+ for (const key of EFFECT_KEYS) {
129
+ delta[key] = afterCounters[key] - beforeCounters[key];
130
+ if (delta[key] < 0) violations.push(`counter-regressed:${key}`);
131
+ }
132
+ if (after.latestCausalSequence < before.latestCausalSequence) violations.push("causal-sequence-regressed");
133
+ const events = after.causalEvents.filter((event) => event.sequence > before.latestCausalSequence);
134
+ if (events.some((event) => event.sequence > after.latestCausalSequence)) violations.push("causal-event-after-observation");
135
+ if (options.concurrency === "quiescent") {
136
+ for (const key of EFFECT_KEYS) if (delta[key] !== 0) violations.push(`quiescent-counter-changed:${key}`);
137
+ if (events.some((event) => EFFECT_KEYS.some((key) => (event.effects[key] ?? 0) > 0))) violations.push("quiescent-causal-effect-observed");
138
+ } else {
139
+ const reconciled = zeroEffects();
140
+ for (const event of events) {
141
+ for (const key of EFFECT_KEYS) reconciled[key] += event.effects[key] ?? 0;
142
+ if (EFFECT_KEYS.some((key) => (event.effects[key] ?? 0) > 0)
143
+ && (event.attribution !== "unrelated-operation" || event.correlationId === options.commandId)) violations.push(`prohibited-causal-attribution:${event.sequence}`);
144
+ }
145
+ for (const key of EFFECT_KEYS) if (reconciled[key] !== delta[key]) violations.push(`unreconciled-concurrent-delta:${key}`);
146
+ }
147
+ if (before.sessionRef !== after.sessionRef) violations.push("session-replaced");
148
+ const unrelatedMessagesSettled = options.concurrency === "concurrent" && delta.userMessages + delta.assistantMessages > 0;
149
+ if (before.leafMessageRef !== after.leafMessageRef && !unrelatedMessagesSettled) violations.push("leaf-message-changed");
150
+ if (before.messageSetDigest !== after.messageSetDigest && !unrelatedMessagesSettled) violations.push("message-set-changed");
151
+ if (before.promptDigest !== after.promptDigest) violations.push("prompt-changed");
152
+ if (before.toolSchemaDigest !== after.toolSchemaDigest) violations.push("provider-tool-schema-changed");
153
+ const allowed = new Set(options.allowedDigestChanges ?? []);
154
+ if (before.taskContractDigest !== after.taskContractDigest && !allowed.has("taskContractDigest")) violations.push("task-contract-changed");
155
+ if (before.journalHead !== after.journalHead && !allowed.has("journalHead")) violations.push("journal-head-changed");
156
+ if (options.mutationClass === "view" && allowed.size > 0) violations.push("view-action-cannot-allow-authoritative-mutation");
157
+ return { version: ZERO_TURN_CONFORMANCE_VERSION, passed: violations.length === 0, action: options.action, commandId: options.commandId,
158
+ concurrency: options.concurrency, mutationClass: options.mutationClass, violations: [...new Set(violations)], delta, ...(result === undefined ? {} : { result }) };
159
+ }
160
+
161
+ export async function runZeroTurnConformance<T>(
162
+ options: ZeroTurnOptions,
163
+ observe: () => ZeroTurnObservation | Promise<ZeroTurnObservation>,
164
+ action: () => T | Promise<T>
165
+ ): Promise<ZeroTurnReport<T>> {
166
+ const before = structuredClone(await observe());
167
+ let result: T;
168
+ try { result = await action(); } catch (error) {
169
+ const after = structuredClone(await observe());
170
+ const report = evaluateZeroTurn<T>(options, before, after);
171
+ return { ...report, passed: false, violations: [...report.violations, `action-failed:${error instanceof Error ? error.name : "unknown"}`] };
172
+ }
173
+ const after = structuredClone(await observe());
174
+ return evaluateZeroTurn(options, before, after, result);
175
+ }
@@ -13,15 +13,19 @@ export const AUTOMATIC_OWNED_WORK_CEILINGS: OwnedWorkCeilings = Object.freeze({
13
13
  const LOCK_WAIT_MS = 5;
14
14
  const LOCK_WAIT_CEILING_MS = 250;
15
15
  const STALE_LOCK_MS = 30_000;
16
- type Reservation = { id: string; deduplicationKey: string; role: HelperRole; authority: string; status: "active" | "succeeded" | "failed" | "cancelled" | "orphaned"; reservedAt: string; expiresAt: string; completedAt: string | null; usageRef: { calls: number; tokens: number; outputDigest: string | null } | null };
17
- type BudgetState = { version: typeof OWNED_WORK_BUDGET_VERSION; taskId: string; taskRunId: string; terminal: boolean; reservations: Reservation[]; updatedAt: string };
18
- export type HelperReleaseResult = { accepted: boolean; status: Reservation["status"] | "stale"; reason: "released" | "helper-call-budget-exceeded" | "helper-token-budget-exceeded" | "reservation-not-active" };
16
+ export type OwnedWorkReservation = { id: string; deduplicationKey: string; role: HelperRole; authority: "read-only" | "single-writer"; status: "active" | "succeeded" | "failed" | "cancelled" | "orphaned"; reservedAt: string; expiresAt: string; completedAt: string | null; usageRef: { calls: number; tokens: number; outputDigest: string | null } | null };
17
+ type BudgetState = { version: typeof OWNED_WORK_BUDGET_VERSION; taskId: string; taskRunId: string; terminal: boolean; reservations: OwnedWorkReservation[]; updatedAt: string };
18
+ export type OwnedWorkBudgetInspection = {
19
+ state: "missing" | "ready" | "corrupt"; terminal: boolean | null; reservations: OwnedWorkReservation[];
20
+ updatedAt: string | null; derivedOrphans: number; reasonCode: string | null;
21
+ };
22
+ export type HelperReleaseResult = { accepted: boolean; status: OwnedWorkReservation["status"] | "stale"; reason: "released" | "helper-call-budget-exceeded" | "helper-token-budget-exceeded" | "reservation-not-active" };
19
23
 
20
24
  function digest(value: unknown): string { return crypto.createHash("sha256").update(String(value ?? "")).digest("hex"); }
21
25
  function statePath(cwd: string, taskRunId: string): string { return path.join(cwd, ".pi", "piagent-state", "helper-budgets", `${safeTaskId(taskRunId)}.json`); }
22
26
  function lockPath(cwd: string, taskRunId: string): string { return `${statePath(cwd, taskRunId)}.lock`; }
23
27
  function empty(request: HelperRequest, now: string): BudgetState { return { version: OWNED_WORK_BUDGET_VERSION, taskId: request.taskId, taskRunId: request.taskRunId, terminal: false, reservations: [], updatedAt: now }; }
24
- function active(state: BudgetState): Reservation[] { return state.reservations.filter((item) => item.status === "active"); }
28
+ function active(state: BudgetState): OwnedWorkReservation[] { return state.reservations.filter((item) => item.status === "active"); }
25
29
  function roleLimit(role: HelperRole): number { return role === "scout" || role === "retriever" || role === "researcher" ? 1 : role === "planner" ? 1 : role === "reviewer" ? 1 : role === "oracle" ? 1 : role === "worker" ? 1 : 0; }
26
30
 
27
31
  function read(cwd: string, request: HelperRequest, now: string): BudgetState {
@@ -63,6 +67,66 @@ function recoverOrphans(state: BudgetState, now: string): number {
63
67
  return recovered;
64
68
  }
65
69
 
70
+ function exactTimestamp(value: unknown): value is string {
71
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
72
+ && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
73
+ }
74
+ function exactFields(value: Record<string, unknown>, fields: string[]): boolean {
75
+ return Object.keys(value).length === fields.length && fields.every((field) => field in value);
76
+ }
77
+ function validInspectionState(value: unknown, taskId: string, taskRunId: string): value is BudgetState {
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
79
+ const state = value as Record<string, any>;
80
+ if (!exactFields(state, ["version", "taskId", "taskRunId", "terminal", "reservations", "updatedAt"])
81
+ || state.version !== OWNED_WORK_BUDGET_VERSION || state.taskId !== taskId || state.taskRunId !== taskRunId
82
+ || typeof state.terminal !== "boolean" || !exactTimestamp(state.updatedAt) || !Array.isArray(state.reservations)
83
+ || state.reservations.length > 64) return false;
84
+ const ids = new Set<string>(); let activeWriters = 0;
85
+ for (const item of state.reservations) {
86
+ if (!item || typeof item !== "object" || Array.isArray(item)
87
+ || !exactFields(item, ["id", "deduplicationKey", "role", "authority", "status", "reservedAt", "expiresAt", "completedAt", "usageRef"])
88
+ || typeof item.id !== "string" || !/^[a-f0-9]{32}$/.test(item.id) || ids.has(item.id)
89
+ || typeof item.deduplicationKey !== "string" || !/^[a-f0-9]{64}$/.test(item.deduplicationKey)
90
+ || !["retriever", "scout", "planner", "worker", "reviewer", "oracle", "researcher"].includes(item.role)
91
+ || !["read-only", "single-writer"].includes(item.authority)
92
+ || !["active", "succeeded", "failed", "cancelled", "orphaned"].includes(item.status)
93
+ || !exactTimestamp(item.reservedAt) || !exactTimestamp(item.expiresAt) || Date.parse(item.expiresAt) < Date.parse(item.reservedAt)
94
+ || item.completedAt !== null && !exactTimestamp(item.completedAt)
95
+ || item.status === "active" && item.completedAt !== null || item.status !== "active" && item.completedAt === null) return false;
96
+ ids.add(item.id); if (item.status === "active" && item.authority === "single-writer") activeWriters += 1;
97
+ if (item.usageRef !== null) {
98
+ if (typeof item.usageRef !== "object" || Array.isArray(item.usageRef)
99
+ || !exactFields(item.usageRef, ["calls", "tokens", "outputDigest"])
100
+ || !Number.isInteger(item.usageRef.calls) || item.usageRef.calls < 0 || item.usageRef.calls > 101
101
+ || !Number.isInteger(item.usageRef.tokens) || item.usageRef.tokens < 0 || item.usageRef.tokens > 100001
102
+ || item.usageRef.outputDigest !== null && (typeof item.usageRef.outputDigest !== "string" || !/^[a-f0-9]{64}$/.test(item.usageRef.outputDigest))) return false;
103
+ }
104
+ }
105
+ return activeWriters <= 1 && (!state.terminal || !state.reservations.some((item: any) => item.status === "active"));
106
+ }
107
+
108
+ /** Read-only projection input. It never repairs, locks, or writes helper state. */
109
+ export function inspectOwnedWorkBudget(cwd: string, taskId: string, taskRunId: string, now = new Date().toISOString()): OwnedWorkBudgetInspection {
110
+ try {
111
+ if (!exactTimestamp(now)) return { state: "corrupt", terminal: null, reservations: [], updatedAt: null, derivedOrphans: 0, reasonCode: "invalid-inspection-time" };
112
+ const target = statePath(cwd, taskRunId); if (!fs.existsSync(target)) return { state: "missing", terminal: null, reservations: [], updatedAt: null, derivedOrphans: 0, reasonCode: "helper-budget-missing" };
113
+ const resolved = resolveLocalStatePath(cwd, target, { label: "Helper budget state", kind: "file" });
114
+ const descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); let value;
115
+ try {
116
+ const before = fs.fstatSync(descriptor, { bigint: true }); if (!before.isFile() || before.size > BigInt(1024 * 1024)) throw new Error("helper-budget-size");
117
+ value = JSON.parse(fs.readFileSync(descriptor, "utf8")); const after = fs.fstatSync(descriptor, { bigint: true });
118
+ const fields = ["dev", "ino", "mode", "size", "mtimeNs", "ctimeNs"] as const;
119
+ if (fields.some((field) => before[field] !== after[field])) throw new Error("helper-budget-changed");
120
+ } finally { fs.closeSync(descriptor); }
121
+ if (!validInspectionState(value, taskId, taskRunId)) return { state: "corrupt", terminal: null, reservations: [], updatedAt: null, derivedOrphans: 0, reasonCode: "helper-budget-invalid" };
122
+ const clone = structuredClone(value), derivedOrphans = recoverOrphans(clone, now);
123
+ return { state: "ready", terminal: clone.terminal, reservations: clone.reservations, updatedAt: clone.updatedAt, derivedOrphans,
124
+ reasonCode: derivedOrphans ? "expired-helper-derived-orphan" : null };
125
+ } catch {
126
+ return { state: "corrupt", terminal: null, reservations: [], updatedAt: null, derivedOrphans: 0, reasonCode: "helper-budget-unreadable" };
127
+ }
128
+ }
129
+
66
130
  export class OwnedWorkBudgetController {
67
131
  reserve(cwd: string, requestInput: HelperRequest, now = new Date().toISOString(), ceilings: OwnedWorkCeilings = DEFAULT_OWNED_WORK_CEILINGS): { decision: "reserved" | "duplicate" | "blocked"; reason: string; reservationId: string | null; recoveredOrphans: number } {
68
132
  const request = validateHelperRequest(structuredClone(requestInput));