spexcode 0.6.1 → 0.6.5

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 (480) hide show
  1. package/README.md +99 -149
  2. package/bin/spex.mjs +4 -0
  3. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +76 -0
  4. package/node_modules/@spexcode/spec-cli/dist/attach.d.ts +2 -0
  5. package/node_modules/@spexcode/spec-cli/dist/attach.js +48 -0
  6. package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +33 -0
  7. package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +307 -0
  8. package/node_modules/@spexcode/spec-cli/dist/cli.d.ts +1 -0
  9. package/node_modules/@spexcode/spec-cli/dist/cli.js +1859 -0
  10. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +111 -0
  11. package/node_modules/@spexcode/spec-cli/dist/client.js +476 -0
  12. package/node_modules/@spexcode/spec-cli/dist/cockpit.d.ts +7 -0
  13. package/node_modules/@spexcode/spec-cli/dist/cockpit.js +26 -0
  14. package/node_modules/@spexcode/spec-cli/dist/codex-headless.d.ts +1 -0
  15. package/{spec-cli/src/codex-headless.ts → node_modules/@spexcode/spec-cli/dist/codex-headless.js} +3 -9
  16. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +53 -0
  17. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +686 -0
  18. package/node_modules/@spexcode/spec-cli/dist/commit-surgery.d.ts +1 -0
  19. package/node_modules/@spexcode/spec-cli/dist/commit-surgery.js +90 -0
  20. package/node_modules/@spexcode/spec-cli/dist/contract-filter.d.ts +14 -0
  21. package/node_modules/@spexcode/spec-cli/dist/contract-filter.js +219 -0
  22. package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.d.ts +6 -0
  23. package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.js +50 -0
  24. package/node_modules/@spexcode/spec-cli/dist/delivery-queue.d.ts +23 -0
  25. package/node_modules/@spexcode/spec-cli/dist/delivery-queue.js +179 -0
  26. package/node_modules/@spexcode/spec-cli/dist/doctor.d.ts +9 -0
  27. package/node_modules/@spexcode/spec-cli/dist/doctor.js +547 -0
  28. package/node_modules/@spexcode/spec-cli/dist/eval-host.d.ts +1 -0
  29. package/node_modules/@spexcode/spec-cli/dist/eval-host.js +19 -0
  30. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +26 -0
  31. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +475 -0
  32. package/node_modules/@spexcode/spec-cli/dist/file-write.d.ts +2 -0
  33. package/node_modules/@spexcode/spec-cli/dist/file-write.js +26 -0
  34. package/node_modules/@spexcode/spec-cli/dist/flat.d.ts +89 -0
  35. package/node_modules/@spexcode/spec-cli/dist/flat.js +1076 -0
  36. package/node_modules/@spexcode/spec-cli/dist/gateway-auth.d.ts +56 -0
  37. package/node_modules/@spexcode/spec-cli/dist/gateway-auth.js +168 -0
  38. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.d.ts +28 -0
  39. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +361 -0
  40. package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +37 -0
  41. package/node_modules/@spexcode/spec-cli/dist/gateway.js +503 -0
  42. package/node_modules/@spexcode/spec-cli/dist/graphCache.d.ts +27 -0
  43. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +716 -0
  44. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.d.ts +14 -0
  45. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +35 -0
  46. package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +85 -0
  47. package/node_modules/@spexcode/spec-cli/dist/graphStream.js +1195 -0
  48. package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.d.ts +64 -0
  49. package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.js +166 -0
  50. package/node_modules/@spexcode/spec-cli/dist/guide.d.ts +8 -0
  51. package/{spec-cli/src/guide.ts → node_modules/@spexcode/spec-cli/dist/guide.js} +115 -22
  52. package/node_modules/@spexcode/spec-cli/dist/harness-select.d.ts +18 -0
  53. package/node_modules/@spexcode/spec-cli/dist/harness-select.js +62 -0
  54. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +325 -0
  55. package/node_modules/@spexcode/spec-cli/dist/harness.js +3026 -0
  56. package/node_modules/@spexcode/spec-cli/dist/headless-controller.d.ts +9 -0
  57. package/node_modules/@spexcode/spec-cli/dist/headless-controller.js +47 -0
  58. package/node_modules/@spexcode/spec-cli/dist/help.d.ts +10 -0
  59. package/node_modules/@spexcode/spec-cli/dist/help.js +574 -0
  60. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.d.ts +18 -0
  61. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +122 -0
  62. package/node_modules/@spexcode/spec-cli/dist/hooks.d.ts +1 -0
  63. package/{spec-cli/src/hooks.ts → node_modules/@spexcode/spec-cli/dist/hooks.js} +15 -15
  64. package/node_modules/@spexcode/spec-cli/dist/host-resources.d.ts +97 -0
  65. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +795 -0
  66. package/node_modules/@spexcode/spec-cli/dist/host.d.ts +88 -0
  67. package/node_modules/@spexcode/spec-cli/dist/host.js +663 -0
  68. package/node_modules/@spexcode/spec-cli/dist/index.d.ts +1 -0
  69. package/node_modules/@spexcode/spec-cli/dist/index.js +899 -0
  70. package/node_modules/@spexcode/spec-cli/dist/init.d.ts +1 -0
  71. package/node_modules/@spexcode/spec-cli/dist/init.js +284 -0
  72. package/node_modules/@spexcode/spec-cli/dist/issues-cli.d.ts +6 -0
  73. package/node_modules/@spexcode/spec-cli/dist/issues-cli.js +334 -0
  74. package/node_modules/@spexcode/spec-cli/dist/issues.d.ts +69 -0
  75. package/node_modules/@spexcode/spec-cli/dist/issues.js +189 -0
  76. package/node_modules/@spexcode/spec-cli/dist/lint.d.ts +34 -0
  77. package/node_modules/@spexcode/spec-cli/dist/lint.js +476 -0
  78. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +7 -0
  79. package/{spec-cli/src/listen.ts → node_modules/@spexcode/spec-cli/dist/listen.js} +13 -17
  80. package/node_modules/@spexcode/spec-cli/dist/localIssues.d.ts +65 -0
  81. package/node_modules/@spexcode/spec-cli/dist/localIssues.js +582 -0
  82. package/node_modules/@spexcode/spec-cli/dist/login-page.d.ts +5 -0
  83. package/{spec-cli/src/login-page.ts → node_modules/@spexcode/spec-cli/dist/login-page.js} +7 -7
  84. package/node_modules/@spexcode/spec-cli/dist/loop-in.d.ts +23 -0
  85. package/{spec-cli/src/loop-in.ts → node_modules/@spexcode/spec-cli/dist/loop-in.js} +35 -45
  86. package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +69 -0
  87. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +603 -0
  88. package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +18 -0
  89. package/node_modules/@spexcode/spec-cli/dist/materialize.js +468 -0
  90. package/node_modules/@spexcode/spec-cli/dist/mentions.d.ts +50 -0
  91. package/node_modules/@spexcode/spec-cli/dist/mentions.js +117 -0
  92. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +4 -0
  93. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +208 -0
  94. package/node_modules/@spexcode/spec-cli/dist/opencode.d.ts +3 -0
  95. package/{spec-cli/src/opencode.ts → node_modules/@spexcode/spec-cli/dist/opencode.js} +9 -13
  96. package/node_modules/@spexcode/spec-cli/dist/pi-harness.d.ts +3 -0
  97. package/{spec-cli/src/pi-harness.ts → node_modules/@spexcode/spec-cli/dist/pi-harness.js} +45 -40
  98. package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +25 -0
  99. package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +196 -0
  100. package/node_modules/@spexcode/spec-cli/dist/plugin-harness.d.ts +21 -0
  101. package/node_modules/@spexcode/spec-cli/dist/plugin-harness.js +146 -0
  102. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.d.ts +10 -0
  103. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +404 -0
  104. package/node_modules/@spexcode/spec-cli/dist/pty-helper.d.mts +1 -0
  105. package/node_modules/@spexcode/spec-cli/dist/pty-helper.mjs +101 -0
  106. package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.d.mts +2 -0
  107. package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.mjs +23 -0
  108. package/node_modules/@spexcode/spec-cli/dist/public-graph.d.ts +46 -0
  109. package/node_modules/@spexcode/spec-cli/dist/public-graph.js +56 -0
  110. package/node_modules/@spexcode/spec-cli/dist/ranker.d.ts +15 -0
  111. package/node_modules/@spexcode/spec-cli/dist/ranker.js +188 -0
  112. package/node_modules/@spexcode/spec-cli/dist/reaper.d.ts +6 -0
  113. package/node_modules/@spexcode/spec-cli/dist/reaper.js +81 -0
  114. package/node_modules/@spexcode/spec-cli/dist/reviews.d.ts +189 -0
  115. package/node_modules/@spexcode/spec-cli/dist/reviews.js +350 -0
  116. package/node_modules/@spexcode/spec-cli/dist/runtime-guard.d.ts +6 -0
  117. package/node_modules/@spexcode/spec-cli/dist/runtime-guard.js +37 -0
  118. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +21 -0
  119. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +84 -0
  120. package/node_modules/@spexcode/spec-cli/dist/search.bench.d.mts +1 -0
  121. package/node_modules/@spexcode/spec-cli/dist/search.bench.mjs +75 -0
  122. package/node_modules/@spexcode/spec-cli/dist/search.d.ts +20 -0
  123. package/node_modules/@spexcode/spec-cli/dist/search.js +61 -0
  124. package/node_modules/@spexcode/spec-cli/dist/session-cursors.d.ts +14 -0
  125. package/node_modules/@spexcode/spec-cli/dist/session-cursors.js +82 -0
  126. package/node_modules/@spexcode/spec-cli/dist/session-declarations.d.ts +13 -0
  127. package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +126 -0
  128. package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +10 -0
  129. package/node_modules/@spexcode/spec-cli/dist/session-execution.js +70 -0
  130. package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +28 -0
  131. package/node_modules/@spexcode/spec-cli/dist/session-files.js +130 -0
  132. package/node_modules/@spexcode/spec-cli/dist/session-follow.d.ts +31 -0
  133. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +179 -0
  134. package/node_modules/@spexcode/spec-cli/dist/session-reparent.d.ts +10 -0
  135. package/node_modules/@spexcode/spec-cli/dist/session-reparent.js +33 -0
  136. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +51 -0
  137. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +233 -0
  138. package/node_modules/@spexcode/spec-cli/dist/session-web.d.ts +24 -0
  139. package/node_modules/@spexcode/spec-cli/dist/session-web.js +141 -0
  140. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +412 -0
  141. package/node_modules/@spexcode/spec-cli/dist/sessions.js +4239 -0
  142. package/node_modules/@spexcode/spec-cli/dist/sh.d.ts +1 -0
  143. package/node_modules/@spexcode/spec-cli/dist/sh.js +3 -0
  144. package/node_modules/@spexcode/spec-cli/dist/shim-runtime.d.ts +1 -0
  145. package/{spec-cli/src/shim-runtime.ts → node_modules/@spexcode/spec-cli/dist/shim-runtime.js} +3 -4
  146. package/node_modules/@spexcode/spec-cli/dist/slash-commands.d.ts +9 -0
  147. package/node_modules/@spexcode/spec-cli/dist/slash-commands.js +307 -0
  148. package/node_modules/@spexcode/spec-cli/dist/source-files.d.ts +9 -0
  149. package/node_modules/@spexcode/spec-cli/dist/source-files.js +113 -0
  150. package/node_modules/@spexcode/spec-cli/dist/supervise.d.ts +1 -0
  151. package/node_modules/@spexcode/spec-cli/dist/supervise.js +308 -0
  152. package/node_modules/@spexcode/spec-cli/dist/tree.d.ts +25 -0
  153. package/node_modules/@spexcode/spec-cli/dist/tree.js +98 -0
  154. package/node_modules/@spexcode/spec-cli/dist/tsx-bin.d.ts +3 -0
  155. package/node_modules/@spexcode/spec-cli/dist/tsx-bin.js +41 -0
  156. package/node_modules/@spexcode/spec-cli/dist/uninstall.d.ts +3 -0
  157. package/node_modules/@spexcode/spec-cli/dist/uninstall.js +177 -0
  158. package/node_modules/@spexcode/spec-cli/dist/uploads.d.ts +36 -0
  159. package/node_modules/@spexcode/spec-cli/dist/uploads.js +243 -0
  160. package/node_modules/@spexcode/spec-cli/dist/worktree-sources.d.ts +1 -0
  161. package/node_modules/@spexcode/spec-cli/dist/worktree-sources.js +47 -0
  162. package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/dispatch.sh +5 -5
  163. package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/harness.sh +27 -7
  164. package/node_modules/@spexcode/spec-cli/package.json +47 -0
  165. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  166. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
  167. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec-first.sh +1 -1
  168. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +1 -0
  169. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +22 -0
  170. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/spec.md +2 -2
  171. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +31 -29
  172. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
  173. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
  174. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
  175. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
  176. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +94 -0
  177. package/node_modules/@spexcode/spec-core/dist/anchors.js +730 -0
  178. package/node_modules/@spexcode/spec-core/dist/git.d.ts +166 -0
  179. package/node_modules/@spexcode/spec-core/dist/git.js +2736 -0
  180. package/node_modules/@spexcode/spec-core/dist/graph-delta.d.ts +44 -0
  181. package/node_modules/@spexcode/spec-core/dist/graph-delta.js +72 -0
  182. package/node_modules/@spexcode/spec-core/dist/graph.d.ts +34 -0
  183. package/node_modules/@spexcode/spec-core/dist/graph.js +237 -0
  184. package/node_modules/@spexcode/spec-core/dist/graphDelta.d.ts +3 -0
  185. package/node_modules/@spexcode/spec-core/dist/graphDelta.js +13 -0
  186. package/node_modules/@spexcode/spec-core/dist/harness-identity.d.ts +31 -0
  187. package/node_modules/@spexcode/spec-core/dist/harness-identity.js +20 -0
  188. package/node_modules/@spexcode/spec-core/dist/identity-presets.d.ts +152 -0
  189. package/node_modules/@spexcode/spec-core/dist/identity-presets.js +132 -0
  190. package/node_modules/@spexcode/spec-core/dist/index.d.ts +45 -0
  191. package/node_modules/@spexcode/spec-core/dist/index.js +19 -0
  192. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +183 -0
  193. package/node_modules/@spexcode/spec-core/dist/layout.js +548 -0
  194. package/node_modules/@spexcode/spec-core/dist/process-identity.d.ts +37 -0
  195. package/node_modules/@spexcode/spec-core/dist/process-identity.js +214 -0
  196. package/node_modules/@spexcode/spec-core/dist/project-identity.d.ts +12 -0
  197. package/node_modules/@spexcode/spec-core/dist/project-identity.js +71 -0
  198. package/node_modules/@spexcode/spec-core/dist/project-store.d.ts +3 -0
  199. package/node_modules/@spexcode/spec-core/dist/project-store.js +14 -0
  200. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +2 -0
  201. package/node_modules/@spexcode/spec-core/dist/resilience.js +40 -0
  202. package/node_modules/@spexcode/spec-core/dist/review/index.d.ts +3 -0
  203. package/node_modules/@spexcode/spec-core/dist/review/index.js +5 -0
  204. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +77 -0
  205. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +308 -0
  206. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +66 -0
  207. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +180 -0
  208. package/node_modules/@spexcode/spec-core/dist/review/session.d.ts +4 -0
  209. package/node_modules/@spexcode/spec-core/dist/review/session.js +8 -0
  210. package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.d.ts +15 -0
  211. package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.js +12 -0
  212. package/node_modules/@spexcode/spec-core/dist/root-lru.d.ts +4 -0
  213. package/{spec-cli/src/root-lru.ts → node_modules/@spexcode/spec-core/dist/root-lru.js} +26 -30
  214. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +117 -0
  215. package/node_modules/@spexcode/spec-core/dist/specs.js +489 -0
  216. package/node_modules/@spexcode/spec-core/package.json +28 -0
  217. package/node_modules/@spexcode/spec-eval/dist/cache.d.ts +10 -0
  218. package/node_modules/@spexcode/spec-eval/dist/cache.js +50 -0
  219. package/node_modules/@spexcode/spec-eval/dist/cli.d.ts +22 -0
  220. package/node_modules/@spexcode/spec-eval/dist/cli.js +981 -0
  221. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +98 -0
  222. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +176 -0
  223. package/node_modules/@spexcode/spec-eval/dist/filing.d.ts +16 -0
  224. package/node_modules/@spexcode/spec-eval/dist/filing.js +36 -0
  225. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +48 -0
  226. package/node_modules/@spexcode/spec-eval/dist/freshness.js +799 -0
  227. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +46 -0
  228. package/node_modules/@spexcode/spec-eval/dist/host.js +53 -0
  229. package/node_modules/@spexcode/spec-eval/dist/humanok.d.ts +11 -0
  230. package/node_modules/@spexcode/spec-eval/dist/humanok.js +27 -0
  231. package/node_modules/@spexcode/spec-eval/dist/index.d.ts +4 -0
  232. package/node_modules/@spexcode/spec-eval/dist/index.js +4 -0
  233. package/node_modules/@spexcode/spec-eval/dist/remarks.d.ts +31 -0
  234. package/node_modules/@spexcode/spec-eval/dist/remarks.js +1 -0
  235. package/node_modules/@spexcode/spec-eval/dist/scenariofresh.d.ts +13 -0
  236. package/node_modules/@spexcode/spec-eval/dist/scenariofresh.js +303 -0
  237. package/node_modules/@spexcode/spec-eval/dist/scenarios.d.ts +99 -0
  238. package/node_modules/@spexcode/spec-eval/dist/scenarios.js +650 -0
  239. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +307 -0
  240. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +1883 -0
  241. package/node_modules/@spexcode/spec-eval/dist/sidecar.d.ts +55 -0
  242. package/node_modules/@spexcode/spec-eval/dist/sidecar.js +82 -0
  243. package/node_modules/@spexcode/spec-eval/dist/timeline.d.ts +25 -0
  244. package/node_modules/@spexcode/spec-eval/dist/timeline.js +65 -0
  245. package/node_modules/@spexcode/spec-eval/dist/ui-path.d.ts +1 -0
  246. package/node_modules/@spexcode/spec-eval/dist/ui-path.js +2 -0
  247. package/node_modules/@spexcode/spec-eval/package.json +36 -0
  248. package/node_modules/@spexcode/spec-forge/dist/cache.d.ts +29 -0
  249. package/node_modules/@spexcode/spec-forge/dist/cache.js +59 -0
  250. package/node_modules/@spexcode/spec-forge/dist/cli.d.ts +1 -0
  251. package/node_modules/@spexcode/spec-forge/dist/cli.js +100 -0
  252. package/node_modules/@spexcode/spec-forge/dist/drivers/github.d.ts +2 -0
  253. package/node_modules/@spexcode/spec-forge/dist/drivers/github.js +116 -0
  254. package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.d.ts +7 -0
  255. package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.js +144 -0
  256. package/node_modules/@spexcode/spec-forge/dist/drivers.d.ts +10 -0
  257. package/node_modules/@spexcode/spec-forge/dist/drivers.js +83 -0
  258. package/node_modules/@spexcode/spec-forge/dist/index.d.ts +4 -0
  259. package/node_modules/@spexcode/spec-forge/dist/index.js +4 -0
  260. package/node_modules/@spexcode/spec-forge/dist/links.d.ts +15 -0
  261. package/node_modules/@spexcode/spec-forge/dist/links.js +65 -0
  262. package/node_modules/@spexcode/spec-forge/dist/needs-eval.d.ts +10 -0
  263. package/node_modules/@spexcode/spec-forge/dist/needs-eval.js +20 -0
  264. package/node_modules/@spexcode/spec-forge/dist/port.d.ts +53 -0
  265. package/node_modules/@spexcode/spec-forge/dist/port.js +1 -0
  266. package/node_modules/@spexcode/spec-forge/dist/resident.d.ts +7 -0
  267. package/node_modules/@spexcode/spec-forge/dist/resident.js +43 -0
  268. package/node_modules/@spexcode/spec-forge/package.json +32 -0
  269. package/package.json +33 -19
  270. package/spec-cli/bin/spex.mjs +0 -69
  271. package/spec-cli/src/anchors.ts +0 -706
  272. package/spec-cli/src/attach.ts +0 -43
  273. package/spec-cli/src/claude-headless.ts +0 -321
  274. package/spec-cli/src/cli.ts +0 -1115
  275. package/spec-cli/src/client.ts +0 -315
  276. package/spec-cli/src/cockpit.ts +0 -43
  277. package/spec-cli/src/codex-runtime-generations.ts +0 -646
  278. package/spec-cli/src/commit-surgery.ts +0 -68
  279. package/spec-cli/src/contract-filter.ts +0 -202
  280. package/spec-cli/src/delivery-queue.ts +0 -107
  281. package/spec-cli/src/doctor.ts +0 -516
  282. package/spec-cli/src/file-write.ts +0 -22
  283. package/spec-cli/src/gateway-auth.ts +0 -181
  284. package/spec-cli/src/gateway-hub.ts +0 -356
  285. package/spec-cli/src/gateway.ts +0 -368
  286. package/spec-cli/src/git.ts +0 -2180
  287. package/spec-cli/src/graph.ts +0 -221
  288. package/spec-cli/src/graphCache.ts +0 -641
  289. package/spec-cli/src/graphDelta.ts +0 -77
  290. package/spec-cli/src/graphStream.ts +0 -1019
  291. package/spec-cli/src/harness-select.ts +0 -77
  292. package/spec-cli/src/harness.ts +0 -2747
  293. package/spec-cli/src/headless-controller.ts +0 -63
  294. package/spec-cli/src/help.ts +0 -480
  295. package/spec-cli/src/host-resources.ts +0 -707
  296. package/spec-cli/src/host.ts +0 -611
  297. package/spec-cli/src/identity-presets.d.ts +0 -13
  298. package/spec-cli/src/identity-presets.js +0 -128
  299. package/spec-cli/src/index.ts +0 -730
  300. package/spec-cli/src/init.ts +0 -258
  301. package/spec-cli/src/issues-cli.ts +0 -276
  302. package/spec-cli/src/issues.ts +0 -237
  303. package/spec-cli/src/layout.ts +0 -557
  304. package/spec-cli/src/lint.ts +0 -458
  305. package/spec-cli/src/localIssues.ts +0 -538
  306. package/spec-cli/src/materialize.ts +0 -412
  307. package/spec-cli/src/mentions.ts +0 -81
  308. package/spec-cli/src/opencode-headless.ts +0 -205
  309. package/spec-cli/src/pi-headless.ts +0 -153
  310. package/spec-cli/src/plugin-harness.ts +0 -157
  311. package/spec-cli/src/process-identity.ts +0 -207
  312. package/spec-cli/src/project-identity.ts +0 -73
  313. package/spec-cli/src/project-store.ts +0 -17
  314. package/spec-cli/src/pty-bridge.ts +0 -396
  315. package/spec-cli/src/pty-helper.mjs +0 -96
  316. package/spec-cli/src/pty-native-helper.mjs +0 -22
  317. package/spec-cli/src/ranker.ts +0 -191
  318. package/spec-cli/src/reaper.ts +0 -125
  319. package/spec-cli/src/resilience.ts +0 -41
  320. package/spec-cli/src/reviewSnapshot.ts +0 -28
  321. package/spec-cli/src/reviews.ts +0 -405
  322. package/spec-cli/src/runtime-guard.ts +0 -35
  323. package/spec-cli/src/runtime-ownership.ts +0 -88
  324. package/spec-cli/src/search.bench.mjs +0 -80
  325. package/spec-cli/src/search.ts +0 -63
  326. package/spec-cli/src/session-cursors.ts +0 -91
  327. package/spec-cli/src/session-follow.ts +0 -182
  328. package/spec-cli/src/session-timeline.ts +0 -98
  329. package/spec-cli/src/sessions.ts +0 -3403
  330. package/spec-cli/src/sh.ts +0 -3
  331. package/spec-cli/src/slash-commands.ts +0 -318
  332. package/spec-cli/src/source-files.ts +0 -104
  333. package/spec-cli/src/specs.ts +0 -498
  334. package/spec-cli/src/supervise.ts +0 -245
  335. package/spec-cli/src/tree.ts +0 -118
  336. package/spec-cli/src/tsx-bin.ts +0 -18
  337. package/spec-cli/src/uninstall.ts +0 -171
  338. package/spec-cli/src/uploads.ts +0 -253
  339. package/spec-cli/src/worktree-sources.ts +0 -42
  340. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +0 -8
  341. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +0 -28
  342. package/spec-cli/templates/spec/project/.plugins/core/spec.md +0 -23
  343. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +0 -32
  344. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +0 -9
  345. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +0 -15
  346. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +0 -18
  347. package/spec-dashboard/dist/assets/App-F9uaAVcH.js +0 -2
  348. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  349. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +0 -1
  350. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  351. package/spec-dashboard/dist/assets/IssuesPage-CuKLFhH3.js +0 -1
  352. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  353. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  354. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  355. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  356. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  357. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  358. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  359. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  360. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  361. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  362. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  363. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  364. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  365. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  366. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  367. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  368. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  369. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  370. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  371. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  372. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  373. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  374. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  375. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  376. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  377. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  378. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  379. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  380. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  381. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  382. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  383. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  384. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  385. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  386. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  387. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  388. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  389. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  390. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  391. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  392. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  393. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  394. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  395. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  396. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  397. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  398. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  399. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  400. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  401. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  402. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  403. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  404. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  405. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  406. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  407. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  408. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  409. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  410. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  411. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  412. package/spec-dashboard/dist/assets/Modal-CQgYymmr.js +0 -1
  413. package/spec-dashboard/dist/assets/PageScroll-hT7UTLvD.js +0 -1
  414. package/spec-dashboard/dist/assets/ProjectsPage-CtXxakF9.js +0 -1
  415. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +0 -32
  416. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  417. package/spec-dashboard/dist/assets/SessionWindow-CixDi4PI.js +0 -1
  418. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  419. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  420. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +0 -1
  421. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  422. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  423. package/spec-dashboard/dist/assets/index-DrVao0Ep.js +0 -41
  424. package/spec-dashboard/dist/assets/launch-BBH02b1v.js +0 -276
  425. package/spec-dashboard/dist/assets/launch-Cp579UoJ.css +0 -1
  426. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  427. package/spec-dashboard/dist/index.html +0 -29
  428. package/spec-dashboard/src/reviewFilters.js +0 -322
  429. package/spec-dashboard/src/reviewQuery.js +0 -174
  430. package/spec-dashboard/src/session.js +0 -199
  431. package/spec-eval/src/cache.ts +0 -57
  432. package/spec-eval/src/cli.ts +0 -889
  433. package/spec-eval/src/evaltab.ts +0 -262
  434. package/spec-eval/src/filing.ts +0 -39
  435. package/spec-eval/src/freshness.ts +0 -557
  436. package/spec-eval/src/humanok.ts +0 -28
  437. package/spec-eval/src/scenariofresh.ts +0 -244
  438. package/spec-eval/src/scenarios.ts +0 -672
  439. package/spec-eval/src/sessioneval.ts +0 -2226
  440. package/spec-eval/src/sidecar.ts +0 -93
  441. package/spec-eval/src/timeline.ts +0 -54
  442. package/spec-forge/src/__fixtures__/github-forge.json +0 -63
  443. package/spec-forge/src/cache.ts +0 -66
  444. package/spec-forge/src/cli.ts +0 -99
  445. package/spec-forge/src/drivers/github.ts +0 -132
  446. package/spec-forge/src/drivers/gitlab.ts +0 -153
  447. package/spec-forge/src/drivers.ts +0 -73
  448. package/spec-forge/src/links.ts +0 -72
  449. package/spec-forge/src/needs-eval.ts +0 -29
  450. package/spec-forge/src/port.ts +0 -36
  451. package/spec-forge/src/resident.ts +0 -46
  452. /package/{spec-cli → node_modules/@spexcode/spec-cli}/README.md +0 -0
  453. /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -0
  454. /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-sed-v0.fixture +0 -0
  455. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/commit-msg +0 -0
  456. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-checkout +0 -0
  457. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-merge +0 -0
  458. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/pre-commit +0 -0
  459. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/prepare-commit-msg +0 -0
  460. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/reference-transaction +0 -0
  461. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/extract/spec.md +0 -0
  462. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/regroup/spec.md +0 -0
  463. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/rename/spec.md +0 -0
  464. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/spec.md +0 -0
  465. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/tidy/spec.md +0 -0
  466. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/idle.sh +0 -0
  467. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/spec.md +0 -0
  468. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/mark-active.sh +0 -0
  469. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/spec.md +0 -0
  470. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/fail.sh +0 -0
  471. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/spec.md +0 -0
  472. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec.md +0 -0
  473. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec.md +0 -0
  474. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/spec.md +0 -0
  475. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/digest.mjs +0 -0
  476. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/spec.md +0 -0
  477. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/spec.md +0 -0
  478. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/spec.md +0 -0
  479. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/spec.md +0 -0
  480. /package/{spec-cli → node_modules/@spexcode/spec-core}/templates/spexcode.json +0 -0
@@ -1,2180 +0,0 @@
1
- import { execFileSync, execFile, spawn } from 'node:child_process'
2
- import { AsyncLocalStorage } from 'node:async_hooks'
3
- import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync, accessSync, constants } from 'node:fs'
4
- import { join, isAbsolute, resolve, delimiter } from 'node:path'
5
- import { createHash, randomBytes } from 'node:crypto'
6
- import { projectRuntimeRoot } from './project-store.js'
7
- import { rootSlots, touchRoot as touchRootLru } from './root-lru.js'
8
-
9
- const US = '\x1f', RS = '\x1e'
10
-
11
- // @@@ bounded graph git children - a git child that never exits (wedged fs, a hijacked PATH git, a dead
12
- // network mount) must not pin its awaiter forever: [[graph-cache]]'s settle guarantee starts at this seam.
13
- // Every shared helper passes a generous timeout with SIGKILL. A graph build additionally carries one fixed
14
- // permit pool through AsyncLocalStorage, so corpus-wide Promise.all fanout queues here before spawn rather
15
- // than materializing one process per worktree/eval. Calls outside that build context remain unconstrained.
16
- const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000)
17
- export const BOARD_GIT_CONCURRENCY = 4
18
- const gitByPath = new Map<string, string>()
19
-
20
- export function gitBinary(env: NodeJS.ProcessEnv = process.env): string {
21
- const path = env.PATH || ''
22
- const known = gitByPath.get(path)
23
- if (known) {
24
- try { accessSync(known, constants.X_OK); return known } catch {}
25
- }
26
- for (const dir of path.split(delimiter)) {
27
- const candidate = resolve(dir || '.', 'git')
28
- try {
29
- accessSync(candidate, constants.X_OK)
30
- gitByPath.set(path, candidate)
31
- return candidate
32
- } catch {}
33
- }
34
- throw new Error('git executable not found on PATH')
35
- }
36
- type GitPermitPool = { acquire: (signal: AbortSignal) => Promise<() => void> }
37
- type GitBuildContext = { signal: AbortSignal; permits: GitPermitPool }
38
- const gitBuild = new AsyncLocalStorage<GitBuildContext>()
39
-
40
- // @@@ a build's git children run on a bounded pack footprint - git sizes its mmap window, its mmap ceiling
41
- // and its delta-base cache for a process that owns the machine. A graph build's heaviest walks each mapped
42
- // well over a hundred megabytes of pack for output measured in kilobytes, and those children land inside the
43
- // build's own memory platform. Capping the three makes the same walks run in a fraction of the resident set
44
- // for a fraction of a second more. This is a RESOURCE boundary, never a semantic one — output, exit status
45
- // and stderr are byte-identical under every setting — and it is scoped to the build context, so ordinary
46
- // CLI/API git keeps git's defaults. It is also content-blind: the transport knows pack sizing, never which
47
- // walk a caller is doing.
48
- const BUILD_GIT_LIMITS = [
49
- '-c', 'core.packedGitWindowSize=1m',
50
- '-c', 'core.packedGitLimit=32m',
51
- '-c', 'core.deltaBaseCacheLimit=1m',
52
- ]
53
- const withBuildLimits = (args: string[]): string[] => (gitBuild.getStore() ? [...BUILD_GIT_LIMITS, ...args] : args)
54
-
55
- export function gitAbortError(): Error {
56
- return Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' })
57
- }
58
-
59
- function gitPermitPool(limit: number): GitPermitPool {
60
- type Waiter = {
61
- signal: AbortSignal
62
- resolve: (release: () => void) => void
63
- reject: (error: Error) => void
64
- onAbort: () => void
65
- }
66
- let active = 0
67
- const waiting: Waiter[] = []
68
-
69
- const releasePermit = (): (() => void) => {
70
- let released = false
71
- return () => {
72
- if (released) return
73
- released = true
74
- active--
75
- drain()
76
- }
77
- }
78
- const drain = () => {
79
- while (active < limit && waiting.length) {
80
- const waiter = waiting.shift()!
81
- waiter.signal.removeEventListener('abort', waiter.onAbort)
82
- if (waiter.signal.aborted) {
83
- waiter.reject(gitAbortError())
84
- continue
85
- }
86
- active++
87
- waiter.resolve(releasePermit())
88
- }
89
- }
90
-
91
- return {
92
- acquire(signal) {
93
- if (signal.aborted) return Promise.reject(gitAbortError())
94
- if (active < limit) {
95
- active++
96
- return Promise.resolve(releasePermit())
97
- }
98
- return new Promise((resolve, reject) => {
99
- const waiter: Waiter = {
100
- signal,
101
- resolve,
102
- reject,
103
- onAbort: () => {
104
- const index = waiting.indexOf(waiter)
105
- if (index >= 0) waiting.splice(index, 1)
106
- reject(gitAbortError())
107
- },
108
- }
109
- waiting.push(waiter)
110
- signal.addEventListener('abort', waiter.onAbort, { once: true })
111
- })
112
- },
113
- }
114
- }
115
-
116
- // A board build owns one abort signal. Async git calls inherit it without every graph layer growing a
117
- // cancellation parameter; aborting the build therefore reaches every active child and queued permit below
118
- // the graph seam. The pool is created here, so ordinary CLI/API git calls never share or wait on it.
119
- export function withGitAbortSignal<T>(signal: AbortSignal, run: () => Promise<T>): Promise<T> {
120
- return gitBuild.run({ signal, permits: gitPermitPool(BOARD_GIT_CONCURRENCY) }, run)
121
- }
122
-
123
- const inheritedContext = (): GitBuildContext | undefined => gitBuild.getStore()
124
- export function currentGitBuildAbortSignal(): AbortSignal | undefined {
125
- return inheritedContext()?.signal
126
- }
127
- function warnIfTimedOut(e: any, args: string[]): void {
128
- if (e?.signal === 'SIGKILL') console.warn(`spec-cli: git ${args.slice(0, 6).join(' ')}… killed after ${GIT_TIMEOUT_MS}ms — child never exited`)
129
- }
130
-
131
- // strip git's hook-exported env (GIT_DIR etc.) so every call discovers the repo from the filesystem.
132
- export function git(args: string[]): string {
133
- const env = { ...process.env }
134
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
135
- try {
136
- return execFileSync(gitBinary(env), withBuildLimits(args), { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
137
- } catch (e: any) { warnIfTimedOut(e, args); throw e }
138
- }
139
-
140
- function gitBuffer(args: string[], input?: string): Buffer {
141
- const env = { ...process.env }
142
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
143
- try {
144
- return execFileSync(gitBinary(env), withBuildLimits(args), {
145
- input,
146
- env,
147
- stdio: ['pipe', 'pipe', 'pipe'],
148
- timeout: GIT_TIMEOUT_MS,
149
- killSignal: 'SIGKILL',
150
- maxBuffer: 1 << 27,
151
- })
152
- } catch (e: any) { warnIfTimedOut(e, args); throw e }
153
- }
154
-
155
- export type GitObjectFormat = 'sha1' | 'sha256'
156
- const gitObjectFormatMemo = new Map<string, GitObjectFormat>()
157
- export function gitObjectFormat(root: string): GitObjectFormat {
158
- const key = rootKey(root)
159
- const hit = gitObjectFormatMemo.get(key)
160
- if (hit) return hit
161
- const format = git(['-C', root, 'rev-parse', '--show-object-format=storage']).trim()
162
- if (format !== 'sha1' && format !== 'sha256') throw new Error(`unsupported Git object format '${format || 'empty'}' at ${root}`)
163
- gitObjectFormatMemo.set(key, format)
164
- return format
165
- }
166
- export function isGitObjectId(root: string, value: string): boolean {
167
- const length = gitObjectFormat(root) === 'sha256' ? 64 : 40
168
- return value.length === length && /^[0-9a-f]+$/.test(value)
169
- }
170
-
171
- // Batch immutable object lookups used by the shared anchor/index path. Git accepts revision:path queries on
172
- // batch-check, so dozens of rev-parse + cat-file children collapse into two bounded processes without
173
- // changing the returned bytes or object ids.
174
- //
175
- // These are async for two reasons a synchronous execFileSync cannot serve once the caller batches its WHOLE
176
- // invocation rather than one reading at a time. A sync child is invisible to the build's permit pool and its
177
- // abort signal, so a watchdog abort could not kill it; and one build-wide `cat-file --batch` reading tens of
178
- // megabytes would be a single uninterruptible stretch — exactly the /health-blocking shape [[graph-cache]]
179
- // closed for the fs walks.
180
- //
181
- // Chunking is a real output bound, not ceremony: the async transport caps a child's stdout at GIT_MAX_BUFFER
182
- // and overflow is a loud error. Revision rows are 41 bytes each, so only the blob read (payload-sized) needs
183
- // a chunk; the cap is on COUNT because sizes are unknown until git answers, and it is set so an ordinary
184
- // source corpus never approaches the byte ceiling.
185
- const BATCH_BLOB_CHUNK = 256
186
- const BATCH_BLOB_MAX_BUFFER = 1 << 26
187
- async function batchBuffer(args: string[], input: string, maxBuffer?: number): Promise<Buffer> {
188
- const env = { ...process.env }
189
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
190
- try { return (await execGitForCaller(args, env, maxBuffer, input)).stdout }
191
- catch (error: any) {
192
- if (error?.name === 'AbortError') throw error
193
- warnIfTimedOut(error, args)
194
- throw new Error(`git ${args.slice(2, 5).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
195
- }
196
- }
197
- export async function batchRevisionOids(root: string, revisions: string[]): Promise<(string | null)[]> {
198
- if (!revisions.length) return []
199
- const out = (await batchBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n')).toString('utf8')
200
- const lines = out.split('\n')
201
- if (lines.length - 1 !== revisions.length) throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`)
202
- return revisions.map((revision, index) => {
203
- const value = lines[index].trim()
204
- if (isGitObjectId(root, value)) return value
205
- if (value === `${revision} missing`) return null
206
- throw new Error(`git cat-file --batch-check returned '${value}' for ${revision}`)
207
- })
208
- }
209
- export async function batchBlobTexts(root: string, oids: string[]): Promise<Map<string, string>> {
210
- const unique = [...new Set(oids.filter(Boolean))]
211
- const files = new Map<string, string>()
212
- if (!unique.length) return files
213
- for (const oid of unique) if (!isGitObjectId(root, oid)) throw new Error(`invalid object id '${oid}'`)
214
- for (let cursor = 0; cursor < unique.length; cursor += BATCH_BLOB_CHUNK) {
215
- const chunk = unique.slice(cursor, cursor + BATCH_BLOB_CHUNK)
216
- const out = await batchBuffer(['-C', root, 'cat-file', '--batch'], chunk.join('\n') + '\n', BATCH_BLOB_MAX_BUFFER)
217
- let offset = 0
218
- for (const oid of chunk) {
219
- const newline = out.indexOf(10, offset)
220
- if (newline < 0) throw new Error(`git cat-file --batch ended before ${oid}`)
221
- const header = out.subarray(offset, newline).toString('utf8')
222
- const size = Number(header.match(/ blob (\d+)$/)?.[1])
223
- if (!header.startsWith(`${oid} blob `) || !Number.isFinite(size)) throw new Error(`git cat-file --batch returned '${header}' for ${oid}`) // dead-words-ok: Git object protocol type
224
- const start = newline + 1, end = start + size
225
- if (end >= out.length || out[end] !== 0x0a) throw new Error(`git cat-file --batch truncated object ${oid}`)
226
- files.set(oid, out.subarray(start, end).toString('utf8'))
227
- offset = end + 1
228
- }
229
- if (offset !== out.length) throw new Error(`git cat-file --batch returned ${out.length - offset} unexpected trailing bytes`)
230
- }
231
- return files
232
- }
233
-
234
- type TreeBlob = { oid: string; path: string }
235
- function treeBlobs(root: string, tip: string, pathspec = '.'): TreeBlob[] {
236
- const out = git(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', tip, '--', pathspec])
237
- const blobs: TreeBlob[] = []
238
- for (const record of out.split('\0')) {
239
- if (!record) continue
240
- const m = record.match(/^\d+ blob ([0-9a-f]+)\t([\s\S]+)$/)
241
- if (m) blobs.push({ oid: m[1], path: m[2] })
242
- }
243
- return blobs
244
- }
245
-
246
- // Read a tree slice in two Git calls regardless of file count: ls-tree names immutable blob oids, then
247
- // cat-file --batch returns their exact bytes. Pending lint uses this for .spec so commit --only/partial
248
- // staging can never be judged through unrelated working-tree prose.
249
- export function treeTextFiles(root: string, tip: string, pathspec = '.'): Map<string, string> {
250
- const blobs = treeBlobs(root, tip, pathspec)
251
- const files = new Map<string, string>()
252
- if (!blobs.length) return files
253
- const out = gitBuffer(['-C', root, 'cat-file', '--batch'], blobs.map((b) => b.oid).join('\n') + '\n')
254
- let offset = 0
255
- for (const blob of blobs) {
256
- const newline = out.indexOf(10, offset)
257
- if (newline < 0) throw new Error(`git cat-file --batch ended before ${blob.path}`)
258
- const header = out.subarray(offset, newline).toString('utf8')
259
- const size = Number(header.match(/ blob (\d+)$/)?.[1])
260
- if (!Number.isFinite(size)) throw new Error(`git cat-file --batch returned '${header}' for ${blob.path}`)
261
- const start = newline + 1, end = start + size
262
- files.set(blob.path, out.subarray(start, end).toString('utf8'))
263
- offset = end + 1 // one LF follows each payload
264
- }
265
- return files
266
- }
267
-
268
- export function treeFilePaths(root: string, tip: string): Set<string> {
269
- return new Set(treeBlobs(root, tip).map((b) => b.path))
270
- }
271
-
272
- export function treeFileText(root: string, tip: string, path: string): string | null {
273
- try { return gitBuffer(['-C', root, 'cat-file', 'blob', `${tip}:${path}`]).toString('utf8') } // dead-words-ok: git plumbing
274
- catch { return null }
275
- }
276
-
277
- // stdout stays a Buffer to the transport's edge: `cat-file --batch` frames each payload by BYTE length, so
278
- // a decode before framing mis-slices every object after the first multi-byte character. Text callers decode.
279
- type GitExec = { stdout: Buffer; stderr: string }
280
-
281
- // execFile's AbortSignal kills only its direct child. A wedged adapter may have descendants (the
282
- // deterministic tests use a shell + sleep), so async git runs in their own process group and abort/timeout
283
- // kills the whole group. The callback still carries the same stdout/stderr/error shape to gitA/gitTry.
284
- const GIT_MAX_BUFFER = 1 << 24
285
- function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER, input?: string): Promise<GitExec> {
286
- return new Promise((resolve, reject) => {
287
- if (signal?.aborted) { reject(gitAbortError()); return }
288
- const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
289
- const stdout: Buffer[] = [], stderr: Buffer[] = []
290
- let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false
291
- let spawnError: Error | null = null
292
- const killTree = () => {
293
- if (!child.pid) return
294
- try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
295
- try { child.kill('SIGKILL') } catch { /* already exited */ }
296
- }
297
- const onAbort = () => { aborted = true; killTree() }
298
- const append = (chunks: Buffer[], chunk: Buffer, stream: 'stdout' | 'stderr') => {
299
- const total = stream === 'stdout' ? (stdoutBytes += chunk.length) : (stderrBytes += chunk.length)
300
- if (total > maxBuffer) { overflow = true; killTree(); return }
301
- chunks.push(chunk)
302
- }
303
- child.stdout!.on('data', (chunk: Buffer) => append(stdout, chunk, 'stdout'))
304
- child.stderr!.on('data', (chunk: Buffer) => append(stderr, chunk, 'stderr'))
305
- child.once('error', (error) => { spawnError = error })
306
- if (input !== undefined) {
307
- // A command can reject its input before the pipe drains. Keep that write failure on the same
308
- // close/reject path as spawn and exit failures instead of letting Node raise an unhandled EPIPE.
309
- child.stdin!.once('error', (error) => { if (!spawnError) spawnError = error })
310
- child.stdin!.end(input)
311
- }
312
- child.once('close', (code, childSignal) => {
313
- clearTimeout(timer)
314
- signal?.removeEventListener('abort', onAbort)
315
- const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
316
- if (code === 0 && !aborted && !timedOut && !overflow && !spawnError) { resolve(result); return }
317
- const error: any = spawnError ?? new Error(overflow
318
- ? `git output exceeded ${maxBuffer} bytes`
319
- : `git exited with ${code ?? childSignal ?? 'unknown status'}`)
320
- // @@@ a spawn failure keeps its OWN cause - a child that never started still emits `close`, with the
321
- // negated errno as its code (EACCES arrives as -13). Overwriting the spawn error's `'EACCES'`/`'ENOENT'`
322
- // with that number turns "git could not RUN" into "git ran and exited", and every caller that separates
323
- // the two reads the second as a real git answer: the freshness batch concluded "the anchor object is
324
- // unreadable", session create concluded "the branch does not exist". A machine where git is missing or
325
- // unexecutable would have been told, silently, that the thing it asked about is absent.
326
- if (overflow) error.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
327
- else if (!spawnError) error.code = code
328
- error.signal = childSignal
329
- error.stdout = result.stdout.toString('utf8')
330
- error.stderr = result.stderr
331
- if (aborted) error.name = 'AbortError'
332
- if (timedOut) error.spexcodeGitTimeout = true
333
- reject(error)
334
- })
335
- signal?.addEventListener('abort', onAbort, { once: true })
336
- if (signal?.aborted) onAbort()
337
- const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
338
- timer.unref?.()
339
- })
340
- }
341
-
342
- async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffer?: number, input?: string): Promise<GitExec> {
343
- const context = inheritedContext()
344
- if (!context) return execGit(args, env, undefined, maxBuffer, input)
345
- const release = await context.permits.acquire(context.signal)
346
- try {
347
- return await execGit(withBuildLimits(args), env, context.signal, maxBuffer, input)
348
- } finally {
349
- release()
350
- }
351
- }
352
-
353
- // Event streams are the index input itself and may legitimately exceed execFile's fixed maxBuffer. Read
354
- // them through spawn so the only bound is the index the caller is intentionally constructing; timeout,
355
- // cancellation, process-group cleanup and build permits remain identical to the ordinary async transport.
356
- function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<GitExec> {
357
- return new Promise((resolve, reject) => {
358
- if (signal?.aborted) { reject(gitAbortError()); return }
359
- const child = spawn(gitBinary(env), args, { env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
360
- const stdout: Buffer[] = [], stderr: Buffer[] = []
361
- let settled = false, aborted = false, timedOut = false
362
- const killTree = () => {
363
- if (!child.pid) return
364
- try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
365
- try { child.kill('SIGKILL') } catch { /* already exited */ }
366
- }
367
- const onAbort = () => { aborted = true; killTree() }
368
- const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
369
- timer.unref?.()
370
- signal?.addEventListener('abort', onAbort, { once: true })
371
- child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk))
372
- child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk))
373
- child.on('error', (error: any) => {
374
- if (settled) return
375
- settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
376
- if (aborted) error.name = 'AbortError'
377
- if (timedOut) error.spexcodeGitTimeout = true
378
- reject(error)
379
- })
380
- child.on('close', (code, childSignal) => {
381
- if (settled) return
382
- settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
383
- const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
384
- if (code === 0 && !aborted && !timedOut) { resolve(result); return }
385
- const error: any = new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
386
- error.code = code
387
- error.signal = childSignal
388
- error.stdout = result.stdout.toString('utf8')
389
- error.stderr = result.stderr
390
- if (aborted) error.name = 'AbortError'
391
- if (timedOut) error.spexcodeGitTimeout = true
392
- reject(error)
393
- })
394
- })
395
- }
396
- async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv): Promise<GitExec> {
397
- const context = inheritedContext()
398
- if (!context) return execGitStream(args, env)
399
- const release = await context.permits.acquire(context.signal)
400
- try { return await execGitStream(withBuildLimits(args), env, context.signal) }
401
- finally { release() }
402
- }
403
-
404
- export async function gitA(args: string[], input?: string): Promise<string> {
405
- const env = { ...process.env }
406
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
407
- const context = inheritedContext()
408
- try {
409
- const { stdout } = await execGitForCaller(args, env, undefined, input)
410
- return stdout.toString('utf8')
411
- } catch (e: any) {
412
- if (context?.signal.aborted || e?.name === 'AbortError') throw e
413
- warnIfTimedOut(e, args); return ''
414
- }
415
- }
416
-
417
- type TextEventRecord = { hash: string; raw: string }
418
- type IdentityEventRecord = { hash: string; identity: IdentityRawRecord }
419
- type EventRecord = TextEventRecord | IdentityEventRecord
420
- type EventCache = { streams: Map<EventStreamKind, Map<string, EventRecord>>; streamTips: Map<EventStreamKind, string[]> }
421
- type EventStreamOutput = string | IdentityRawRecord[]
422
- const EVENT_CACHE_SCHEMA = 'history-events-v15'
423
- const EVENT_STREAM_KINDS = ['merge', 'identity-raw'] as const
424
- type EventStreamKind = typeof EVENT_STREAM_KINDS[number]
425
- type EventCacheLocation = { path: string; identity: string; objectFormat: GitObjectFormat }
426
- type EventLedgerSnapshot = {
427
- payload: Buffer
428
- state: EventCache
429
- }
430
- type EventStreamRequest = {
431
- kind: EventStreamKind
432
- argsFor: (base: string) => string[]
433
- order: Map<string, number>
434
- reachable: Set<string>
435
- }
436
-
437
- type EventPathMemo = EventCacheLocation & {
438
- common: string; shallowPath: string; grafts: string; shallow: string
439
- replacementStorage: string; replacements: string
440
- }
441
- const eventPathMemo = new Map<string, EventPathMemo>()
442
- function replacementStorageIdentity(common: string): string {
443
- const hash = createHash('sha256')
444
- const addTree = (root: string, rel: string) => {
445
- if (!existsSync(root)) return
446
- const stack = [{ dir: root, rel }]
447
- while (stack.length) {
448
- const current = stack.pop()!
449
- const entries = readdirSync(current.dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
450
- for (const entry of entries) {
451
- const path = join(current.dir, entry.name)
452
- const name = `${current.rel}/${entry.name}`
453
- if (entry.isDirectory()) stack.push({ dir: path, rel: name })
454
- else hash.update(`\0${name}\0`).update(readFileSync(path))
455
- }
456
- }
457
- }
458
- addTree(join(common, 'refs', 'replace'), 'refs/replace')
459
- for (const name of ['packed-refs']) {
460
- const path = join(common, name)
461
- if (existsSync(path)) hash.update(`\0${name}\0`).update(readFileSync(path))
462
- }
463
- // Reftable is opaque here by design: its bytes are only an invalidation signal. Git remains the one
464
- // parser and supplies the canonical refs/replace targets when those bytes change.
465
- addTree(join(common, 'reftable'), 'reftable')
466
- return hash.digest('hex')
467
- }
468
- function eventCacheLocation(root: string): EventCacheLocation {
469
- const rootId = rootKey(root), old = eventPathMemo.get(rootId)
470
- const common = old?.common ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
471
- const shallowPath = old?.shallowPath ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-path', 'shallow']).trim()
472
- const shallow = existsSync(shallowPath) ? readFileSync(shallowPath, 'utf8') : 'unshallow'
473
- const graftsPath = join(common, 'info', 'grafts')
474
- const grafts = existsSync(graftsPath) ? readFileSync(graftsPath, 'utf8') : ''
475
- const replacementStorage = replacementStorageIdentity(common)
476
- const replacements = old?.replacementStorage === replacementStorage
477
- ? old.replacements
478
- : git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
479
- const objectFormat = gitObjectFormat(root)
480
- if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
481
- return { path: old.path, identity: old.identity, objectFormat }
482
- const identity = createHash('sha256')
483
- .update(`${EVENT_CACHE_SCHEMA}\0${objectFormat}\0${shallow}\0${grafts}\0${replacements}`)
484
- .digest('hex').slice(0, 16)
485
- // projectRuntimeRoot derives checkout identity from dirname(common). A bare repository is its own common
486
- // dir, so a synthetic `.git` suffix preserves the repository path instead of collapsing sibling bares.
487
- const gitDir = gitDirOf(root)
488
- const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
489
- const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
490
- eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, objectFormat })
491
- return { path, identity, objectFormat }
492
- }
493
- function emptyEventCache(): EventCache {
494
- return { streams: new Map(), streamTips: new Map() }
495
- }
496
- function exactKeys(value: Record<string, unknown>, expected: string[]): boolean {
497
- const actual = Object.keys(value).sort()
498
- return actual.length === expected.length && actual.every((key, index) => key === expected[index])
499
- }
500
- function isGitObjectIdForFormat(format: GitObjectFormat, value: string): boolean {
501
- return value.length === (format === 'sha256' ? 64 : 40) && /^[0-9a-f]+$/.test(value)
502
- }
503
- function eventPayloadDigest(payload: Buffer): string {
504
- return createHash('sha256').update(payload).digest('hex')
505
- }
506
- function eventIntegrityFooter(payload: Buffer, addition: Buffer): Buffer {
507
- const footer = {
508
- k: 'integrity',
509
- bytes: payload.length + addition.length,
510
- sha256: createHash('sha256').update(payload).update(addition).digest('hex'),
511
- }
512
- return Buffer.from(`${JSON.stringify(footer)}\n`)
513
- }
514
- function eventStreamKind(value: unknown): EventStreamKind | null {
515
- return typeof value === 'string' && (EVENT_STREAM_KINDS as readonly string[]).includes(value)
516
- ? value as EventStreamKind
517
- : null
518
- }
519
- function decodeEventPayload(payload: Buffer, location: EventCacheLocation): EventCache | null {
520
- const state = emptyEventCache()
521
- const text = payload.toString('utf8')
522
- for (let start = 0; start < text.length;) {
523
- const newline = text.indexOf('\n', start)
524
- const end = newline < 0 ? text.length : newline
525
- const line = text.slice(start, end)
526
- start = end + 1
527
- if (!line) continue
528
- let value: unknown
529
- try { value = JSON.parse(line) } catch { return null }
530
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
531
- const row = value as Record<string, unknown>
532
- if (exactKeys(row, ['k', 'tip'])) {
533
- const kind = typeof row.k === 'string' && row.k.startsWith('tip:') ? eventStreamKind(row.k.slice(4)) : null
534
- if (!kind || typeof row.tip !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.tip)) return null
535
- const tips = state.streamTips.get(kind) ?? []
536
- if (tips.includes(row.tip)) return null
537
- tips.push(row.tip)
538
- state.streamTips.set(kind, tips)
539
- continue
540
- }
541
- const kind = eventStreamKind(row.k)
542
- if (!kind || typeof row.h !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.h)) return null
543
- const record: EventRecord | null = kind === 'identity-raw'
544
- ? (() => {
545
- const payload = exactKeys(row, ['a', 'c', 'd', 'h', 'k', 'r', 's'])
546
- ? { a: row.a, c: row.c, d: row.d, r: row.r, s: row.s }
547
- : null
548
- const identity = payload ? decodeIdentityRawRecord(payload, row.h, location) : null
549
- return identity ? { hash: row.h, identity } : null
550
- })()
551
- : (!exactKeys(row, ['h', 'k', 'r']) || typeof row.r !== 'string' || !row.r
552
- ? null
553
- : (() => {
554
- const rawHash = row.r.split(US, 1)[0].split('\n', 1)[0].trim()
555
- return rawHash === row.h ? { hash: row.h, raw: row.r } : null
556
- })())
557
- if (!record) return null
558
- let stream = state.streams.get(kind)
559
- if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
560
- if (stream.has(row.h)) return null
561
- stream.set(row.h, record)
562
- }
563
- return state
564
- }
565
- function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
566
- let file: Buffer
567
- try { file = readFileSync(location.path) }
568
- catch (error: any) {
569
- if (error?.code !== 'ENOENT') throw error
570
- return { payload: Buffer.alloc(0), state: emptyEventCache() }
571
- }
572
- if (!file.length || file[file.length - 1] !== 0x0a)
573
- return { payload: Buffer.alloc(0), state: emptyEventCache() }
574
- const body = file.subarray(0, file.length - 1)
575
- const footerBreak = body.lastIndexOf(0x0a)
576
- const footerStart = footerBreak + 1
577
- const payload = file.subarray(0, footerStart)
578
- try {
579
- const value = JSON.parse(body.subarray(footerStart).toString('utf8')) as unknown
580
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid integrity row')
581
- const row = value as Record<string, unknown>
582
- if (!exactKeys(row, ['bytes', 'k', 'sha256']) || row.k !== 'integrity' || row.bytes !== payload.length
583
- || typeof row.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(row.sha256)
584
- || row.sha256 !== eventPayloadDigest(payload)) throw new Error('invalid integrity row')
585
- const state = decodeEventPayload(payload, location)
586
- if (!state) throw new Error('invalid event row')
587
- return { payload, state }
588
- } catch {
589
- return { payload: Buffer.alloc(0), state: emptyEventCache() }
590
- }
591
- }
592
-
593
- type EventLockOwner = { pid: number; token: string }
594
- function readEventLockOwner(lock: string): EventLockOwner | null {
595
- try {
596
- const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')) as { pid?: unknown; token?: unknown }
597
- return Number.isInteger(value.pid) && (value.pid as number) > 0 && typeof value.token === 'string'
598
- ? { pid: value.pid as number, token: value.token }
599
- : null
600
- } catch { return null }
601
- }
602
- function processAlive(pid: number): boolean {
603
- try { process.kill(pid, 0); return true }
604
- catch (error: any) { return error?.code !== 'ESRCH' }
605
- }
606
- function retireLockPath(active: string): boolean {
607
- const inert = `${active}.inert.${process.pid}.${randomBytes(8).toString('hex')}`
608
- try { renameSync(active, inert) }
609
- catch (error: any) {
610
- if (error?.code === 'ENOENT') return false
611
- throw error
612
- }
613
- try { rmSync(inert, { recursive: true, force: true }) } catch { /* no longer arbitrates ownership */ }
614
- return true
615
- }
616
- function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: EventLockOwner): boolean {
617
- if (processAlive(held.pid)) return false
618
- const reclaim = join(lock, 'reclaim')
619
- if (existsSync(reclaim)) {
620
- const reclaimer = readEventLockOwner(reclaim)
621
- if (!reclaimer || processAlive(reclaimer.pid)) return false
622
- retireLockPath(reclaim)
623
- }
624
- const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.token}`)
625
- try {
626
- mkdirSync(prepared)
627
- writeFileSync(join(prepared, 'owner.json'), JSON.stringify(claimant))
628
- renameSync(prepared, reclaim)
629
- } catch (error: any) {
630
- rmSync(prepared, { recursive: true, force: true })
631
- if (error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY' || error?.code === 'ENOENT') return false
632
- throw error
633
- }
634
- const current = readEventLockOwner(lock)
635
- if (current?.pid !== held.pid || current.token !== held.token || processAlive(current.pid)) {
636
- retireLockPath(reclaim)
637
- return false
638
- }
639
- return retireLockPath(lock)
640
- }
641
- async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): Promise<T> {
642
- const lock = `${path}.lock`
643
- mkdirSync(join(path, '..'), { recursive: true })
644
- const owner: EventLockOwner = { pid: process.pid, token: randomBytes(16).toString('hex') }
645
- const attempts = Math.max(1, Math.ceil(GIT_TIMEOUT_MS / 5))
646
- const signal = inheritedContext()?.signal
647
- for (let attempt = 0; ; attempt++) {
648
- if (signal?.aborted) throw gitAbortError()
649
- const claimant = `${lock}.claim.${owner.pid}.${owner.token}`
650
- try {
651
- mkdirSync(claimant)
652
- writeFileSync(join(claimant, 'owner.json'), JSON.stringify(owner))
653
- renameSync(claimant, lock)
654
- break
655
- } catch (error: any) {
656
- rmSync(claimant, { recursive: true, force: true })
657
- if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
658
- const held = readEventLockOwner(lock)
659
- if (held && reclaimDeadEventLock(lock, held, owner)) continue
660
- if (attempt >= attempts) {
661
- const heldBy = readEventLockOwner(lock)?.pid
662
- throw new Error(`timed out waiting for history event cache lock held by ${heldBy ? `pid ${heldBy}` : 'unknown owner'}: ${path}`)
663
- }
664
- await new Promise((resolve) => setTimeout(resolve, 5))
665
- }
666
- }
667
- try { return await run() } finally {
668
- if (readEventLockOwner(lock)?.token === owner.token) retireLockPath(lock)
669
- }
670
- }
671
- function removeEventTemps(path: string): void {
672
- const dir = join(path, '..'), base = path.split('/').pop() ?? ''
673
- try {
674
- for (const name of readdirSync(dir)) if (name.startsWith(`${base}.`) && name.endsWith('.tmp'))
675
- rmSync(join(dir, name), { force: true })
676
- } catch { /* the writer creates the directory immediately below */ }
677
- }
678
- function replaceEventLedger(path: string, payload: Buffer, additions: string[]): void {
679
- const addition = Buffer.from(additions.join(''))
680
- const tmp = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
681
- let fd: number | null = null
682
- try {
683
- fd = openSync(tmp, 'wx', 0o600)
684
- writeFileSync(fd, payload)
685
- writeFileSync(fd, addition)
686
- writeFileSync(fd, eventIntegrityFooter(payload, addition))
687
- const written = fd; fd = null; closeSync(written)
688
- renameSync(tmp, path)
689
- } catch (error) {
690
- if (fd !== null) closeSync(fd)
691
- rmSync(tmp, { force: true })
692
- throw error
693
- }
694
- }
695
- function sortedEventRecords(records: Iterable<EventRecord>, request: EventStreamRequest): EventRecord[] {
696
- return [...records].filter((record) => request.reachable.has(record.hash))
697
- .sort((a, b) => (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
698
- }
699
- function identityRecords(records: Iterable<EventRecord>, request: EventStreamRequest): IdentityRawRecord[] {
700
- return sortedEventRecords(records, request).map((record) => {
701
- if (!('identity' in record)) throw new Error('identity-raw ledger contained a text event')
702
- return record.identity
703
- })
704
- }
705
- function renderEventStream(state: EventCache, request: EventStreamRequest): EventStreamOutput {
706
- const records = sortedEventRecords(state.streams.get(request.kind)?.values() ?? [], request)
707
- if (request.kind === 'identity-raw') return records.map((record) => {
708
- if (!('identity' in record)) throw new Error('identity-raw ledger contained a text event')
709
- return record.identity
710
- })
711
- return records.map((record) => {
712
- if (!('raw' in record)) throw new Error(`history event stream '${request.kind}' contained a structured event`)
713
- return RS + record.raw
714
- }).join('')
715
- }
716
- function appendEventRecord(state: EventCache, kind: EventStreamKind, record: EventRecord, additions: string[]): void {
717
- let stream = state.streams.get(kind)
718
- if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
719
- if (stream.has(record.hash)) return
720
- stream.set(record.hash, record)
721
- additions.push('identity' in record
722
- ? JSON.stringify({ k: kind, h: record.hash, d: record.identity.d, r: record.identity.r, s: record.identity.s, a: record.identity.a, c: record.identity.c.flat() }) + '\n'
723
- : JSON.stringify({ k: kind, h: record.hash, r: record.raw }) + '\n')
724
- }
725
- function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, additions: string[]): void {
726
- const tips = state.streamTips.get(kind) ?? []
727
- if (tips.includes(tip)) return
728
- tips.push(tip)
729
- state.streamTips.set(kind, tips)
730
- additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
731
- }
732
- function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
733
- if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
734
- const records: EventRecord[] = []
735
- for (const rec of out.split(RS)) {
736
- const raw = rec.replace(/^\n/, '')
737
- if (!raw) continue
738
- const hash = raw.split(US, 1)[0].split('\n', 1)[0].trim()
739
- if (!isGitObjectIdForFormat(location.objectFormat, hash))
740
- throw new Error(`history event stream '${kind}' returned malformed object id '${hash || 'empty'}'`)
741
- records.push({ hash, raw })
742
- }
743
- return records
744
- }
745
- function indexEventRequests(
746
- root: string,
747
- tip: string,
748
- order: Map<string, number>,
749
- reachable: Set<string>,
750
- ): Record<EventStreamKind, EventStreamRequest> {
751
- return {
752
- 'identity-raw': {
753
- kind: 'identity-raw', order, reachable,
754
- argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
755
- 'log', '--root', '--full-history', '--date-order', '--no-diff-merges', '-M', '-l0', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv',
756
- `--format=${RS}%H%x00%aI%x00%s%x00%b%x00%(trailers:key=Spec-OK,valueonly,separator=%x2C)%x00`,
757
- ...(base ? [`^${base}`] : []), tip],
758
- },
759
- merge: {
760
- kind: 'merge', order, reachable,
761
- argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
762
- 'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M',
763
- `--format=${RS}%H`, ...(base ? [`^${base}`] : []), tip],
764
- },
765
- }
766
- }
767
-
768
- async function strictEventGit(args: string[]): Promise<string> {
769
- return gitRequiredA(args, 'cannot derive history events')
770
- }
771
- async function deriveEventStreams(
772
- root: string,
773
- tip: string,
774
- requests: EventStreamRequest[],
775
- persist = true,
776
- cache = true,
777
- ): Promise<Map<EventStreamKind, EventStreamOutput>> {
778
- if (!cache) {
779
- const location = eventCacheLocation(root)
780
- const outputs = await Promise.all(requests.map((request) => strictEventGit(request.argsFor(''))))
781
- const rendered = new Map<EventStreamKind, EventStreamOutput>()
782
- for (let index = 0; index < requests.length; index++) {
783
- const request = requests[index]
784
- rendered.set(request.kind, request.kind === 'identity-raw'
785
- ? identityRecords(parseIdentityRawEventRecords(outputs[index], location), request)
786
- : outputs[index])
787
- }
788
- return rendered
789
- }
790
- if (new Set(requests.map((request) => request.kind)).size !== requests.length)
791
- throw new Error('one event-ledger transaction cannot request the same stream twice')
792
-
793
- const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, EventStreamOutput> | null> => {
794
- const snapshot = loadEventLedger(location)
795
- const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
796
- const outputs = await Promise.all(missing.map((request) => {
797
- const base = [...(snapshot.state.streamTips.get(request.kind) ?? [])].reverse()
798
- .find((candidate) => request.reachable.has(candidate)) ?? ''
799
- return strictEventGit(request.argsFor(base))
800
- }))
801
- if (eventCacheLocation(root).identity !== location.identity) return null
802
-
803
- const additions: string[] = []
804
- for (let index = 0; index < missing.length; index++) {
805
- const request = missing[index]
806
- for (const record of parseEventRecords(outputs[index], request.kind, location))
807
- appendEventRecord(snapshot.state, request.kind, record, additions)
808
- if (persist) appendEventTip(snapshot.state, request.kind, tip, additions)
809
- }
810
- if (persist && additions.length) {
811
- removeEventTemps(location.path)
812
- mkdirSync(join(location.path, '..'), { recursive: true })
813
- replaceEventLedger(location.path, snapshot.payload, additions)
814
- }
815
- return new Map(requests.map((request) => [request.kind, renderEventStream(snapshot.state, request)]))
816
- }
817
-
818
- for (let attempt = 0; attempt < 8; attempt++) {
819
- const location = eventCacheLocation(root)
820
- const result = persist ? await withEventCacheLock(location.path, () => run(location)) : await run(location)
821
- if (result) return result
822
- }
823
- throw new Error(`history event cache identity changed repeatedly while deriving ${tip}`)
824
- }
825
- async function eventStream(
826
- root: string,
827
- tip: string,
828
- request: EventStreamRequest,
829
- persist = true,
830
- cache = true,
831
- ): Promise<EventStreamOutput> {
832
- const value = (await deriveEventStreams(root, tip, [request], persist, cache)).get(request.kind)
833
- if (value === undefined) throw new Error(`history event stream '${request.kind}' was not rendered`)
834
- return value
835
- }
836
- async function textEventStream(root: string, tip: string, request: EventStreamRequest, persist = true, cache = true): Promise<string> {
837
- const value = await eventStream(root, tip, request, persist, cache)
838
- if (typeof value !== 'string') throw new Error(`history event stream '${request.kind}' rendered structured data`)
839
- return value
840
- }
841
- async function identityRawEventStream(root: string, tip: string, request: EventStreamRequest, persist = true, cache = true): Promise<IdentityRawRecord[]> {
842
- const value = await eventStream(root, tip, request, persist, cache)
843
- if (!Array.isArray(value) || value.some((record) => !('a' in record))) throw new Error(`history event stream '${request.kind}' rendered text`)
844
- return value as IdentityRawRecord[]
845
- }
846
- export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
847
- export async function gitTry(args: string[], options: { indexFile?: string; extraEnv?: Record<string, string | undefined> } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
848
- const env = { ...process.env }
849
- for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
850
- if (value === undefined) delete env[key]
851
- else env[key] = value
852
- }
853
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
854
- if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
855
- const context = inheritedContext()
856
- try {
857
- const { stdout, stderr } = await execGitForCaller(args, env)
858
- return { ok: true, stdout: stdout.toString('utf8'), stderr }
859
- } catch (e: any) {
860
- if (context?.signal.aborted || e?.name === 'AbortError') throw e
861
- warnIfTimedOut(e, args)
862
- const failure: GitTryFailure = e?.spexcodeGitTimeout ? 'timeout' : typeof e?.code === 'number' ? 'exit' : 'spawn'
863
- return { ok: false, stdout: e?.stdout ?? '', stderr: e?.stderr ?? String(e?.message ?? e), failure }
864
- }
865
- }
866
-
867
- export async function gitRequiredA(args: string[], purpose: string): Promise<string> {
868
- const env = { ...process.env }
869
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
870
- try { return (await execGitStreamForCaller(args, env)).stdout.toString('utf8') }
871
- catch (error: any) {
872
- if (error?.name === 'AbortError') throw error
873
- warnIfTimedOut(error, args)
874
- throw new Error(`${purpose}: git ${args.slice(2).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
875
- }
876
- }
877
-
878
- // memoized: repoRoot is constant per process, but resolveLayout() calls it per request — avoid a git fork each time.
879
- let repoRootCache: string | null = null
880
- export function repoRoot(): string {
881
- if (repoRootCache !== null) return repoRootCache
882
- try {
883
- repoRootCache = git(['rev-parse', '--show-toplevel']).trim()
884
- } catch {
885
- repoRootCache = process.cwd()
886
- }
887
- return repoRootCache
888
- }
889
-
890
- function gitDirOf(root: string): string {
891
- // a normal checkout has a `.git` DIRECTORY; a linked worktree has a `.git` FILE: `gitdir: <path>`.
892
- const dotgit = join(root, '.git')
893
- if (!existsSync(dotgit)) {
894
- try {
895
- if (git(['-C', root, 'rev-parse', '--is-bare-repository']).trim() === 'true') return root
896
- } catch { /* the caller will receive the same loud path error as a malformed checkout */ }
897
- throw new Error(`headSha: no .git directory at ${dotgit}`)
898
- }
899
- if (statSync(dotgit).isDirectory()) return dotgit
900
- const m = readFileSync(dotgit, 'utf8').match(/^gitdir:\s*(.+)$/m)
901
- if (!m) throw new Error(`headSha: unparseable .git file at ${dotgit}`)
902
- const dir = m[1].trim()
903
- return isAbsolute(dir) ? dir : resolve(root, dir)
904
- }
905
- function commonDirOf(gitDir: string): string {
906
- // a worktree's gitdir holds per-worktree state (HEAD); SHARED refs (refs/heads/*, packed-refs) live
907
- // in the common dir, named by the `commondir` pointer. A plain checkout IS its own common dir.
908
- const p = join(gitDir, 'commondir')
909
- if (!existsSync(p)) return gitDir
910
- const c = readFileSync(p, 'utf8').trim()
911
- return isAbsolute(c) ? c : resolve(gitDir, c)
912
- }
913
- export function headSha(root: string): string {
914
- const gitDir = gitDirOf(root)
915
- const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim()
916
- const ref = head.match(/^ref:\s*(.+)$/)
917
- if (!ref) return head // detached HEAD: the file already holds the sha
918
- const name = ref[1].trim()
919
- // a loose ref wins over packed; per-worktree HEAD points at a branch whose ref lives in the common dir.
920
- const looseWt = join(gitDir, name)
921
- if (existsSync(looseWt)) return readFileSync(looseWt, 'utf8').trim()
922
- const common = commonDirOf(gitDir)
923
- const loose = join(common, name)
924
- if (existsSync(loose)) return readFileSync(loose, 'utf8').trim()
925
- const packed = join(common, 'packed-refs')
926
- if (existsSync(packed)) {
927
- for (const line of readFileSync(packed, 'utf8').split('\n')) {
928
- if (!line || line[0] === '#' || line[0] === '^') continue
929
- const sp = line.indexOf(' ')
930
- if (sp > 0 && line.slice(sp + 1).trim() === name) return line.slice(0, sp).trim()
931
- }
932
- }
933
- // an UNBORN HEAD — a fresh `git init` with no commits — points at a branch ref that doesn't exist yet.
934
- // That is a valid EMPTY-HISTORY state, not a failure: the board renders fine from the working tree. Return
935
- // a stable, truthy sentinel so historyIndex/driftIndex/safeHead MEMOIZE it (the head value is only ever a
936
- // cache key, never a git ref) instead of re-forking git on every read; headOrEmpty's warning is then
937
- // reserved for a genuinely unreadable HEAD and never fires for this routine first-run state.
938
- return `unborn:${name}`
939
- }
940
-
941
- // fingerprint of a worktree's `.spec` working tree by path + mtimeMs + size (no git); the overlay-cache
942
- // key for its working-tree state. '' when `.spec` is absent.
943
- export function worktreeSpecSig(wtPath: string): string {
944
- const root = join(wtPath, '.spec')
945
- if (!existsSync(root)) return ''
946
- const parts: string[] = []
947
- const stack = [root]
948
- while (stack.length) {
949
- const dir = stack.pop()!
950
- let ents
951
- try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
952
- for (const e of ents) {
953
- const p = join(dir, e.name)
954
- if (e.isDirectory()) { stack.push(p); continue }
955
- try { const st = statSync(p); parts.push(`${p}:${st.mtimeMs}:${st.size}`) } catch { /* vanished mid-walk */ }
956
- }
957
- }
958
- return parts.sort().join('\n')
959
- }
960
-
961
- export type Version = { hash: string; date: string; reason: string; session: string | null }
962
- export type DiffStat = { additions: number; deletions: number; files: number }
963
-
964
- // ---- bulk spec history index ----
965
-
966
- export type HistoryIndex = {
967
- versions: Map<string, Version[]> // headPath -> rows newest-first (incl. pure-rename rows)
968
- contentVersions: Set<string> // headPath\0hash rows whose immutable blob changed
969
- versionPaths: Map<string, string> // headPath\0hash -> path at that version commit
970
- mergeVersions?: Set<string> // path\0hash pairs with an all-parent combined-diff line
971
- }
972
-
973
- type IdentityRawRecord = { h: string; d: string; r: string; s: string | null; a: string; c: [string, string, string, string, string][] }
974
-
975
- function isRawObjectId(value: string, format: GitObjectFormat): boolean {
976
- const length = format === 'sha256' ? 64 : 40
977
- return isGitObjectIdForFormat(format, value) || (value.length === length && /^0+$/.test(value))
978
- }
979
-
980
- function parseIdentityRawEventRecords(out: string, location: EventCacheLocation): EventRecord[] {
981
- const parsed: IdentityRawRecord[] = []
982
- let current: IdentityRawRecord | null = null
983
- const tokens = out.split('\0')
984
- let index = 0
985
- const metadata = (field: string): string => {
986
- const token = tokens[index++]
987
- if (token === undefined) throw new Error(`raw identity stream ended before commit ${field}`)
988
- return token
989
- }
990
- const begin = (token: string): IdentityRawRecord => {
991
- const header = token.startsWith('\n') ? token.slice(1) : token
992
- if (!header.startsWith(RS)) throw new Error(`raw identity stream expected a commit header, got '${header}'`)
993
- const hash = header.slice(RS.length)
994
- if (!isGitObjectIdForFormat(location.objectFormat, hash))
995
- throw new Error(`history event stream 'identity-raw' returned malformed object id '${hash || 'empty'}'`)
996
- if (current) parsed.push(current)
997
- const date = metadata('date'), reason = metadata('subject'), body = metadata('body'), ack = metadata('trailers')
998
- const session = body.match(/Session:\s*(\S+)/)
999
- return { h: hash, d: date, r: reason, s: session ? session[1] : null, a: ack, c: [] }
1000
- }
1001
- while (index < tokens.length) {
1002
- const token = tokens[index++]
1003
- if (!current) {
1004
- if (!token) continue
1005
- current = begin(token)
1006
- continue
1007
- }
1008
- const value = token.startsWith('\n') ? token.slice(1) : token
1009
- if (!value) continue
1010
- if (value.startsWith(RS)) { current = begin(value); continue }
1011
- const raw = value.match(/^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])(?:\d+)?$/)
1012
- if (!raw || !isRawObjectId(raw[3], location.objectFormat) || !isRawObjectId(raw[4], location.objectFormat))
1013
- throw new Error(`raw identity event ${current.h} has malformed raw record '${value}'`)
1014
- const from = tokens[index++]
1015
- if (from === undefined) throw new Error(`raw identity event ${current.h} ended before its path`)
1016
- const to = raw[5] === 'R' ? tokens[index++] : from
1017
- if (to === undefined) throw new Error(`raw identity event ${current.h} ended before its rename destination`)
1018
- current.c.push([raw[5], from, to, raw[3], raw[4]])
1019
- }
1020
- if (current) parsed.push(current)
1021
- return parsed.map((identity) => ({ hash: identity.h, identity }))
1022
- }
1023
-
1024
- function decodeIdentityRawRecord(value: unknown, hash: string, location: EventCacheLocation): IdentityRawRecord | null {
1025
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
1026
- const record = value as Record<string, unknown>
1027
- if (!exactKeys(record, ['a', 'c', 'd', 'r', 's']) || typeof record.a !== 'string' || typeof record.d !== 'string'
1028
- || typeof record.r !== 'string' || (typeof record.s !== 'string' && record.s !== null) || !Array.isArray(record.c) || record.c.length % 5) return null
1029
- const changes: [string, string, string, string, string][] = []
1030
- for (let index = 0; index < record.c.length; index += 5) {
1031
- if (typeof record.c[index] !== 'string' || typeof record.c[index + 1] !== 'string' || typeof record.c[index + 2] !== 'string'
1032
- || typeof record.c[index + 3] !== 'string' || typeof record.c[index + 4] !== 'string'
1033
- || !/^[A-Z](?:\d+)?$/.test(record.c[index] as string) || !isRawObjectId(record.c[index + 3] as string, location.objectFormat)
1034
- || !isRawObjectId(record.c[index + 4] as string, location.objectFormat)) return null
1035
- changes.push([record.c[index], record.c[index + 1], record.c[index + 2], record.c[index + 3], record.c[index + 4]] as [string, string, string, string, string])
1036
- }
1037
- return { h: hash, d: record.d, r: record.r, s: record.s, a: record.a, c: changes }
1038
- }
1039
-
1040
- // Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
1041
- // once — the backend checkout (board, loadSpecs) plus every session worktree ([[session-eval]]'s eval
1042
- // tab roots its readings at the session's branch). A single-slot cache thrashes between those roots:
1043
- // each eval-tab request evicts the board's entry and vice versa, so every request re-runs a full-history
1044
- // `git log` and re-parses it on the event loop — which is what starves every other request (the board,
1045
- // remark posts) under load. So the cache is a small LRU keyed by interpretation identity + HEAD, holding
1046
- // the in-flight PROMISE so concurrent requests for one immutable view share a single build.
1047
- const indexCache = new Map<string, Promise<HistoryIndex>>()
1048
- const indexRoots = new Map<string, string>()
1049
- const driftRoots = new Map<string, string>()
1050
- const driftIdxCache = new Map<string, Promise<DriftIndex>>()
1051
- const INDEX_ROOT_SLOTS = rootSlots(process.env.SPEXCODE_INDEX_CACHE_ROOTS, 32)
1052
-
1053
- function rootKey(root: string): string { return resolve(root) }
1054
-
1055
- function indexCacheKey(root: string, head: string): string { return `${eventCacheLocation(root).path}\0${head}` }
1056
-
1057
- // A project-namespaced ledger path plus HEAD identifies immutable index contents. Its path is scoped to the
1058
- // common Git store and interpretation state, so linked worktrees share while independent same-HEAD clones do not.
1059
- // The checkout root owns which live view is useful; the root bound keeps closed worktrees from leaking.
1060
- // The bookkeeping itself is [[root-lru]]'s — this only names the store and the bound.
1061
- function touchRoot(roots: Map<string, string>, cache: Map<string, Promise<unknown>>, root: string, cacheKey: string): void {
1062
- touchRootLru(roots, cache, rootKey(root), cacheKey, INDEX_ROOT_SLOTS)
1063
- }
1064
-
1065
- function dropFailed(cache: Map<string, Promise<unknown>>, head: string, promise: Promise<unknown>): void {
1066
- if (cache.get(head) !== promise) return
1067
- // A rejected index is never reusable, even when its root still points at that HEAD. The next read must
1068
- // start a fresh walk after a watchdog abort or transient git failure.
1069
- cache.delete(head)
1070
- }
1071
-
1072
- function pendingOnlyIssues(root: string, tip: string): boolean {
1073
- try {
1074
- const parents = git(['-C', root, 'rev-list', '--parents', '-n1', tip]).trim().split(/\s+/).filter(Boolean)
1075
- // A first-parent issue diff can hide side-branch code debt in an ours merge. Only a single-parent
1076
- // candidate may reuse its parent's indexes; every merge must build the candidate's full reachable view.
1077
- if (parents.length !== 2) return false
1078
- const parent = git(['-C', root, 'rev-parse', `${tip}^`]).trim()
1079
- const paths = git(['-C', root, 'diff-tree', '--no-commit-id', '--name-only', '-r', parent, tip])
1080
- .split('\n').map((p) => p.trim()).filter(Boolean)
1081
- return paths.length > 0 && paths.every((p) => p.startsWith('.spec/.issues/'))
1082
- } catch { return false }
1083
- }
1084
- function pendingParent(root: string, tip: string): string | null {
1085
- try {
1086
- const parent = git(['-C', root, 'rev-parse', `${tip}^`]).trim()
1087
- return isGitObjectId(root, parent) ? parent : null
1088
- } catch { return null }
1089
- }
1090
-
1091
- export function historyIndex(root: string, tip = 'HEAD'): Promise<HistoryIndex> {
1092
- if (tip !== 'HEAD') {
1093
- const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
1094
- // An issue-only candidate changes no governed content, but its lint result still includes the
1095
- // parent's existing drift/related-drift warnings. Reuse that immutable parent index instead of
1096
- // manufacturing an empty one (which silently dropped warnings at golden depths).
1097
- if (pendingOnlyIssues(root, resolved)) {
1098
- const parent = pendingParent(root, resolved)
1099
- if (parent) {
1100
- const head = headOrEmpty(root)
1101
- if (head === parent) return historyIndex(root)
1102
- return buildIndex(root, parent, true, true)
1103
- }
1104
- }
1105
- return buildIndex(root, resolved, true, true)
1106
- }
1107
- const head = headOrEmpty(root)
1108
- if (!head) return buildIndex(root, 'HEAD', false, true)
1109
- const cacheKey = indexCacheKey(root, head)
1110
- touchRoot(indexRoots, indexCache, root, cacheKey)
1111
- const hit = indexCache.get(cacheKey)
1112
- if (hit) return hit
1113
- const p = buildIndex(root, head.startsWith('unborn:') ? 'HEAD' : head, false, true)
1114
- p.catch(() => { dropFailed(indexCache, cacheKey, p) }) // don't pin a failed build
1115
- indexCache.set(cacheKey, p)
1116
- return p
1117
- }
1118
-
1119
- // resolve HEAD for cache-keying, '' if unreadable (fails the cache test → recompute); warns once.
1120
- let headWarned = false
1121
- function headOrEmpty(root: string): string {
1122
- try { return headSha(root) }
1123
- catch (e) {
1124
- if (!headWarned) { headWarned = true; console.warn(`spec-cli: headSha failed, recomputing every read: ${(e as Error).message}`) }
1125
- return ''
1126
- }
1127
- }
1128
-
1129
- // @@@ reachability memoized on the rename side, never the event side - every comparison the projector makes
1130
- // has a RENAME commit on one end, and a history holds far fewer renames than file events. Keying the memo on
1131
- // the OTHER end rebuilds a history-wide ancestor set per distinct event commit: 2.3M parent-edge visits and
1132
- // 1,219 retained bitsets served 9k one-bit questions on this repository. That end builds one closure per
1133
- // distinct event commit actually compared against a rename — C of them, O(C(H+G)) construction and Θ(CH) bits
1134
- // — which a linear history whose events all sit on one renamed path drives to Θ(H²). Asking the rename end
1135
- // instead — its descendants when it is the older commit, its ancestors when it is the newer one — moves ONLY
1136
- // the closure term onto the rename count K: at most 2K full-size closures, O(K(H+G)) construction over H
1137
- // reachable commits and G parent edges, O(KH) bits. That 2K ceiling bounds closure buffers, count and bytes,
1138
- // against C; it does NOT bound runtime or edge visits, since a rename with many unrelated descendants
1139
- // traverses ground the event-side ancestor walk never touched. The projector's own work is unchanged and is
1140
- // NOT covered by that term: one scan of the N events, plus a lineage walk whose frontier compares each step's
1141
- // applicable renames pairwise — Σ d(candidate)² O(1) queries, worst case Θ(NK²) when one path carries K
1142
- // mutually incomparable renames. So: no linear-in-history promise — at K≈H the closure term is O(H(H+G)),
1143
- // quadratic only where the DAG is sparse enough that G=O(H), and the untouched frontier term is cubic when
1144
- // N, K and H grow together. It is also why a
1145
- // reachability matrix over the rename commits is not worth it: same O(KH) bits, but eagerly.
1146
- function renameSideReachability(
1147
- renameCommits: Set<string>,
1148
- topology: TopologyProjection,
1149
- ): (older: string, newer: string) => boolean {
1150
- const { order, parents } = topology
1151
- const size = (order.size + 7) >> 3
1152
- let childEdges: Map<string, string[]> | null = null
1153
- const children = (): Map<string, string[]> => {
1154
- if (childEdges) return childEdges
1155
- childEdges = new Map()
1156
- for (const [hash] of order) for (const parent of parents.get(hash) ?? []) {
1157
- if (!order.has(parent)) continue // shallow boundary: an unwalked parent ends the chain
1158
- const kids = childEdges.get(parent)
1159
- if (kids) kids.push(hash)
1160
- else childEdges.set(parent, [hash])
1161
- }
1162
- return childEdges
1163
- }
1164
- const closure = (start: string, edges: Map<string, string[]>, memo: Map<string, Uint8Array>): Uint8Array => {
1165
- const hit = memo.get(start)
1166
- if (hit) return hit
1167
- const bits = new Uint8Array(size)
1168
- const at = order.get(start)!
1169
- bits[at >> 3] |= 1 << (at & 7)
1170
- const stack = [start]
1171
- while (stack.length) for (const next of edges.get(stack.pop()!) ?? []) {
1172
- const position = order.get(next)
1173
- if (position === undefined) continue
1174
- const mask = 1 << (position & 7)
1175
- if (bits[position >> 3] & mask) continue
1176
- bits[position >> 3] |= mask
1177
- stack.push(next)
1178
- }
1179
- memo.set(start, bits)
1180
- return bits
1181
- }
1182
- const ancestors = new Map<string, Uint8Array>(), descendants = new Map<string, Uint8Array>()
1183
- return (older, newer) => {
1184
- const from = order.get(older), to = order.get(newer)
1185
- if (from === undefined || to === undefined)
1186
- throw new Error(`rename projection cannot place ${older} against ${newer} in the current topology`)
1187
- // Whichever end is the rename owns the closure; the projector always puts one there.
1188
- return renameCommits.has(older)
1189
- ? (closure(older, children(), descendants)[to >> 3] & (1 << (to & 7))) !== 0
1190
- : (closure(newer, parents, ancestors)[from >> 3] & (1 << (from & 7))) !== 0
1191
- }
1192
- }
1193
-
1194
- type RenameProjectionEvent = { hash: string; to: string }
1195
- function canonicalPathProjector(
1196
- renamesByFrom: Map<string, RenameProjectionEvent[]>,
1197
- topology: TopologyProjection,
1198
- ): (path: string, event: string) => string[] {
1199
- const renameCommits = new Set<string>()
1200
- for (const renames of renamesByFrom.values()) for (const rename of renames) renameCommits.add(rename.hash)
1201
- const precedes = renameSideReachability(renameCommits, topology)
1202
- return (path, event) => {
1203
- // A path no rename ever left keeps its own identity at every event; there is no lineage to walk.
1204
- if (!renamesByFrom.has(path)) return [path]
1205
- const pending = [path], resolved = new Set<string>(), seen = new Set<string>()
1206
- while (pending.length) {
1207
- const candidate = pending.pop()!
1208
- if (seen.has(candidate)) { resolved.add(candidate); continue }
1209
- seen.add(candidate)
1210
- const unique = new Map<string, RenameProjectionEvent>()
1211
- for (const rename of renamesByFrom.get(candidate) ?? []) {
1212
- if (!precedes(rename.hash, event)) unique.set(`${rename.hash}\0${rename.to}`, rename)
1213
- }
1214
- const applicable = [...unique.values()]
1215
- if (!applicable.length) { resolved.add(candidate); continue }
1216
- // The earliest applicable rename starts this path's next lineage epoch. Later renames from the same
1217
- // path belong to a recreated path, not to an event that predates the first boundary. Incomparable
1218
- // rename branches are both frontier members, so their identities still fork at the merge. A merge can
1219
- // expose the same rename once per parent; equal-commit rows are peers, never ancestors of one another.
1220
- const frontier = applicable.filter((rename, index) =>
1221
- !applicable.some((other, otherIndex) => otherIndex !== index
1222
- && other.hash !== rename.hash && precedes(other.hash, rename.hash)))
1223
- if (frontier.some((rename) => !precedes(event, rename.hash))) resolved.add(candidate)
1224
- for (const rename of frontier) pending.push(rename.to)
1225
- }
1226
- return [...resolved]
1227
- }
1228
- }
1229
-
1230
- type SharedIndexInputs = {
1231
- allPaths: Set<string>
1232
- specPaths: Set<string>
1233
- topology: TopologyProjection
1234
- identityRecords: IdentityRawRecord[]
1235
- mergeIndex: MergeHistoryEvents
1236
- }
1237
-
1238
- type TopologyProjection = {
1239
- order: Map<string, number>
1240
- parents: Map<string, string[]>
1241
- reachable: Set<string>
1242
- }
1243
-
1244
- async function buildIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<HistoryIndex> {
1245
- const versions = new Map<string, Version[]>()
1246
- const contentVersions = new Set<string>()
1247
- const versionPaths = new Map<string, string>()
1248
- const mergeVersions = new Set<string>()
1249
- const commitVersions = new Map<string, Version>()
1250
- const commitOrder = new Map<string, number>()
1251
- const rawVersions = new Map<string, Version[]>()
1252
- let currentPaths: Set<string>
1253
- let topology: TopologyProjection
1254
- if (shared) {
1255
- currentPaths = shared.specPaths
1256
- topology = shared.topology
1257
- } else {
1258
- const [tipPathsOut, topologyOut] = await Promise.all([
1259
- strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip, '--', '.spec']),
1260
- strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1261
- ])
1262
- currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')))
1263
- topology = topologyProjection(topologyOut)
1264
- }
1265
- const renamesByFrom = new Map<string, { hash: string; to: string }[]>()
1266
- const topologyOrd = topology.order
1267
- const topologyParents = topology.parents
1268
- const topologyReachable = topology.reachable
1269
- const identityRecords = shared?.identityRecords ?? await identityRawEventStream(root, tip,
1270
- indexEventRequests(root, tip, topologyOrd, topologyReachable)['identity-raw'], !transient, useCache)
1271
- if (!identityRecords.length) return { versions, contentVersions, versionPaths, mergeVersions }
1272
- let commitPosition = 0
1273
- const rawContent = new Set<string>()
1274
- for (const record of identityRecords) {
1275
- const version: Version = { hash: record.h, date: record.d, reason: record.r, session: record.s }
1276
- commitVersions.set(record.h, version)
1277
- if (!commitOrder.has(record.h)) commitOrder.set(record.h, commitPosition++)
1278
- for (const [, from, to, oldOid, newOid] of record.c) {
1279
- if (!from.startsWith('.spec/') && !to.startsWith('.spec/')) continue
1280
- if (!rawVersions.has(to)) rawVersions.set(to, [])
1281
- rawVersions.get(to)!.push(version)
1282
- if (oldOid !== newOid) rawContent.add(`${to}\0${record.h}`)
1283
- if (from !== to) {
1284
- const renames = renamesByFrom.get(from) ?? []
1285
- renames.push({ hash: record.h, to })
1286
- renamesByFrom.set(from, renames)
1287
- }
1288
- }
1289
- }
1290
-
1291
- // Re-establish Git's date-order contract after records are parsed from a stream. An independent commit
1292
- // can appear before its child in a date-ordered result; repair that partial-order constraint with a stable
1293
- // topological pass. A comparator that
1294
- // materializes an ancestor Set/bitset for every sort comparison turns a 4k-commit history into an
1295
- // accidental quadratic walk; this queue visits each commit and parent edge once.
1296
- const originalOrder = new Map(commitOrder)
1297
- const childrenLeft = new Map<string, number>()
1298
- for (const hash of commitVersions.keys()) childrenLeft.set(hash, 0)
1299
- for (const hash of commitVersions.keys()) for (const parent of topologyParents.get(hash) ?? [])
1300
- if (childrenLeft.has(parent)) childrenLeft.set(parent, childrenLeft.get(parent)! + 1)
1301
- const heap: string[] = []
1302
- const before = (a: string, b: string) => (originalOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - (originalOrder.get(b) ?? Number.MAX_SAFE_INTEGER)
1303
- const push = (hash: string) => {
1304
- let i = heap.push(hash) - 1
1305
- while (i > 0) {
1306
- const parent = (i - 1) >> 1
1307
- if (before(heap[parent], hash) <= 0) break
1308
- heap[i] = heap[parent]; i = parent
1309
- }
1310
- heap[i] = hash
1311
- }
1312
- const pop = (): string => {
1313
- const first = heap[0], last = heap.pop()!
1314
- if (heap.length) {
1315
- let i = 0
1316
- while (true) {
1317
- const left = i * 2 + 1
1318
- if (left >= heap.length) break
1319
- let child = left
1320
- const right = left + 1
1321
- if (right < heap.length && before(heap[right], heap[left]) < 0) child = right
1322
- if (before(heap[child], last) >= 0) break
1323
- heap[i] = heap[child]; i = child
1324
- }
1325
- heap[i] = last
1326
- }
1327
- return first
1328
- }
1329
- for (const [hash, left] of childrenLeft) if (left === 0) push(hash)
1330
- const ordered: string[] = []
1331
- while (heap.length) {
1332
- const hash = pop(); ordered.push(hash)
1333
- for (const parent of topologyParents.get(hash) ?? []) {
1334
- if (!childrenLeft.has(parent)) continue
1335
- const left = childrenLeft.get(parent)! - 1
1336
- childrenLeft.set(parent, left)
1337
- if (left === 0) push(parent)
1338
- }
1339
- }
1340
- // A shallow or malformed topology should fail closed rather than silently dropping a version row.
1341
- if (ordered.length !== commitVersions.size)
1342
- for (const hash of commitVersions.keys()) if (!ordered.includes(hash)) ordered.push(hash)
1343
- commitOrder.clear(); ordered.forEach((hash, i) => commitOrder.set(hash, i))
1344
-
1345
- const mergeIndex = shared?.mergeIndex
1346
- ?? await mergeHistoryEvents(root, tip, transient, topologyOrd, topologyReachable, useCache)
1347
- for (const rename of mergeIndex.renames) {
1348
- const renames = renamesByFrom.get(rename.from) ?? []
1349
- renames.push({ hash: rename.hash, to: rename.to })
1350
- renamesByFrom.set(rename.from, renames)
1351
- }
1352
- const canonical = canonicalPathProjector(renamesByFrom, topology)
1353
- for (const [path, pathRows] of rawVersions) {
1354
- for (const row of pathRows) for (const head of canonical(path, row.hash).filter((candidate) => currentPaths.has(candidate))) {
1355
- const rows = versions.get(head) ?? []
1356
- if (!rows.some((existing) => existing.hash === row.hash)) rows.push(row)
1357
- versions.set(head, rows)
1358
- const key = `${head}\0${row.hash}`
1359
- if (rawContent.has(`${path}\0${row.hash}`)) contentVersions.add(key)
1360
- if (!versionPaths.has(key)) versionPaths.set(key, path)
1361
- }
1362
- }
1363
- for (const [path, mergeEvents] of mergeIndex.resolutions) {
1364
- if (!isSpecMd(path)) continue
1365
- for (const { hash } of mergeEvents) for (const head of canonical(path, hash)) {
1366
- const rows = versions.get(head) ?? []
1367
- const version = commitVersions.get(hash)
1368
- if (!version || rows.some((row) => row.hash === hash)) continue
1369
- rows.push(version)
1370
- const key = `${head}\0${hash}`
1371
- mergeVersions.add(key)
1372
- if (!versionPaths.has(key)) versionPaths.set(key, path)
1373
- versions.set(head, rows)
1374
- }
1375
- }
1376
- for (const rows of versions.values()) {
1377
- rows.sort((a, b) => (commitOrder.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (commitOrder.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
1378
- }
1379
- return { versions, contentVersions, versionPaths, mergeVersions }
1380
- }
1381
-
1382
- // Pure lookups over a prebuilt index. Blob identity decides whether a one-parent row is a content version;
1383
- // numstat remains display data and must not let attributes erase a version window.
1384
- export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
1385
- const rows = idx.versions.get(relPath) ?? []
1386
- return rows.filter((v) => idx.contentVersions.has(`${relPath}\0${v.hash}`) || idx.mergeVersions?.has(`${relPath}\0${v.hash}`))
1387
- }
1388
-
1389
- // per-commit numstat summed over a SET of paths in one `git log` walk. No `--follow` (it takes a single
1390
- // path), so no rename-tracking — same as the old `git show -- paths`; spec.md gets renames via the bulk index.
1391
- export async function pathsStats(root: string, paths: string[]): Promise<Map<string, DiffStat>> {
1392
- const m = new Map<string, DiffStat>()
1393
- if (!paths.length) return m
1394
- const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '--format=%H', '--numstat', '--', ...paths])
1395
- if (!out) return m
1396
- let cur = ''
1397
- for (const line of out.split('\n')) {
1398
- const t = line.trim()
1399
- if (isGitObjectId(root, t)) { cur = t; continue }
1400
- const n = line.match(/^(\d+|-)\t(\d+|-)\t/)
1401
- if (n && cur) {
1402
- const s = m.get(cur) ?? { additions: 0, deletions: 0, files: 0 }
1403
- s.files++; s.additions += n[1] === '-' ? 0 : +n[1]; s.deletions += n[2] === '-' ? 0 : +n[2]
1404
- m.set(cur, s)
1405
- }
1406
- }
1407
- return m
1408
- }
1409
-
1410
- // History display stats are intentionally read only for the selected node: their text interpretation may
1411
- // depend on working-tree attributes, so they cannot live in the shared immutable event ledger. One diff-tree
1412
- // batch preserves every selected commit's historical path without turning a history page into N processes.
1413
- export async function historyStats(root: string, idx: HistoryIndex, relPath: string): Promise<Map<string, DiffStat>> {
1414
- const rows = rowsFor(idx, relPath)
1415
- if (!rows.length) return new Map()
1416
- const paths = new Map(rows.map((row) => [row.hash, idx.versionPaths.get(`${relPath}\0${row.hash}`) ?? relPath]))
1417
- const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--stdin', '--root', '-r', '--numstat', '-M', '-l0', `--format=${RS}%H`], `${rows.map((row) => row.hash).join('\n')}\n`)
1418
- const stats = new Map<string, DiffStat>()
1419
- for (const record of out.split(RS)) {
1420
- const lines = record.replace(/^\n/, '').split('\n')
1421
- const hash = lines.shift()?.trim() ?? ''
1422
- const path = paths.get(hash)
1423
- if (!path) continue
1424
- const stat = { additions: 0, deletions: 0, files: 0 }
1425
- for (const line of lines) {
1426
- const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/)
1427
- if (!match || parseStatPath(match[3]).to !== path) continue
1428
- stat.files++; stat.additions += match[1] === '-' ? 0 : Number(match[1]); stat.deletions += match[2] === '-' ? 0 : Number(match[2])
1429
- }
1430
- stats.set(hash, stat)
1431
- }
1432
- return stats
1433
- }
1434
-
1435
- // the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
1436
- // via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
1437
- export async function fileDiffAt(root: string, relPath: string, hash: string): Promise<string> {
1438
- if (!hash || !relPath.endsWith('/spec.md')) return ''
1439
- const leaf = relPath.slice(relPath.lastIndexOf('/', relPath.length - '/spec.md'.length - 1) + 1) // `<id>/spec.md`
1440
- const names = await gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '--name-only', '--format=', '-M', hash])
1441
- const at = names.split('\n').map((s) => s.trim()).find((p) => p.endsWith('/' + leaf) || p === leaf) ?? relPath
1442
- return gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '-M', '--format=', hash, '--', at])
1443
- }
1444
-
1445
- // A `git log` over HEAD, enriched with parent edges so "newer than
1446
- // the spec" is answered by true DAG reachability, never by a log-position/date compare (a linear
1447
- // order can't encode a branching history's partial order and silently under-reports — back-dated
1448
- // branches, adoption). driftFor()/ancestorsOf() are then pure in-memory lookups. `acks`/`specNodes`
1449
- // carry the Spec-OK convention (see driftFor): acks[hash] = node ids declared still-valid via
1450
- // `Spec-OK:` trailers; specNodes[hash] = node ids whose spec.md it touched.
1451
- export type DriftIndex = {
1452
- tip?: string
1453
- ord: Map<string, number> // hash -> dense id from the walk: a bitset slot, NEVER an order to compare
1454
- parents: Map<string, string[]> // hash -> parent hashes (the DAG edges, from the same walk)
1455
- fileEvents: Map<string, DriftPathEvent[]>
1456
- lineageEvents: Map<string, DriftPathEvent[]> // immutable events keyed by terminal rename identity, present or deleted
1457
- lineageKeys: (path: string, revision: string) => string[]
1458
- resolutionEvents?: Map<string, DriftPathEvent[]> // merge-authored all-parent lines projected to this path
1459
- acks: Map<string, Set<string>> // tree-unchanged checkpoint hash -> node ids acknowledged via `Spec-OK:`
1460
- selfAcks?: Map<string, Set<string>> // content commit hash -> node ids acknowledged for that commit only
1461
- specNodes: Map<string, Set<string>> // commit hash -> node ids whose spec.md it touched (its versions)
1462
- anc: Map<string, Uint8Array> // memoized reachability bitsets, lazily built per queried sha
1463
- }
1464
- export type DriftPathEvent = {
1465
- commit: string
1466
- historicalPath: string
1467
- parents: { commit: string; historicalPath: string }[]
1468
- }
1469
-
1470
- export type DiffLineRange = [number, number]
1471
- export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
1472
-
1473
- function decodeGitCPath(value: string): string {
1474
- if (!value.startsWith('"')) return value
1475
- if (value.length < 2 || !value.endsWith('"')) throw new Error(`malformed Git C-quoted path '${value}'`)
1476
- const bytes: number[] = []
1477
- const plain = (part: string) => bytes.push(...Buffer.from(part, 'utf8'))
1478
- for (let index = 1; index < value.length - 1;) {
1479
- const point = value.codePointAt(index)!
1480
- const char = String.fromCodePoint(point)
1481
- index += char.length
1482
- if (char !== '\\') { plain(char); continue }
1483
- const escaped = value[index++]
1484
- if (escaped === undefined) throw new Error(`malformed Git C-quoted path '${value}'`)
1485
- const simple: Record<string, number> = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 }
1486
- if (escaped in simple) { bytes.push(simple[escaped]); continue }
1487
- const octal = `${escaped}${value.slice(index, index + 2)}`
1488
- if (!/^[0-7]{3}$/.test(octal)) throw new Error(`malformed Git C-quoted path '${value}'`)
1489
- bytes.push(Number.parseInt(octal, 8)); index += 2
1490
- }
1491
- return Buffer.from(bytes).toString('utf8')
1492
- }
1493
-
1494
- // Dense combined diff prefixes have one column per parent. Ownership is a LINE fact, not a hunk fact:
1495
- // all `+` means the result authored a line absent from every parent; all `-` means it deleted a line present
1496
- // in every parent. Mixed columns inherit from at least one parent. Track every cursor through all displayed
1497
- // lines so adjacent mixed/owned rows never widen one another.
1498
- export function combinedDiffOwnedChanges(patch: string): Map<string, CombinedDiffOwnedChanges> {
1499
- const byPath = new Map<string, CombinedDiffOwnedChanges>()
1500
- let path: string | null = null
1501
- let parents = 0
1502
- let parentLines: number[] = []
1503
- let parentPaths: string[] = []
1504
- let resultLine = 0
1505
- let inHunk = false
1506
-
1507
- for (const line of patch.split('\n')) {
1508
- if (line.startsWith('diff --cc ')) {
1509
- path = decodeGitCPath(line.slice('diff --cc '.length))
1510
- parents = 0
1511
- parentPaths = []
1512
- inHunk = false
1513
- continue
1514
- }
1515
- if (!inHunk && path !== null && line.startsWith('--- ')) {
1516
- const raw = decodeGitCPath(line.slice(4))
1517
- parentPaths.push(raw === '/dev/null' ? path : raw.replace(/^a\//, ''))
1518
- continue
1519
- }
1520
- const header = line.match(/^(@{3,}) ((?:-\d+(?:,\d+)? )+)\+(\d+)(?:,\d+)? @+/)
1521
- if (header) {
1522
- parents = header[1].length - 1
1523
- parentLines = [...header[2].matchAll(/-(\d+)/g)].map((match) => Number(match[1]))
1524
- resultLine = Number(header[3])
1525
- inHunk = path !== null && parents >= 2 && parentLines.length === parents
1526
- continue
1527
- }
1528
- if (!inHunk || path === null || line.startsWith('\')) continue
1529
- const prefix = line.slice(0, parents)
1530
- if (prefix.length !== parents || !/^[ +\-]+$/.test(prefix)) {
1531
- inHunk = false
1532
- continue
1533
- }
1534
-
1535
- const authoredAfter = [...prefix].every((c) => c === '+')
1536
- const authoredBefore = [...prefix].every((c) => c === '-')
1537
- if ((authoredAfter || authoredBefore) && parentPaths.length !== parents)
1538
- throw new Error(`combined diff for '${path}' declared ${parents} parents but exposed ${parentPaths.length} parent paths`)
1539
- const changes = byPath.get(path) ?? {
1540
- after: [],
1541
- before: Array.from({ length: parents }, () => []),
1542
- parentPaths: [...parentPaths],
1543
- }
1544
- if (authoredAfter) changes.after.push([Math.max(1, resultLine), Math.max(1, resultLine)])
1545
- if (authoredBefore) {
1546
- for (let parent = 0; parent < parents; parent++) {
1547
- const point = Math.max(1, parentLines[parent])
1548
- changes.before[parent].push([point, point])
1549
- }
1550
- }
1551
- if (authoredAfter || authoredBefore) byPath.set(path, changes)
1552
- const resultExists = prefix.includes('+') || [...prefix].every((c) => c === ' ')
1553
- for (let parent = 0; parent < parents; parent++)
1554
- if (prefix[parent] === '-' || (prefix[parent] === ' ' && resultExists)) parentLines[parent]++
1555
- if (resultExists) resultLine++
1556
- }
1557
- return byPath
1558
- }
1559
- type MergeResolutionEvent = { hash: string; parentPaths: string[] }
1560
- type MergeRenameEvent = { hash: string; from: string; to: string }
1561
- type MergeHistoryEvents = { resolutions: Map<string, MergeResolutionEvent[]>; renames: MergeRenameEvent[] }
1562
- function parseMergeHistoryEvents(out: string): MergeHistoryEvents {
1563
- const resolutions = new Map<string, MergeResolutionEvent[]>()
1564
- const renames: MergeRenameEvent[] = []
1565
- for (const rec of out.split(RS)) {
1566
- const normalized = rec.replace(/^\n/, '')
1567
- const newline = normalized.indexOf('\n')
1568
- const hash = (newline < 0 ? normalized : normalized.slice(0, newline)).trim()
1569
- if (!hash) continue
1570
- const patch = newline < 0 ? '' : normalized.slice(newline + 1)
1571
- for (const line of patch.split('\n')) {
1572
- const raw = line.match(/^(:{2,})[0-7]{6}(?: [0-7]{6})+ (?:[0-9a-f]+ )+[A-Z]+\t/)
1573
- if (!raw) continue
1574
- const parentCount = raw[1].length
1575
- const fields = line.split('\t')
1576
- const status = fields[0].trim().split(/\s+/).at(-1) ?? ''
1577
- const paths = fields.slice(1).map(decodeGitCPath)
1578
- if (status.length !== parentCount || paths.length !== parentCount + 1)
1579
- throw new Error(`combined raw diff for ${hash} exposed ${status.length} statuses and ${paths.length} paths for ${parentCount} parents`)
1580
- const resultPath = paths[parentCount]
1581
- for (let parent = 0; parent < parentCount; parent++) {
1582
- if (status[parent] !== 'R' || paths[parent] === resultPath) continue
1583
- renames.push({ hash, from: paths[parent], to: resultPath })
1584
- }
1585
- }
1586
- for (const [path, changes] of combinedDiffOwnedChanges(patch)) {
1587
- const events = resolutions.get(path) ?? []
1588
- events.push({ hash, parentPaths: changes.parentPaths })
1589
- resolutions.set(path, events)
1590
- }
1591
- }
1592
- return { resolutions, renames }
1593
- }
1594
-
1595
- async function mergeHistoryEvents(
1596
- root: string,
1597
- tip: string,
1598
- transient: boolean,
1599
- order: Map<string, number>,
1600
- reachable: Set<string>,
1601
- useCache = true,
1602
- ): Promise<MergeHistoryEvents> {
1603
- if (!useCache) return parseMergeHistoryEvents(await strictEventGit(['-C', root, '-c', 'core.quotePath=false',
1604
- 'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M', `--format=${RS}%H`, tip]))
1605
- return parseMergeHistoryEvents(await textEventStream(root, tip,
1606
- indexEventRequests(root, tip, order, reachable).merge, !transient, useCache))
1607
- }
1608
-
1609
- async function buildDriftIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<DriftIndex> {
1610
- const ord = new Map<string, number>(), parents = new Map<string, string[]>()
1611
- const fileEvents = new Map<string, DriftPathEvent[]>()
1612
- const lineageEvents = new Map<string, DriftPathEvent[]>()
1613
- const acks = new Map<string, Set<string>>(), selfAcks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
1614
- const ackCandidates = new Map<string, Set<string>>(), ackCheckpoints = new Map<string, boolean>()
1615
- const idx: DriftIndex = { tip, ord, parents, fileEvents, lineageEvents, lineageKeys: (path) => [path], resolutionEvents: new Map(), acks, selfAcks, specNodes, anc: new Map() }
1616
- let currentPaths: Set<string>
1617
- let topology: TopologyProjection
1618
- if (shared) {
1619
- currentPaths = shared.allPaths
1620
- topology = shared.topology
1621
- } else {
1622
- const [tipPathsOut, topologyOut] = await Promise.all([
1623
- strictEventGit(['-C', root, 'ls-tree', '-r', '-z', '--name-only', tip]),
1624
- strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1625
- ])
1626
- currentPaths = new Set(tipPathsOut.split('\0').filter(Boolean))
1627
- topology = topologyProjection(topologyOut)
1628
- }
1629
- const topologyOrder = topology.order
1630
- const topologyParents = topology.parents
1631
- const topologyReachable = topology.reachable
1632
- for (const [hash, position] of topologyOrder) {
1633
- ord.set(hash, position)
1634
- parents.set(hash, topologyParents.get(hash) ?? [])
1635
- }
1636
- // Raw identity records path pairs and immutable object ids once; projection owns forks and reuse.
1637
- const records = shared?.identityRecords ?? await identityRawEventStream(root, tip,
1638
- indexEventRequests(root, tip, topologyOrder, topologyReachable)['identity-raw'], !transient, useCache)
1639
- if (!records.length) return idx
1640
- const rawFileEvents = new Map<string, DriftPathEvent[]>()
1641
- const renamesByFrom = new Map<string, RenameProjectionEvent[]>()
1642
- for (const record of records) {
1643
- const { h: hash, a: ackStr, c: changes } = record
1644
- const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
1645
- if (ackSet.size) {
1646
- ackCandidates.set(hash, ackSet)
1647
- ackCheckpoints.set(hash, changes.length === 0)
1648
- }
1649
- const merge = (parents.get(hash) ?? []).length > 1
1650
- for (const [, from, to] of changes) {
1651
- if (!merge) {
1652
- const events = rawFileEvents.get(to) ?? []
1653
- const event: DriftPathEvent = {
1654
- commit: hash,
1655
- historicalPath: to,
1656
- parents: (parents.get(hash) ?? []).slice(0, 1).map((commit) => ({ commit, historicalPath: from })),
1657
- }
1658
- events.push(event)
1659
- rawFileEvents.set(to, events)
1660
- }
1661
- if (from !== to) {
1662
- const renames = renamesByFrom.get(from) ?? []
1663
- renames.push({ hash, to })
1664
- renamesByFrom.set(from, renames)
1665
- }
1666
- }
1667
- }
1668
- const mergeIndex = shared?.mergeIndex
1669
- ?? await mergeHistoryEvents(root, tip, transient, topologyOrder, topologyReachable, useCache)
1670
- for (const rename of mergeIndex.renames) {
1671
- const renames = renamesByFrom.get(rename.from) ?? []
1672
- renames.push({ hash: rename.hash, to: rename.to })
1673
- renamesByFrom.set(rename.from, renames)
1674
- }
1675
- const canonical = canonicalPathProjector(renamesByFrom, topology)
1676
- idx.lineageKeys = canonical
1677
- // One projection per event serves both the lineage index and the current-path index; asking the
1678
- // projector the same question twice per event only pays for it twice.
1679
- const addEvent = (keys: string[], event: DriftPathEvent, target: Map<string, DriftPathEvent[]>) => {
1680
- for (const key of keys) {
1681
- const events = target.get(key) ?? []
1682
- if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1683
- target.set(key, events)
1684
- }
1685
- }
1686
- for (const [path, rawEvents] of rawFileEvents) for (const event of rawEvents) {
1687
- const keys = canonical(path, event.commit)
1688
- addEvent(keys, event, lineageEvents)
1689
- for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
1690
- const events = fileEvents.get(head) ?? []
1691
- if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1692
- fileEvents.set(head, events)
1693
- if (isSpecMd(head)) {
1694
- const nodes = specNodes.get(event.commit) ?? new Set<string>()
1695
- nodes.add(nodeIdOf(head))
1696
- specNodes.set(event.commit, nodes)
1697
- }
1698
- }
1699
- }
1700
- for (const [hash, nodes] of ackCandidates) {
1701
- const parentList = parents.get(hash) ?? []
1702
- const firstParent = parentList[0]
1703
- // A non-merge commit with no raw identity entries has the same tree as its sole parent.
1704
- const checkpoint = parentList.length === 1 && !!firstParent && ackCheckpoints.get(hash) === true
1705
- ;(checkpoint ? acks : selfAcks).set(hash, nodes)
1706
- }
1707
- for (const [path, mergeEvents] of mergeIndex.resolutions) for (const mergeEvent of mergeEvents) {
1708
- const mergeParents = topologyParents.get(mergeEvent.hash) ?? []
1709
- if (mergeParents.length !== mergeEvent.parentPaths.length)
1710
- throw new Error(`merge ${mergeEvent.hash} has ${mergeParents.length} topology parents but ${mergeEvent.parentPaths.length} combined parent paths`)
1711
- const event: DriftPathEvent = {
1712
- commit: mergeEvent.hash,
1713
- historicalPath: path,
1714
- parents: mergeParents.map((commit, index) => ({ commit, historicalPath: mergeEvent.parentPaths[index] })),
1715
- }
1716
- const keys = canonical(path, mergeEvent.hash)
1717
- addEvent(keys, event, lineageEvents)
1718
- for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
1719
- const events = idx.resolutionEvents!.get(head) ?? []
1720
- if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1721
- idx.resolutionEvents!.set(head, events)
1722
- if (isSpecMd(head)) {
1723
- const nodes = specNodes.get(mergeEvent.hash) ?? new Set<string>()
1724
- nodes.add(nodeIdOf(head))
1725
- specNodes.set(mergeEvent.hash, nodes)
1726
- }
1727
- }
1728
- }
1729
- return idx
1730
- }
1731
- export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
1732
- if (tip !== 'HEAD') {
1733
- const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
1734
- if (pendingOnlyIssues(root, resolved)) {
1735
- const parent = pendingParent(root, resolved)
1736
- if (parent) {
1737
- const head = headOrEmpty(root)
1738
- if (head === parent) return driftIndex(root)
1739
- return buildDriftIndex(root, parent, true, true)
1740
- }
1741
- }
1742
- return buildDriftIndex(root, resolved, true, true)
1743
- }
1744
- const head = headOrEmpty(root) // filesystem HEAD, no subprocess — see historyIndex
1745
- if (!head) return buildDriftIndex(root, 'HEAD', false, true)
1746
- const cacheKey = indexCacheKey(root, head)
1747
- touchRoot(driftRoots, driftIdxCache, root, cacheKey)
1748
- const hit = driftIdxCache.get(cacheKey)
1749
- if (hit) return hit
1750
- const p = buildDriftIndex(root, head.startsWith('unborn:') ? 'HEAD' : head, false, true)
1751
- p.catch(() => { dropFailed(driftIdxCache, cacheKey, p) })
1752
- driftIdxCache.set(cacheKey, p)
1753
- return p
1754
- }
1755
-
1756
- function topologyProjection(out: string): TopologyProjection {
1757
- const order = new Map<string, number>(), parents = new Map<string, string[]>(), reachable = new Set<string>()
1758
- let position = 0
1759
- for (const line of out.trim().split('\n')) {
1760
- const [hash, ...parentList] = line.split(' ')
1761
- if (!hash) continue
1762
- order.set(hash, position++)
1763
- parents.set(hash, parentList)
1764
- reachable.add(hash)
1765
- }
1766
- return { order, parents, reachable }
1767
- }
1768
-
1769
- function textStream(value: EventStreamOutput | undefined, kind: EventStreamKind): string {
1770
- if (typeof value !== 'string') throw new Error(`history event stream '${kind}' did not render text`)
1771
- return value
1772
- }
1773
- function identityRawStream(value: EventStreamOutput | undefined, kind: EventStreamKind): IdentityRawRecord[] {
1774
- if (!Array.isArray(value) || value.some((record) => !('a' in record))) throw new Error(`history event stream '${kind}' did not render identity records`)
1775
- return value as IdentityRawRecord[]
1776
- }
1777
- async function buildIndexPair(root: string, tip: string, transient: boolean, useCache = true): Promise<[HistoryIndex, DriftIndex]> {
1778
- const [allPathsOut, topologyOut] = await Promise.all([
1779
- strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
1780
- strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1781
- ])
1782
- const topology = topologyProjection(topologyOut)
1783
- const allPaths = new Set(allPathsOut.split('\0').filter(Boolean))
1784
- const specPaths = new Set([...allPaths].filter((path) => path.startsWith('.spec/')))
1785
- const requests = indexEventRequests(root, tip, topology.order, topology.reachable)
1786
- const streams = await deriveEventStreams(root, tip, EVENT_STREAM_KINDS.map((kind) => requests[kind]), !transient, useCache)
1787
- const shared: SharedIndexInputs = {
1788
- allPaths,
1789
- specPaths,
1790
- topology,
1791
- identityRecords: identityRawStream(streams.get('identity-raw'), 'identity-raw'),
1792
- mergeIndex: parseMergeHistoryEvents(textStream(streams.get('merge'), 'merge')),
1793
- }
1794
- streams.clear()
1795
- return Promise.all([
1796
- buildIndex(root, tip, transient, useCache, shared),
1797
- buildDriftIndex(root, tip, transient, useCache, shared),
1798
- ])
1799
- }
1800
-
1801
- // Standing correctness oracle: same projection, but every immutable stream comes from Git root history.
1802
- export function sourceIndexesFull(root: string, tip = 'HEAD'): Promise<[HistoryIndex, DriftIndex]> {
1803
- const head = tip === 'HEAD' ? headOrEmpty(root) : ''
1804
- const resolved = tip === 'HEAD'
1805
- ? (!head || head.startsWith('unborn:') ? 'HEAD' : head)
1806
- : git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
1807
- return buildIndexPair(root, resolved, true, false)
1808
- }
1809
-
1810
- // History and drift are one product projection. Building them together is what gives one lint one ledger
1811
- // transaction; the public single-index functions remain for consumers that genuinely need only one side.
1812
- export function sourceIndexes(root: string, tip = 'HEAD'): Promise<[HistoryIndex, DriftIndex]> {
1813
- if (tip !== 'HEAD') {
1814
- const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
1815
- if (pendingOnlyIssues(root, resolved)) {
1816
- const parent = pendingParent(root, resolved)
1817
- if (parent) return headOrEmpty(root) === parent
1818
- ? sourceIndexes(root)
1819
- : buildIndexPair(root, parent, true, true)
1820
- }
1821
- return buildIndexPair(root, resolved, true, true)
1822
- }
1823
- const head = headOrEmpty(root)
1824
- if (!head) return buildIndexPair(root, 'HEAD', false, true)
1825
- const cacheKey = indexCacheKey(root, head)
1826
- touchRoot(indexRoots, indexCache, root, cacheKey)
1827
- touchRoot(driftRoots, driftIdxCache, root, cacheKey)
1828
- const historyHit = indexCache.get(cacheKey), driftHit = driftIdxCache.get(cacheKey)
1829
- if (historyHit && driftHit) return Promise.all([historyHit, driftHit])
1830
-
1831
- const pair = buildIndexPair(root, head.startsWith('unborn:') ? 'HEAD' : head, false, true)
1832
- const historyPromise = pair.then(([history]) => history)
1833
- const driftPromise = pair.then(([, drift]) => drift)
1834
- historyPromise.catch(() => { dropFailed(indexCache, cacheKey, historyPromise) })
1835
- driftPromise.catch(() => { dropFailed(driftIdxCache, cacheKey, driftPromise) })
1836
- indexCache.set(cacheKey, historyPromise)
1837
- driftIdxCache.set(cacheKey, driftPromise)
1838
- return pair
1839
- }
1840
-
1841
- export function historyCacheStats(): { historyHeads: number; driftHeads: number; historyRoots: number; driftRoots: number } {
1842
- return { historyHeads: indexCache.size, driftHeads: driftIdxCache.size, historyRoots: indexRoots.size, driftRoots: driftRoots.size }
1843
- }
1844
- // Tests deliberately clear process-local promises and path identity to exercise both cold and read-back paths.
1845
- // Production callers retain the bounded index caches; this is not a correctness escape hatch.
1846
- export function resetHistoryCachesForTests(): void {
1847
- indexCache.clear(); driftIdxCache.clear(); indexRoots.clear(); driftRoots.clear()
1848
- eventPathMemo.clear(); gitObjectFormatMemo.clear()
1849
- }
1850
- export function historyEventCachePathForTests(root: string): string { return eventCacheLocation(root).path }
1851
- // @@@ one ordinary ancestry memo, with a batch entrance - every closure below has exactly the same dense
1852
- // bitset shape in idx.anc. A caller that already knows several bases can prime them in one child-to-parent
1853
- // topology pass; the short-lived frontier is released before returning. Unknown or ad-hoc revisions retain
1854
- // this direct parent DFS, so neither path introduces a second reachability representation or persistent fact.
1855
- // undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
1856
- // ref) — callers apply their own conservative rule to that "can't prove" case.
1857
- export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefined {
1858
- const hit = idx.anc.get(sha)
1859
- if (hit) return hit
1860
- const start = idx.ord.get(sha)
1861
- if (start === undefined) return undefined
1862
- const bits = new Uint8Array((idx.ord.size + 7) >> 3)
1863
- bits[start >> 3] |= 1 << (start & 7)
1864
- const stack = [sha]
1865
- while (stack.length) {
1866
- for (const p of idx.parents.get(stack.pop()!) ?? []) {
1867
- const o = idx.ord.get(p)
1868
- if (o === undefined) continue // shallow-clone boundary: an unwalked parent ends the chain
1869
- const m = 1 << (o & 7)
1870
- if (bits[o >> 3] & m) continue
1871
- bits[o >> 3] |= m
1872
- stack.push(p)
1873
- }
1874
- }
1875
- idx.anc.set(sha, bits)
1876
- return bits
1877
- }
1878
-
1879
- // Fill the existing ancestry memo for a known roster in one child-before-parent topology pass. At each
1880
- // commit the transient row names requested descendants; emitting those bits into the ordinary closures makes
1881
- // the resulting bytes identical to calling ancestorsOf() independently for every requested SHA.
1882
- export function primeAncestorClosures(idx: DriftIndex, shas: Iterable<string>): void {
1883
- const endpoints = [...new Set(shas)].filter((sha) => !idx.anc.has(sha) && idx.ord.has(sha))
1884
- if (!endpoints.length) return
1885
- const count = idx.ord.size
1886
- const width = (endpoints.length + 7) >> 3
1887
- const closureWidth = (count + 7) >> 3
1888
- const endpointAt = new Map(endpoints.map((sha, position) => [sha, position]))
1889
- const hashAt = new Array<string>(count)
1890
- for (const [hash, position] of idx.ord) hashAt[position] = hash
1891
-
1892
- // Every row waits until all of its children have contributed. Parents outside the walked topology are
1893
- // deliberately absent: they are the same shallow boundary ancestorsOf() stops at.
1894
- const childrenLeft = new Int32Array(count)
1895
- for (const parents of idx.parents.values()) for (const parent of parents) {
1896
- const position = idx.ord.get(parent)
1897
- if (position !== undefined) childrenLeft[position]++
1898
- }
1899
- const ready: number[] = []
1900
- for (let position = 0; position < count; position++) if (childrenLeft[position] === 0) ready.push(position)
1901
-
1902
- const closures = endpoints.map(() => new Uint8Array(closureWidth))
1903
- const rows = new Map<number, Uint8Array>(), pool: Uint8Array[] = []
1904
- const rowFor = (position: number): Uint8Array => {
1905
- const existing = rows.get(position)
1906
- if (existing) return existing
1907
- const row = pool.pop() ?? new Uint8Array(width)
1908
- rows.set(position, row)
1909
- return row
1910
- }
1911
- let visited = 0
1912
- while (ready.length) {
1913
- const position = ready.pop()!
1914
- visited++
1915
- const row = rowFor(position)
1916
- rows.delete(position)
1917
- const endpoint = endpointAt.get(hashAt[position])
1918
- if (endpoint !== undefined) row[endpoint >> 3] |= 1 << (endpoint & 7)
1919
- for (let byte = 0; byte < width; byte++) {
1920
- let pending = row[byte]
1921
- while (pending) {
1922
- const bit = 31 - Math.clz32(pending & -pending)
1923
- closures[(byte << 3) + bit][position >> 3] |= 1 << (position & 7)
1924
- pending &= pending - 1
1925
- }
1926
- }
1927
- for (const parent of idx.parents.get(hashAt[position]) ?? []) {
1928
- const parentPosition = idx.ord.get(parent)
1929
- if (parentPosition === undefined) continue
1930
- const target = rowFor(parentPosition)
1931
- for (let byte = 0; byte < width; byte++) target[byte] |= row[byte]
1932
- if (--childrenLeft[parentPosition] === 0) ready.push(parentPosition)
1933
- }
1934
- row.fill(0)
1935
- pool.push(row)
1936
- }
1937
- if (visited !== count)
1938
- throw new Error(`cannot prime ancestry closures: topology yielded ${visited} of ${count} reachable commits`)
1939
- for (let position = 0; position < endpoints.length; position++) idx.anc.set(endpoints[position], closures[position])
1940
- }
1941
- export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boolean {
1942
- const o = idx.ord.get(sha)
1943
- return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
1944
- }
1945
-
1946
- // @@@ reachability is membership, not a closure - `ancestorsOf` returns undefined for EXACTLY the shas
1947
- // absent from `idx.ord` (both writers of `idx.anc` gate on ord: the single-sha path after its ord lookup
1948
- // succeeds, the batch after an explicit `ord.has` filter), so asking it here answered a hash-table
1949
- // question by walking the whole parent DAG and allocating an ord.size-wide bitset per distinct sha —
1950
- // per eval reading, on every board build.
1951
- export function commitReachable(idx: DriftIndex, sha: string): boolean {
1952
- return idx.ord.has(sha)
1953
- }
1954
-
1955
- // the valid Spec-OK coverage for a node's version commit: `sinceHash` is the node's OWN latest version,
1956
- // so the node(s) it's a version of (specNodes[sinceHash]) name the node being measured; an ack counts
1957
- // only if its `Spec-OK:` set names one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that
1958
- // is itself an ancestor of the version can't speak for it (a re-version invalidates older acks); a valid
1959
- // ack quiets exactly the commits reachable from it. Shared by driftFor (the count) and the anchor
1960
- // engine's windowCommits (the commit set) so both read ONE ack rule.
1961
- function targetNodes(idx: DriftIndex, sinceHash: string, nodeId?: string): Set<string> | undefined {
1962
- return nodeId ? new Set([nodeId]) : idx.specNodes.get(sinceHash)
1963
- }
1964
-
1965
- export function ackCoverFor(idx: DriftIndex, sinceHash: string, nodeId?: string): Uint8Array[] {
1966
- const base = ancestorsOf(idx, sinceHash)
1967
- if (!base) return []
1968
- const targets = targetNodes(idx, sinceHash, nodeId)
1969
- const cover: Uint8Array[] = []
1970
- if (targets) {
1971
- for (const [h, ackSet] of idx.acks) {
1972
- if (inAncestors(idx, base, h)) continue
1973
- if (![...targets].some((t) => ackSet.has(t))) continue
1974
- const a = ancestorsOf(idx, h)
1975
- if (a) cover.push(a)
1976
- }
1977
- }
1978
- return cover
1979
- }
1980
-
1981
- export function selfAckCovers(idx: DriftIndex, sinceHash: string, hash: string, nodeId?: string): boolean {
1982
- const targets = targetNodes(idx, sinceHash, nodeId)
1983
- const declared = idx.selfAcks?.get(hash)
1984
- return !!targets && !!declared && [...targets].some((node) => declared.has(node))
1985
- }
1986
-
1987
- export function pathEvents(idx: DriftIndex, path: string): DriftPathEvent[] {
1988
- return [...(idx.fileEvents.get(path) ?? []), ...(idx.resolutionEvents?.get(path) ?? [])]
1989
- }
1990
-
1991
- // Exact base..tip window for consumers whose subject is not a spec version. The path is an identity at
1992
- // `identityRevision`; projecting it and every immutable event to the same terminal lineage keys preserves
1993
- // rename chains, parallel forks, path reuse, and lineages deleted before the tip.
1994
- export function pathRangeEvents(idx: DriftIndex, sinceHash: string, path: string, identityRevision = idx.tip ?? sinceHash): DriftPathEvent[] | null {
1995
- const base = ancestorsOf(idx, sinceHash)
1996
- if (!base) return null
1997
- const seen = new Set<string>()
1998
- const events = idx.lineageKeys(path, identityRevision).flatMap((key) => idx.lineageEvents.get(key) ?? [])
1999
- return events.filter((event) => {
2000
- const key = `${event.commit}\0${event.historicalPath}\0${event.parents.map((parent) => `${parent.commit}:${parent.historicalPath}`).join('\x1e')}`
2001
- if (seen.has(key) || inAncestors(idx, base, event.commit)) return false
2002
- seen.add(key)
2003
- return true
2004
- })
2005
- }
2006
-
2007
- // @@@ eventsSince - THE meaning of "this path changed since <sha>", in one place. A commit touching `path`
2008
- // lies in `sha..HEAD` exactly when it is NOT an ancestor of `sha` — true DAG reachability, wherever a
2009
- // date-ordered log happens to place it. `null` is the honest third answer: the anchor commit is not reachable
2010
- // (folded, rebased, cherry-picked away), so ancestry cannot testify at all and the caller must decide what to
2011
- // do about that — the spec layer reports no window, the eval layer falls back to comparing content.
2012
- // Callers add their OWN layer's decoration on top (ack cover is spec-only; the content probe is eval-only);
2013
- // what none of them may do is restate the reachability rule, which is how it came to exist four times.
2014
- export function eventsSince(idx: DriftIndex, sinceHash: string, path: string): DriftPathEvent[] | null {
2015
- const base = ancestorsOf(idx, sinceHash)
2016
- if (!base) return null
2017
- return pathEvents(idx, path).filter((event) => !inAncestors(idx, base, event.commit))
2018
- }
2019
-
2020
- export function driftPathWindow(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): DriftPathEvent[] | null {
2021
- const events = eventsSince(idx, sinceHash, path)
2022
- if (!events) return null
2023
- const cover = ackCoverFor(idx, sinceHash, nodeId)
2024
- return events.filter((event) => !cover.some((a) => inAncestors(idx, a, event.commit))
2025
- && !selfAckCovers(idx, sinceHash, event.commit, nodeId))
2026
- }
2027
-
2028
- // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
2029
- // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
2030
- // An off-history `sinceHash` → 0: no basis on HEAD to measure from.
2031
- export function driftFor(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): number {
2032
- if (!sinceHash) return 0
2033
- return new Set(driftPathWindow(idx, sinceHash, path, nodeId)?.map((event) => event.commit) ?? []).size
2034
- }
2035
-
2036
- // the paths git is about to commit (index vs HEAD), scoping the pre-commit drift gate to this commit's files.
2037
- export function stagedFiles(root: string): string[] {
2038
- try {
2039
- return git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--cached', '--name-only'])
2040
- .split('\n').map((s) => s.trim()).filter(Boolean)
2041
- } catch { return [] }
2042
- }
2043
-
2044
- // ---- pending worktree changes (the board's runtime overlay) ----
2045
-
2046
- // one pending change a worktree makes to a spec node vs main; committed = on the branch, dirty = uncommitted edits.
2047
- export type NodeOp = {
2048
- nodeId: string
2049
- op: 'added' | 'edited' | 'deleted' | 'moved'
2050
- path: string // the node's spec.md path (new path for moved/added, old for deleted)
2051
- fromPath?: string; toPath?: string // set for 'moved' (a reparent renames the spec.md path)
2052
- committed: boolean; dirty: boolean
2053
- }
2054
-
2055
- // node id = the directory holding the spec.md (basename of its parent dir). git always emits
2056
- // forward-slash paths, so split rather than node:path (which is backslash-y on Windows).
2057
- const nodeIdOf = (p: string): string => { const s = p.split('/'); return s[s.length - 2] ?? p }
2058
- const isSpecMd = (p: string): boolean => p.endsWith('/spec.md')
2059
-
2060
- // `git ... --name-status -M` rows: `A\tpath`, `M\tpath`, `D\tpath`, `R100\told\tnew`. Recover the
2061
- // status letter plus from/to (to === from for non-renames) so callers map letter -> op uniformly.
2062
- function parseNameStatus(out: string): { code: string; from: string; to: string }[] {
2063
- const rows: { code: string; from: string; to: string }[] = []
2064
- for (const line of out.split('\n')) {
2065
- if (!line) continue
2066
- const parts = line.split('\t')
2067
- const code = parts[0][0]
2068
- if ((code === 'R' || code === 'C') && parts.length >= 3) rows.push({ code, from: parts[1], to: parts[2] })
2069
- else rows.push({ code, from: parts[1], to: parts[1] })
2070
- }
2071
- return rows
2072
- }
2073
-
2074
- export type ReviewDiffFile = { path: string; oldPath?: string; status: string; additions: number; deletions: number }
2075
- const DIFF_STATUS: Record<string, string> = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' }
2076
- function parseStatPath(token: string): { from: string; to: string } {
2077
- const b = token.indexOf('{'), arrow = token.indexOf(' => ', b), close = token.indexOf('}', arrow)
2078
- if (b >= 0 && arrow > b && close > arrow) {
2079
- const pre = token.slice(0, b), post = token.slice(close + 1)
2080
- return { from: `${pre}${token.slice(b + 1, arrow)}${post}`.replace(/\/\//g, '/'), to: `${pre}${token.slice(arrow + 4, close)}${post}`.replace(/\/\//g, '/') }
2081
- }
2082
- const arrowAt = token.indexOf(' => ')
2083
- return arrowAt >= 0 ? { from: token.slice(0, arrowAt), to: token.slice(arrowAt + 4) } : { from: token, to: token }
2084
- }
2085
- export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
2086
- const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
2087
- const base = (await run(['merge-base', mainRef, 'HEAD'])).trim()
2088
- if (!base) return []
2089
- const [numstatOut, statusOut] = await Promise.all([
2090
- run(['diff', '--numstat', '-M', `${base}..HEAD`]),
2091
- run(['diff', '--name-status', '-M', `${base}..HEAD`]),
2092
- ])
2093
- const status = new Map<string, { status: string; from: string }>()
2094
- for (const r of parseNameStatus(statusOut)) status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from })
2095
- const files: ReviewDiffFile[] = []
2096
- for (const line of numstatOut.split('\n')) {
2097
- const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
2098
- if (!m) continue
2099
- const { from, to } = parseStatPath(m[3])
2100
- const detail = status.get(to)
2101
- files.push({
2102
- path: to,
2103
- ...(from !== to ? { oldPath: detail?.from ?? from } : {}),
2104
- status: detail?.status ?? 'modified',
2105
- additions: m[1] === '-' ? 0 : +m[1],
2106
- deletions: m[2] === '-' ? 0 : +m[2],
2107
- })
2108
- }
2109
- return files
2110
- }
2111
-
2112
- export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolean> {
2113
- return new Promise((resolve) => {
2114
- const env = { ...process.env }
2115
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
2116
- execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, 'HEAD'],
2117
- { encoding: 'utf8', env, maxBuffer: 1 << 24 },
2118
- // execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
2119
- // errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
2120
- (err) => resolve(!!err && err.code === 1))
2121
- })
2122
- }
2123
-
2124
- // this worktree's spec ops vs main ([[worktree-linker]]): an op must BOTH differ from main's current tip
2125
- // (the proposal — the vs-main working diff supplies the op set and each op's TYPE, so merge terms are
2126
- // spoken: content equal to main is no op, an existing node reads `edited` never `added`) AND have been
2127
- // touched by this branch since its fork point (attribution — main's own post-fork movement is not this
2128
- // worktree's op). A `status --porcelain` pass adds untracked spec.md, a third diff vs HEAD marks committed.
2129
- export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHint?: string): Promise<NodeOp[]> {
2130
- const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
2131
- // fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
2132
- // back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
2133
- // computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
2134
- const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
2135
- // the four queries are independent — run them in parallel.
2136
- const [mainOut, workOut, commOut, statusOut] = await Promise.all([
2137
- run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
2138
- run(['diff', '--name-status', '-M', base, '--', '.spec']),
2139
- run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
2140
- run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
2141
- ])
2142
- const proposals = parseNameStatus(mainOut)
2143
- // the branch's own footprint since its fork point — both sides of every row, so a rename matches
2144
- // whichever side the vs-main diff names.
2145
- const touched = new Set<string>()
2146
- for (const r of parseNameStatus(workOut)) { touched.add(r.to); if (r.from) touched.add(r.from) }
2147
- const committed = new Set(parseNameStatus(commOut).map((r) => r.to))
2148
- // --untracked-files=all: list every untracked spec.md individually (the default collapses a wholly
2149
- // new node's directory to `.spec/.../node/`, which we'd never recognise as a spec.md add).
2150
- const dirty = new Set<string>(), untracked: string[] = []
2151
- for (const line of statusOut.split('\n')) {
2152
- if (!line) continue
2153
- const xy = line.slice(0, 2)
2154
- let path = line.slice(3)
2155
- const arrow = path.indexOf(' -> '); if (arrow >= 0) path = path.slice(arrow + 4)
2156
- dirty.add(path)
2157
- if (xy === '??' && isSpecMd(path)) untracked.push(path)
2158
- }
2159
-
2160
- const codeFor: Record<string, NodeOp['op']> = { A: 'added', M: 'edited', D: 'deleted', R: 'moved', C: 'added', T: 'edited' }
2161
- const ops: NodeOp[] = [], seen = new Set<string>()
2162
- for (const r of proposals) {
2163
- const path = r.code === 'D' ? r.from : r.to
2164
- if (!isSpecMd(path)) continue
2165
- if (!touched.has(r.to) && !touched.has(r.from)) continue // main moved it, not this branch → no op
2166
- seen.add(path)
2167
- const op = codeFor[r.code] ?? 'edited'
2168
- ops.push({
2169
- nodeId: nodeIdOf(path), op, path,
2170
- ...(op === 'moved' ? { fromPath: r.from, toPath: r.to } : {}),
2171
- committed: committed.has(r.to) || committed.has(r.from),
2172
- dirty: dirty.has(path) || dirty.has(r.from),
2173
- })
2174
- }
2175
- for (const path of untracked) {
2176
- if (seen.has(path)) continue
2177
- ops.push({ nodeId: nodeIdOf(path), op: 'added', path, committed: false, dirty: true })
2178
- }
2179
- return ops
2180
- }