@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,493 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { localFilterDisableArgs } from "./git-filter-safety.ts";
7
+
8
+ const DEFAULT_TIMEOUT_MS = 5_000;
9
+ const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
10
+ const DISPLAY_PATH_MAX = 1_024;
11
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
12
+
13
+ export type RawGitStatus = "." | "M" | "T" | "A" | "D" | "R" | "C" | "U" | "?" | "!";
14
+
15
+ export type GitPath = {
16
+ value: string | null;
17
+ display: string | null;
18
+ displayMode: "exact-safe" | "escaped" | "unavailable";
19
+ digest: string;
20
+ };
21
+
22
+ export type GitStatusRecord = {
23
+ kind: "ordinary" | "renamed" | "unmerged" | "untracked" | "ignored";
24
+ indexStatus: RawGitStatus;
25
+ worktreeStatus: RawGitStatus;
26
+ submodule: string | null;
27
+ headMode: string | null;
28
+ indexMode: string | null;
29
+ worktreeMode: string | null;
30
+ headObject: string | null;
31
+ indexObject: string | null;
32
+ renameScore: string | null;
33
+ path: GitPath;
34
+ oldPath: GitPath | null;
35
+ };
36
+
37
+ export type GitStatusSnapshot = {
38
+ repoRoot: string;
39
+ repoDigest: string;
40
+ headState: "head" | "unborn";
41
+ headOid: string | null;
42
+ branchHead: string | null;
43
+ headers: Readonly<Record<string, string>>;
44
+ records: GitStatusRecord[];
45
+ rawDigest: string;
46
+ raw: Buffer;
47
+ };
48
+
49
+ export class GitInspectionError extends Error {
50
+ readonly code: "not-git" | "timeout" | "output-limit" | "git-failed" | "invalid-output";
51
+ readonly exitCode: number | null;
52
+
53
+ constructor(
54
+ code: GitInspectionError["code"],
55
+ message: string,
56
+ options: { exitCode?: number | null; cause?: unknown } = {}
57
+ ) {
58
+ super(message, { cause: options.cause });
59
+ this.name = "GitInspectionError";
60
+ this.code = code;
61
+ this.exitCode = options.exitCode ?? null;
62
+ }
63
+ }
64
+
65
+ export type ReadOnlyGitOptions = {
66
+ timeoutMs?: number;
67
+ maxBytes?: number;
68
+ input?: Buffer;
69
+ };
70
+
71
+ function sha256(value: Buffer | string): string {
72
+ return createHash("sha256").update(value).digest("hex");
73
+ }
74
+
75
+ function boundedInteger(value: number | undefined, fallback: number, minimum: number, maximum: number): number {
76
+ return Number.isInteger(value) ? Math.max(minimum, Math.min(maximum, value as number)) : fallback;
77
+ }
78
+
79
+ function gitEnvironment(): NodeJS.ProcessEnv {
80
+ const env: NodeJS.ProcessEnv = {
81
+ PATH: process.env.PATH,
82
+ LC_ALL: "C",
83
+ LANG: "C",
84
+ GIT_OPTIONAL_LOCKS: "0",
85
+ GIT_CONFIG_NOSYSTEM: "1",
86
+ GIT_CONFIG_GLOBAL: os.devNull,
87
+ GIT_PAGER: "cat",
88
+ PAGER: "cat",
89
+ GIT_TERMINAL_PROMPT: "0",
90
+ GIT_ASKPASS: ""
91
+ };
92
+ if (process.platform === "win32") {
93
+ env.SystemRoot = process.env.SystemRoot;
94
+ env.ComSpec = process.env.ComSpec;
95
+ }
96
+ return env;
97
+ }
98
+
99
+ const SAFE_GLOBAL_ARGS = [
100
+ "--no-pager",
101
+ "--no-optional-locks",
102
+ "--literal-pathspecs",
103
+ "-c", "color.ui=false",
104
+ "-c", "core.fsmonitor=false",
105
+ "-c", `core.hooksPath=${os.devNull}`,
106
+ "-c", "diff.external=",
107
+ "-c", "diff.trustExitCode=false",
108
+ "-c", "submodule.recurse=false"
109
+ ] as const;
110
+
111
+ const READ_ONLY_COMMANDS = new Set(["cat-file", "diff", "hash-object", "ls-files", "ls-tree", "rev-parse", "show", "status"]);
112
+
113
+ function validateReadOnlyArgs(args: readonly string[]): void {
114
+ const command = args[0];
115
+ if (!command || !READ_ONLY_COMMANDS.has(command)) {
116
+ throw new GitInspectionError("invalid-output", `Git subcommand ${JSON.stringify(command)} is not allowed for inspection`);
117
+ }
118
+ if (args.some((arg) => arg === "-w" || arg === "--write" || arg === "--literally" || arg === "--output" || arg.startsWith("--output="))) {
119
+ throw new GitInspectionError("invalid-output", "A mutating or output-writing Git option was rejected");
120
+ }
121
+ if (command === "hash-object" && !(
122
+ args.length === 4
123
+ && args[1] === "-t"
124
+ && args[2] === "tree"
125
+ && args[3] === os.devNull
126
+ )) {
127
+ throw new GitInspectionError("invalid-output", "Only empty-tree hashing is allowed during Git inspection");
128
+ }
129
+ if (command === "ls-tree" && !(
130
+ args.length === 5
131
+ && args[1] === "-z"
132
+ && /^[a-f0-9]{40,64}$/.test(args[2] ?? "")
133
+ && args[3] === "--"
134
+ )) {
135
+ throw new GitInspectionError("invalid-output", "Only single-path tree inspection is allowed");
136
+ }
137
+ if (
138
+ (command === "diff" && args.some((arg) => arg === "--no-index" || arg === "--ext-diff" || arg === "--textconv"))
139
+ || (command === "show" && args.some((arg) => arg === "--ext-diff" || arg === "--textconv"))
140
+ || (command === "ls-files" && args.includes("--recurse-submodules"))
141
+ ) {
142
+ throw new GitInspectionError("invalid-output", "An external or recursive Git inspection option was rejected");
143
+ }
144
+ const separator = args.indexOf("--");
145
+ if (separator >= 0 && args.slice(separator + 1).some((arg) => {
146
+ const segments = arg.split("/");
147
+ return !arg || path.posix.isAbsolute(arg) || path.win32.isAbsolute(arg) || segments.includes("..");
148
+ })) {
149
+ throw new GitInspectionError("invalid-output", "Git path arguments must stay repository-relative");
150
+ }
151
+ if (command === "cat-file" && !args.slice(1).every((arg) =>
152
+ arg === "blob"
153
+ || arg === "-s"
154
+ || arg === "--batch"
155
+ || arg.startsWith("--batch-check")
156
+ || /^[a-f0-9]{40,64}$/.test(arg)
157
+ )) {
158
+ throw new GitInspectionError("invalid-output", "Unsupported cat-file inspection option");
159
+ }
160
+ }
161
+
162
+ export async function runReadOnlyGit(
163
+ cwd: string,
164
+ args: readonly string[],
165
+ options: ReadOnlyGitOptions = {}
166
+ ): Promise<Buffer> {
167
+ if (!path.isAbsolute(cwd) || args.some((arg) => typeof arg !== "string" || arg.includes("\0"))) {
168
+ throw new GitInspectionError("invalid-output", "Git inspection requires an absolute cwd and NUL-free argv");
169
+ }
170
+ validateReadOnlyArgs(args);
171
+ const timeoutMs = boundedInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 100, 60_000);
172
+ const maxBytes = boundedInteger(options.maxBytes, DEFAULT_MAX_BYTES, 1_024, 64 * 1024 * 1024);
173
+ const input = options.input;
174
+ if (input && input.length > 1024 * 1024) throw new GitInspectionError("output-limit", "Git inspection input exceeded its cap");
175
+ let filterArgs: string[] = [];
176
+ if (args[0] === "status" || args[0] === "diff") try { filterArgs = await localFilterDisableArgs(cwd, timeoutMs); }
177
+ catch (cause) { throw new GitInspectionError("git-failed", "Unable to disable repository Git filters", { cause }); }
178
+ const argv = [...SAFE_GLOBAL_ARGS, ...filterArgs, "-C", cwd, ...args];
179
+
180
+ return await new Promise<Buffer>((resolve, reject) => {
181
+ const child = spawn("git", argv, {
182
+ cwd,
183
+ env: gitEnvironment(),
184
+ shell: false,
185
+ stdio: ["pipe", "pipe", "pipe"],
186
+ windowsHide: true
187
+ });
188
+ const stdout: Buffer[] = [];
189
+ const stderr: Buffer[] = [];
190
+ let stdoutBytes = 0;
191
+ let stderrBytes = 0;
192
+ let failure: GitInspectionError | null = null;
193
+ let settled = false;
194
+
195
+ const fail = (error: GitInspectionError) => {
196
+ if (failure) return;
197
+ failure = error;
198
+ child.kill("SIGKILL");
199
+ };
200
+ const timer = setTimeout(() => fail(new GitInspectionError("timeout", "Git inspection timed out")), timeoutMs);
201
+ timer.unref?.();
202
+ child.stdin.on("error", () => {});
203
+ child.stdin.end(input);
204
+ child.stdout.on("data", (chunk: Buffer) => {
205
+ stdoutBytes += chunk.length;
206
+ if (stdoutBytes > maxBytes) return fail(new GitInspectionError("output-limit", "Git inspection exceeded its output cap"));
207
+ stdout.push(chunk);
208
+ });
209
+ child.stderr.on("data", (chunk: Buffer) => {
210
+ stderrBytes += chunk.length;
211
+ if (stderrBytes <= 64 * 1024) stderr.push(chunk);
212
+ });
213
+ child.once("error", (cause) => {
214
+ clearTimeout(timer);
215
+ if (settled) return;
216
+ settled = true;
217
+ reject(new GitInspectionError("git-failed", "Unable to start Git inspection", { cause }));
218
+ });
219
+ child.once("close", (code) => {
220
+ clearTimeout(timer);
221
+ if (settled) return;
222
+ settled = true;
223
+ if (failure) return reject(failure);
224
+ if (code !== 0) {
225
+ const detail = Buffer.concat(stderr).toString("utf8").replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 300);
226
+ const notGit = /not a git repository/i.test(detail);
227
+ return reject(new GitInspectionError(notGit ? "not-git" : "git-failed", detail || "Git inspection failed", { exitCode: code }));
228
+ }
229
+ resolve(Buffer.concat(stdout));
230
+ });
231
+ });
232
+ }
233
+
234
+ export async function readGitBlobDigests(
235
+ repoRoot: string,
236
+ objectIds: readonly string[],
237
+ options: ReadOnlyGitOptions & { maxBlobBytes?: number } = {}
238
+ ): Promise<Map<string, string>> {
239
+ const ids = [...new Set(objectIds.filter((value) => /^[a-f0-9]{40,64}$/.test(value)))];
240
+ const result = new Map<string, string>();
241
+ if (ids.length === 0) return result;
242
+ const input = Buffer.from(`${ids.join("\n")}\n`, "ascii");
243
+ const checked = await runReadOnlyGit(repoRoot, ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], {
244
+ ...options,
245
+ input,
246
+ maxBytes: Math.max(4_096, ids.length * 160)
247
+ });
248
+ const maxBlobBytes = boundedInteger(options.maxBlobBytes, 4 * 1024 * 1024, 1_024, 16 * 1024 * 1024);
249
+ const sizes = new Map<string, number>();
250
+ for (const line of checked.toString("ascii").trim().split("\n")) {
251
+ const [oid, type, rawSize] = line.split(" ");
252
+ const size = Number(rawSize);
253
+ if (/^[a-f0-9]{40,64}$/.test(oid ?? "") && type === "blob" && Number.isSafeInteger(size) && size >= 0 && size <= maxBlobBytes) {
254
+ sizes.set(oid, size);
255
+ }
256
+ }
257
+ let chunk: string[] = [];
258
+ let expectedBytes = 0;
259
+ const flush = async () => {
260
+ if (chunk.length === 0) return;
261
+ const payload = Buffer.from(`${chunk.join("\n")}\n`, "ascii");
262
+ const output = await runReadOnlyGit(repoRoot, ["cat-file", "--batch"], {
263
+ ...options,
264
+ input: payload,
265
+ maxBytes: Math.max(4_096, expectedBytes + chunk.length * 160)
266
+ });
267
+ let offset = 0;
268
+ for (const requested of chunk) {
269
+ const newline = output.indexOf(0x0a, offset);
270
+ if (newline < 0) throw new GitInspectionError("invalid-output", "Malformed cat-file batch header");
271
+ const [oid, type, rawSize] = output.subarray(offset, newline).toString("ascii").split(" ");
272
+ const size = Number(rawSize);
273
+ const start = newline + 1;
274
+ const end = start + size;
275
+ if (oid !== requested || type !== "blob" || !Number.isSafeInteger(size) || end >= output.length || output[end] !== 0x0a) {
276
+ throw new GitInspectionError("invalid-output", "Malformed cat-file batch payload");
277
+ }
278
+ result.set(oid, `sha256:${sha256(output.subarray(start, end))}`);
279
+ offset = end + 1;
280
+ }
281
+ chunk = [];
282
+ expectedBytes = 0;
283
+ };
284
+ for (const oid of ids) {
285
+ const size = sizes.get(oid);
286
+ if (size === undefined) continue;
287
+ if (chunk.length >= 64 || expectedBytes + size > 8 * 1024 * 1024) await flush();
288
+ chunk.push(oid);
289
+ expectedBytes += size;
290
+ }
291
+ await flush();
292
+ return result;
293
+ }
294
+
295
+ export async function readGitBlob(repoRoot: string, objectId: string, options: ReadOnlyGitOptions = {}): Promise<Buffer> {
296
+ if (!/^[a-f0-9]{40,64}$/.test(objectId)) throw new GitInspectionError("invalid-output", "Invalid Git blob object ID");
297
+ const maxBytes = boundedInteger(options.maxBytes, 4 * 1024 * 1024, 1_024, 16 * 1024 * 1024);
298
+ const size = await runReadOnlyGit(repoRoot, ["cat-file", "-s", objectId], { ...options, maxBytes: 1024 });
299
+ const parsed = Number(size.toString("ascii").trim());
300
+ if (!Number.isSafeInteger(parsed) || parsed < 0) throw new GitInspectionError("invalid-output", "Git returned an invalid blob size");
301
+ if (parsed > maxBytes) throw new GitInspectionError("output-limit", "Git blob exceeds its read cap");
302
+ return await runReadOnlyGit(repoRoot, ["cat-file", "blob", objectId], { ...options, maxBytes: Math.max(1024, parsed + 1) });
303
+ }
304
+
305
+ function splitNul(input: Buffer): Buffer[] {
306
+ const fields: Buffer[] = [];
307
+ let start = 0;
308
+ for (let index = 0; index < input.length; index += 1) {
309
+ if (input[index] !== 0) continue;
310
+ fields.push(input.subarray(start, index));
311
+ start = index + 1;
312
+ }
313
+ if (start < input.length) fields.push(input.subarray(start));
314
+ return fields;
315
+ }
316
+
317
+ function prefixedFields(record: Buffer, count: number): { fields: string[]; remainder: Buffer } {
318
+ const fields: string[] = [];
319
+ let start = 2;
320
+ for (let index = start; index < record.length && fields.length < count; index += 1) {
321
+ if (record[index] !== 0x20) continue;
322
+ fields.push(record.subarray(start, index).toString("ascii"));
323
+ start = index + 1;
324
+ }
325
+ if (fields.length !== count || start > record.length) {
326
+ throw new GitInspectionError("invalid-output", "Malformed porcelain-v2 record");
327
+ }
328
+ return { fields, remainder: record.subarray(start) };
329
+ }
330
+
331
+ function escapedDisplay(value: string): string {
332
+ let output = "";
333
+ for (const character of value) {
334
+ const code = character.codePointAt(0) as number;
335
+ if (character === "%") output += "%25";
336
+ else if (character === "\\") output += "%5C";
337
+ else if (code <= 0x1f || code === 0x7f) {
338
+ output += Buffer.from(character, "utf8").toString("hex").match(/../g)?.map((byte) => `%${byte.toUpperCase()}`).join("") ?? "";
339
+ } else output += character;
340
+ }
341
+ return output;
342
+ }
343
+
344
+ function gitPath(raw: Buffer): GitPath {
345
+ const digest = `sha256:${sha256(raw)}`;
346
+ try {
347
+ const value = utf8.decode(raw);
348
+ const segments = value.split("/");
349
+ const unsafeAuthority = !value || path.posix.isAbsolute(value) || segments.includes("..") || value.includes("\0");
350
+ const needsEscape = /[\u0000-\u001f\u007f\\%]/u.test(value);
351
+ const display = needsEscape ? escapedDisplay(value) : value;
352
+ if (unsafeAuthority || !display || display.length > DISPLAY_PATH_MAX) {
353
+ return { value: null, display: null, displayMode: "unavailable", digest };
354
+ }
355
+ return { value, display, displayMode: needsEscape ? "escaped" : "exact-safe", digest };
356
+ } catch {
357
+ return { value: null, display: null, displayMode: "unavailable", digest };
358
+ }
359
+ }
360
+
361
+ export function projectGitPath(value: string): GitPath {
362
+ return gitPath(Buffer.from(value, "utf8"));
363
+ }
364
+
365
+ function statusPair(value: string): [RawGitStatus, RawGitStatus] {
366
+ if (!/^[.MTADRCU?!]{2}$/.test(value)) throw new GitInspectionError("invalid-output", "Invalid porcelain-v2 XY status");
367
+ return [value[0] as RawGitStatus, value[1] as RawGitStatus];
368
+ }
369
+
370
+ export function parsePorcelainV2(raw: Buffer, repoRoot: string): GitStatusSnapshot {
371
+ const tokens = splitNul(raw);
372
+ const headers: Record<string, string> = {};
373
+ const records: GitStatusRecord[] = [];
374
+ for (let index = 0; index < tokens.length; index += 1) {
375
+ const token = tokens[index];
376
+ if (token.length === 0) continue;
377
+ const kind = String.fromCharCode(token[0]);
378
+ if (kind === "#") {
379
+ const header = token.subarray(2).toString("utf8");
380
+ const separator = header.indexOf(" ");
381
+ headers[separator < 0 ? header : header.slice(0, separator)] = separator < 0 ? "" : header.slice(separator + 1);
382
+ continue;
383
+ }
384
+ if (kind === "?" || kind === "!") {
385
+ records.push({
386
+ kind: kind === "?" ? "untracked" : "ignored",
387
+ indexStatus: kind,
388
+ worktreeStatus: kind,
389
+ submodule: null,
390
+ headMode: null,
391
+ indexMode: null,
392
+ worktreeMode: null,
393
+ headObject: null,
394
+ indexObject: null,
395
+ renameScore: null,
396
+ path: gitPath(token.subarray(2)),
397
+ oldPath: null
398
+ });
399
+ continue;
400
+ }
401
+ if (kind === "1") {
402
+ const parsed = prefixedFields(token, 7);
403
+ const [indexStatus, worktreeStatus] = statusPair(parsed.fields[0]);
404
+ records.push({
405
+ kind: "ordinary", indexStatus, worktreeStatus,
406
+ submodule: parsed.fields[1], headMode: parsed.fields[2], indexMode: parsed.fields[3], worktreeMode: parsed.fields[4],
407
+ headObject: parsed.fields[5], indexObject: parsed.fields[6], renameScore: null,
408
+ path: gitPath(parsed.remainder), oldPath: null
409
+ });
410
+ continue;
411
+ }
412
+ if (kind === "2") {
413
+ const parsed = prefixedFields(token, 8);
414
+ const original = tokens[++index];
415
+ if (!original) throw new GitInspectionError("invalid-output", "Rename record is missing its original path");
416
+ const [indexStatus, worktreeStatus] = statusPair(parsed.fields[0]);
417
+ records.push({
418
+ kind: "renamed", indexStatus, worktreeStatus,
419
+ submodule: parsed.fields[1], headMode: parsed.fields[2], indexMode: parsed.fields[3], worktreeMode: parsed.fields[4],
420
+ headObject: parsed.fields[5], indexObject: parsed.fields[6], renameScore: parsed.fields[7],
421
+ path: gitPath(parsed.remainder), oldPath: gitPath(original)
422
+ });
423
+ continue;
424
+ }
425
+ if (kind === "u") {
426
+ const parsed = prefixedFields(token, 9);
427
+ const [indexStatus, worktreeStatus] = statusPair(parsed.fields[0]);
428
+ records.push({
429
+ kind: "unmerged", indexStatus, worktreeStatus,
430
+ submodule: parsed.fields[1], headMode: parsed.fields[2], indexMode: parsed.fields[3], worktreeMode: parsed.fields[5],
431
+ headObject: parsed.fields[6], indexObject: parsed.fields[7], renameScore: null,
432
+ path: gitPath(parsed.remainder), oldPath: null
433
+ });
434
+ continue;
435
+ }
436
+ throw new GitInspectionError("invalid-output", `Unsupported porcelain-v2 record kind ${JSON.stringify(kind)}`);
437
+ }
438
+
439
+ const canonicalRoot = fs.realpathSync.native(repoRoot);
440
+ const headOid = headers["branch.oid"] && headers["branch.oid"] !== "(initial)" ? headers["branch.oid"] : null;
441
+ return {
442
+ repoRoot: canonicalRoot,
443
+ repoDigest: sha256(canonicalRoot),
444
+ headState: headOid ? "head" : "unborn",
445
+ headOid,
446
+ branchHead: headers["branch.head"] ?? null,
447
+ headers,
448
+ records,
449
+ rawDigest: sha256(raw),
450
+ raw
451
+ };
452
+ }
453
+
454
+ export async function discoverGitRoot(cwd: string, options: ReadOnlyGitOptions = {}): Promise<string> {
455
+ const absolute = path.resolve(cwd);
456
+ const output = await runReadOnlyGit(absolute, ["rev-parse", "--path-format=absolute", "--show-toplevel"], options);
457
+ let decoded: string;
458
+ try {
459
+ decoded = utf8.decode(output).replace(/\r?\n$/, "");
460
+ } catch (cause) {
461
+ throw new GitInspectionError("invalid-output", "Git root is not valid UTF-8", { cause });
462
+ }
463
+ if (!path.isAbsolute(decoded)) throw new GitInspectionError("invalid-output", "Git returned a non-absolute repository root");
464
+ return fs.realpathSync.native(decoded);
465
+ }
466
+
467
+ export async function collectGitStatus(cwd: string, options: ReadOnlyGitOptions = {}): Promise<GitStatusSnapshot> {
468
+ const repoRoot = await discoverGitRoot(cwd, options);
469
+ const raw = await runReadOnlyGit(repoRoot, [
470
+ "status",
471
+ "--porcelain=v2",
472
+ "-z",
473
+ "--branch",
474
+ "--renames",
475
+ "--untracked-files=all",
476
+ "--ignore-submodules=dirty"
477
+ ], options);
478
+ return parsePorcelainV2(raw, repoRoot);
479
+ }
480
+
481
+ export async function collectGitStatusForPaths(cwd: string, repoPaths: string[], options: ReadOnlyGitOptions = {}): Promise<GitStatusSnapshot> {
482
+ if (repoPaths.length === 0 || repoPaths.length > 8) throw new GitInspectionError("invalid-output", "Targeted Git status requires one to eight paths");
483
+ const safe = [...new Set(repoPaths)].map((repoPath) => {
484
+ const projected = projectGitPath(repoPath);
485
+ if (projected.value !== repoPath) throw new GitInspectionError("invalid-output", "Targeted Git status path is unsafe");
486
+ return repoPath;
487
+ });
488
+ const repoRoot = await discoverGitRoot(cwd, options);
489
+ const raw = await runReadOnlyGit(repoRoot, [
490
+ "status", "--porcelain=v2", "-z", "--branch", "--renames", "--untracked-files=all", "--ignore-submodules=dirty", "--", ...safe
491
+ ], options);
492
+ return parsePorcelainV2(raw, repoRoot);
493
+ }
@@ -0,0 +1,108 @@
1
+ import os from "node:os";
2
+
3
+ import {
4
+ GitInspectionError,
5
+ projectGitPath,
6
+ runReadOnlyGit,
7
+ type ReadOnlyGitOptions
8
+ } from "./git-status-adapter.ts";
9
+
10
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
11
+
12
+ export type GitTreeEntry = {
13
+ mode: string;
14
+ type: "blob" | "commit";
15
+ objectId: string;
16
+ path: string;
17
+ };
18
+
19
+ export type GitTreeChange = {
20
+ status: "A" | "C" | "D" | "M" | "R";
21
+ path: string;
22
+ oldPath: string | null;
23
+ };
24
+
25
+ export async function readGitTreeEntry(
26
+ repoRoot: string,
27
+ treeObjectId: string,
28
+ repoPath: string,
29
+ options: ReadOnlyGitOptions = {}
30
+ ): Promise<GitTreeEntry | null> {
31
+ if (!/^[a-f0-9]{40,64}$/.test(treeObjectId)) throw new GitInspectionError("invalid-output", "Invalid Git tree object ID");
32
+ const output = await runReadOnlyGit(repoRoot, ["ls-tree", "-z", treeObjectId, "--", repoPath], options);
33
+ if (output.length === 0) return null;
34
+ if (output[output.length - 1] !== 0 || output.indexOf(0) !== output.length - 1) {
35
+ throw new GitInspectionError("invalid-output", "Git returned an ambiguous tree entry");
36
+ }
37
+ const record = output.subarray(0, -1);
38
+ const tab = record.indexOf(0x09);
39
+ if (tab < 0) throw new GitInspectionError("invalid-output", "Git returned a malformed tree entry");
40
+ const header = record.subarray(0, tab).toString("ascii").split(" ");
41
+ let decodedPath: string;
42
+ try { decodedPath = utf8.decode(record.subarray(tab + 1)); }
43
+ catch (cause) { throw new GitInspectionError("invalid-output", "Git tree path is not valid UTF-8", { cause }); }
44
+ const projected = projectGitPath(decodedPath);
45
+ if (header.length !== 3 || !/^[0-7]{6}$/.test(header[0] ?? "") || !/^(blob|commit)$/.test(header[1] ?? "")
46
+ || !/^[a-f0-9]{40,64}$/.test(header[2] ?? "") || projected.value !== repoPath) {
47
+ throw new GitInspectionError("invalid-output", "Git returned an invalid tree entry");
48
+ }
49
+ return { mode: header[0], type: header[1] as "blob" | "commit", objectId: header[2], path: repoPath };
50
+ }
51
+
52
+ export async function listGitPathsAgainstTree(
53
+ repoRoot: string,
54
+ treeObjectId: string,
55
+ options: ReadOnlyGitOptions = {}
56
+ ): Promise<string[]> {
57
+ if (!/^[a-f0-9]{40,64}$/.test(treeObjectId)) throw new GitInspectionError("invalid-output", "Invalid Git tree object ID");
58
+ const output = await runReadOnlyGit(repoRoot, [
59
+ "diff", "--name-only", "-z", "--no-ext-diff", "--no-textconv", "--no-renames", treeObjectId, "--"
60
+ ], options);
61
+ const result: string[] = [];
62
+ let decoded: string;
63
+ try { decoded = utf8.decode(output); }
64
+ catch (cause) { throw new GitInspectionError("invalid-output", "Task delta paths are not valid UTF-8", { cause }); }
65
+ for (const raw of decoded.split("\0")) {
66
+ if (!raw) continue;
67
+ const projected = projectGitPath(raw);
68
+ if (!projected.value) throw new GitInspectionError("invalid-output", "Task delta contains a path that cannot be represented safely");
69
+ result.push(projected.value);
70
+ }
71
+ return result;
72
+ }
73
+
74
+ export async function listGitChangesAgainstTree(
75
+ repoRoot: string,
76
+ treeObjectId: string,
77
+ options: ReadOnlyGitOptions = {}
78
+ ): Promise<GitTreeChange[]> {
79
+ if (!/^[a-f0-9]{40,64}$/.test(treeObjectId)) throw new GitInspectionError("invalid-output", "Invalid Git tree object ID");
80
+ const output = await runReadOnlyGit(repoRoot, [
81
+ "diff", "--name-status", "-z", "--no-ext-diff", "--no-textconv", "--find-renames", "--diff-filter=ACMRD", treeObjectId, "--"
82
+ ], options);
83
+ if (output.length > 0 && output[output.length - 1] !== 0) throw new GitInspectionError("invalid-output", "Task delta status is not NUL terminated");
84
+ let decoded: string;
85
+ try { decoded = utf8.decode(output); }
86
+ catch (cause) { throw new GitInspectionError("invalid-output", "Task delta status is not valid UTF-8", { cause }); }
87
+ const fields = decoded.split("\0").filter(Boolean), result: GitTreeChange[] = [];
88
+ for (let index = 0; index < fields.length;) {
89
+ const rawStatus = fields[index++], source = fields[index++];
90
+ if (!rawStatus || !source || !/^(?:[ADM]|[RC][0-9]{1,3})$/.test(rawStatus)) {
91
+ throw new GitInspectionError("invalid-output", "Task delta contains an invalid status record");
92
+ }
93
+ const status = rawStatus[0] as GitTreeChange["status"];
94
+ const destination = status === "R" || status === "C" ? fields[index++] : null;
95
+ if ((status === "R" || status === "C") && !destination) throw new GitInspectionError("invalid-output", "Task delta rename is incomplete");
96
+ const path = projectGitPath(destination ?? source), oldPath = destination ? projectGitPath(source) : null;
97
+ if (!path.value || (oldPath && !oldPath.value)) throw new GitInspectionError("invalid-output", "Task delta contains an unsafe path");
98
+ result.push({ status, path: path.value, oldPath: oldPath?.value ?? null });
99
+ }
100
+ return result;
101
+ }
102
+
103
+ export async function emptyGitTreeObjectId(repoRoot: string, options: ReadOnlyGitOptions = {}): Promise<string> {
104
+ const output = await runReadOnlyGit(repoRoot, ["hash-object", "-t", "tree", os.devNull], { ...options, maxBytes: 1024 });
105
+ const objectId = output.toString("ascii").trim();
106
+ if (!/^[a-f0-9]{40,64}$/.test(objectId)) throw new GitInspectionError("invalid-output", "Git returned an invalid empty-tree object ID");
107
+ return objectId;
108
+ }
@@ -0,0 +1,10 @@
1
+ import { toggle } from "../../mcp/mcp-command-actions.js";
2
+
3
+ export function setRuntimeMcpEnabled(options: {
4
+ projectPath: string;
5
+ name: string;
6
+ scope: string;
7
+ enabled: boolean;
8
+ }): void {
9
+ toggle(options);
10
+ }