spexcode 0.6.2 → 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 (479) hide show
  1. package/README.md +3 -1
  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} +36 -32
  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 +1 -1
  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/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec-first.sh +1 -1
  167. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +1 -0
  168. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/spec.md +2 -2
  169. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +31 -29
  170. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +94 -0
  171. package/node_modules/@spexcode/spec-core/dist/anchors.js +730 -0
  172. package/node_modules/@spexcode/spec-core/dist/git.d.ts +166 -0
  173. package/node_modules/@spexcode/spec-core/dist/git.js +2736 -0
  174. package/node_modules/@spexcode/spec-core/dist/graph-delta.d.ts +44 -0
  175. package/node_modules/@spexcode/spec-core/dist/graph-delta.js +72 -0
  176. package/node_modules/@spexcode/spec-core/dist/graph.d.ts +34 -0
  177. package/node_modules/@spexcode/spec-core/dist/graph.js +237 -0
  178. package/node_modules/@spexcode/spec-core/dist/graphDelta.d.ts +3 -0
  179. package/node_modules/@spexcode/spec-core/dist/graphDelta.js +13 -0
  180. package/node_modules/@spexcode/spec-core/dist/harness-identity.d.ts +31 -0
  181. package/node_modules/@spexcode/spec-core/dist/harness-identity.js +20 -0
  182. package/node_modules/@spexcode/spec-core/dist/identity-presets.d.ts +152 -0
  183. package/node_modules/@spexcode/spec-core/dist/identity-presets.js +132 -0
  184. package/node_modules/@spexcode/spec-core/dist/index.d.ts +45 -0
  185. package/node_modules/@spexcode/spec-core/dist/index.js +19 -0
  186. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +183 -0
  187. package/node_modules/@spexcode/spec-core/dist/layout.js +548 -0
  188. package/node_modules/@spexcode/spec-core/dist/process-identity.d.ts +37 -0
  189. package/node_modules/@spexcode/spec-core/dist/process-identity.js +214 -0
  190. package/node_modules/@spexcode/spec-core/dist/project-identity.d.ts +12 -0
  191. package/node_modules/@spexcode/spec-core/dist/project-identity.js +71 -0
  192. package/node_modules/@spexcode/spec-core/dist/project-store.d.ts +3 -0
  193. package/node_modules/@spexcode/spec-core/dist/project-store.js +14 -0
  194. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +2 -0
  195. package/node_modules/@spexcode/spec-core/dist/resilience.js +40 -0
  196. package/node_modules/@spexcode/spec-core/dist/review/index.d.ts +3 -0
  197. package/node_modules/@spexcode/spec-core/dist/review/index.js +5 -0
  198. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +77 -0
  199. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +308 -0
  200. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +66 -0
  201. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +180 -0
  202. package/node_modules/@spexcode/spec-core/dist/review/session.d.ts +4 -0
  203. package/node_modules/@spexcode/spec-core/dist/review/session.js +8 -0
  204. package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.d.ts +15 -0
  205. package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.js +12 -0
  206. package/node_modules/@spexcode/spec-core/dist/root-lru.d.ts +4 -0
  207. package/{spec-cli/src/root-lru.ts → node_modules/@spexcode/spec-core/dist/root-lru.js} +26 -30
  208. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +117 -0
  209. package/node_modules/@spexcode/spec-core/dist/specs.js +489 -0
  210. package/node_modules/@spexcode/spec-core/package.json +28 -0
  211. package/node_modules/@spexcode/spec-eval/dist/cache.d.ts +10 -0
  212. package/node_modules/@spexcode/spec-eval/dist/cache.js +50 -0
  213. package/node_modules/@spexcode/spec-eval/dist/cli.d.ts +22 -0
  214. package/node_modules/@spexcode/spec-eval/dist/cli.js +981 -0
  215. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +98 -0
  216. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +176 -0
  217. package/node_modules/@spexcode/spec-eval/dist/filing.d.ts +16 -0
  218. package/node_modules/@spexcode/spec-eval/dist/filing.js +36 -0
  219. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +48 -0
  220. package/node_modules/@spexcode/spec-eval/dist/freshness.js +799 -0
  221. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +46 -0
  222. package/node_modules/@spexcode/spec-eval/dist/host.js +53 -0
  223. package/node_modules/@spexcode/spec-eval/dist/humanok.d.ts +11 -0
  224. package/node_modules/@spexcode/spec-eval/dist/humanok.js +27 -0
  225. package/node_modules/@spexcode/spec-eval/dist/index.d.ts +4 -0
  226. package/node_modules/@spexcode/spec-eval/dist/index.js +4 -0
  227. package/node_modules/@spexcode/spec-eval/dist/remarks.d.ts +31 -0
  228. package/node_modules/@spexcode/spec-eval/dist/remarks.js +1 -0
  229. package/node_modules/@spexcode/spec-eval/dist/scenariofresh.d.ts +13 -0
  230. package/node_modules/@spexcode/spec-eval/dist/scenariofresh.js +303 -0
  231. package/node_modules/@spexcode/spec-eval/dist/scenarios.d.ts +99 -0
  232. package/node_modules/@spexcode/spec-eval/dist/scenarios.js +650 -0
  233. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +307 -0
  234. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +1883 -0
  235. package/node_modules/@spexcode/spec-eval/dist/sidecar.d.ts +55 -0
  236. package/node_modules/@spexcode/spec-eval/dist/sidecar.js +82 -0
  237. package/node_modules/@spexcode/spec-eval/dist/timeline.d.ts +25 -0
  238. package/node_modules/@spexcode/spec-eval/dist/timeline.js +65 -0
  239. package/node_modules/@spexcode/spec-eval/dist/ui-path.d.ts +1 -0
  240. package/node_modules/@spexcode/spec-eval/dist/ui-path.js +2 -0
  241. package/node_modules/@spexcode/spec-eval/package.json +36 -0
  242. package/node_modules/@spexcode/spec-forge/dist/cache.d.ts +29 -0
  243. package/node_modules/@spexcode/spec-forge/dist/cache.js +59 -0
  244. package/node_modules/@spexcode/spec-forge/dist/cli.d.ts +1 -0
  245. package/node_modules/@spexcode/spec-forge/dist/cli.js +100 -0
  246. package/node_modules/@spexcode/spec-forge/dist/drivers/github.d.ts +2 -0
  247. package/node_modules/@spexcode/spec-forge/dist/drivers/github.js +116 -0
  248. package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.d.ts +7 -0
  249. package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.js +144 -0
  250. package/node_modules/@spexcode/spec-forge/dist/drivers.d.ts +10 -0
  251. package/node_modules/@spexcode/spec-forge/dist/drivers.js +83 -0
  252. package/node_modules/@spexcode/spec-forge/dist/index.d.ts +4 -0
  253. package/node_modules/@spexcode/spec-forge/dist/index.js +4 -0
  254. package/node_modules/@spexcode/spec-forge/dist/links.d.ts +15 -0
  255. package/node_modules/@spexcode/spec-forge/dist/links.js +65 -0
  256. package/node_modules/@spexcode/spec-forge/dist/needs-eval.d.ts +10 -0
  257. package/node_modules/@spexcode/spec-forge/dist/needs-eval.js +20 -0
  258. package/node_modules/@spexcode/spec-forge/dist/port.d.ts +53 -0
  259. package/node_modules/@spexcode/spec-forge/dist/port.js +1 -0
  260. package/node_modules/@spexcode/spec-forge/dist/resident.d.ts +7 -0
  261. package/node_modules/@spexcode/spec-forge/dist/resident.js +43 -0
  262. package/node_modules/@spexcode/spec-forge/package.json +32 -0
  263. package/package.json +33 -19
  264. package/spec-cli/bin/spex.mjs +0 -69
  265. package/spec-cli/src/anchors.ts +0 -728
  266. package/spec-cli/src/attach.ts +0 -43
  267. package/spec-cli/src/claude-headless.ts +0 -321
  268. package/spec-cli/src/cli.ts +0 -1258
  269. package/spec-cli/src/client.ts +0 -414
  270. package/spec-cli/src/cockpit.ts +0 -43
  271. package/spec-cli/src/codex-runtime-generations.ts +0 -646
  272. package/spec-cli/src/commit-surgery.ts +0 -68
  273. package/spec-cli/src/contract-filter.ts +0 -202
  274. package/spec-cli/src/delivery-queue.ts +0 -165
  275. package/spec-cli/src/doctor.ts +0 -516
  276. package/spec-cli/src/execution-trace.ts +0 -444
  277. package/spec-cli/src/file-write.ts +0 -22
  278. package/spec-cli/src/gateway-auth.ts +0 -181
  279. package/spec-cli/src/gateway-hub.ts +0 -358
  280. package/spec-cli/src/gateway.ts +0 -461
  281. package/spec-cli/src/git.ts +0 -2556
  282. package/spec-cli/src/graph.ts +0 -271
  283. package/spec-cli/src/graphCache.ts +0 -710
  284. package/spec-cli/src/graphDelta.ts +0 -77
  285. package/spec-cli/src/graphStream.ts +0 -1019
  286. package/spec-cli/src/harness-select.ts +0 -77
  287. package/spec-cli/src/harness.ts +0 -3025
  288. package/spec-cli/src/headless-controller.ts +0 -63
  289. package/spec-cli/src/help.ts +0 -492
  290. package/spec-cli/src/host-resources.ts +0 -762
  291. package/spec-cli/src/host.ts +0 -611
  292. package/spec-cli/src/identity-presets.d.ts +0 -13
  293. package/spec-cli/src/identity-presets.js +0 -138
  294. package/spec-cli/src/index.ts +0 -818
  295. package/spec-cli/src/init.ts +0 -258
  296. package/spec-cli/src/issues-cli.ts +0 -276
  297. package/spec-cli/src/issues.ts +0 -237
  298. package/spec-cli/src/layout.ts +0 -640
  299. package/spec-cli/src/lint.ts +0 -488
  300. package/spec-cli/src/localIssues.ts +0 -538
  301. package/spec-cli/src/materialize.ts +0 -417
  302. package/spec-cli/src/mentions.ts +0 -81
  303. package/spec-cli/src/opencode-headless.ts +0 -205
  304. package/spec-cli/src/pi-headless.ts +0 -153
  305. package/spec-cli/src/plugin-harness.ts +0 -157
  306. package/spec-cli/src/process-identity.ts +0 -207
  307. package/spec-cli/src/project-identity.ts +0 -73
  308. package/spec-cli/src/project-store.ts +0 -17
  309. package/spec-cli/src/pty-bridge.ts +0 -396
  310. package/spec-cli/src/pty-helper.mjs +0 -96
  311. package/spec-cli/src/pty-native-helper.mjs +0 -22
  312. package/spec-cli/src/ranker.ts +0 -191
  313. package/spec-cli/src/reaper.ts +0 -125
  314. package/spec-cli/src/resilience.ts +0 -41
  315. package/spec-cli/src/reviewSnapshot.ts +0 -28
  316. package/spec-cli/src/reviews.ts +0 -434
  317. package/spec-cli/src/runtime-guard.ts +0 -35
  318. package/spec-cli/src/runtime-ownership.ts +0 -88
  319. package/spec-cli/src/search.bench.mjs +0 -80
  320. package/spec-cli/src/search.ts +0 -63
  321. package/spec-cli/src/session-cursors.ts +0 -91
  322. package/spec-cli/src/session-execution.ts +0 -68
  323. package/spec-cli/src/session-files.ts +0 -121
  324. package/spec-cli/src/session-follow.ts +0 -182
  325. package/spec-cli/src/session-reparent.ts +0 -33
  326. package/spec-cli/src/session-timeline.ts +0 -247
  327. package/spec-cli/src/session-web.ts +0 -135
  328. package/spec-cli/src/sessions.ts +0 -3883
  329. package/spec-cli/src/sh.ts +0 -3
  330. package/spec-cli/src/slash-commands.ts +0 -318
  331. package/spec-cli/src/source-files.ts +0 -104
  332. package/spec-cli/src/specs.ts +0 -498
  333. package/spec-cli/src/supervise.ts +0 -245
  334. package/spec-cli/src/tree.ts +0 -118
  335. package/spec-cli/src/tsx-bin.ts +0 -18
  336. package/spec-cli/src/uninstall.ts +0 -171
  337. package/spec-cli/src/uploads.ts +0 -253
  338. package/spec-cli/src/worktree-sources.ts +0 -42
  339. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +0 -8
  340. package/spec-dashboard/dist/assets/App-B6IbFbGu.js +0 -2
  341. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +0 -1
  342. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +0 -27
  343. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +0 -2
  344. package/spec-dashboard/dist/assets/IssuesPage-BG9rNOgV.js +0 -1
  345. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  346. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  347. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  348. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  349. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  350. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  351. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  352. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  353. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  354. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  355. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  356. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  357. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  358. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  359. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  360. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  361. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  362. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  363. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  364. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  365. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  366. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  367. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  368. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  369. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  370. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  371. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  372. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  373. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  374. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  375. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  376. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  377. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  378. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  379. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  380. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  381. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  382. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  383. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  384. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  385. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  386. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  387. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  388. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  389. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  390. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  391. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  392. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  393. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  394. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  395. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  396. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  397. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  398. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  399. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  400. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  401. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  402. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  403. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  404. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +0 -2
  405. package/spec-dashboard/dist/assets/Modal-B3EcgeA3.js +0 -1
  406. package/spec-dashboard/dist/assets/PageScroll-CNZOugWV.js +0 -1
  407. package/spec-dashboard/dist/assets/ProjectsPage-C2CRHmvs.js +0 -1
  408. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +0 -32
  409. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +0 -40
  410. package/spec-dashboard/dist/assets/SessionWindow-1nYgZ4CL.js +0 -1
  411. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +0 -1
  412. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +0 -13
  413. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +0 -1
  414. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +0 -1
  415. package/spec-dashboard/dist/assets/index-DLVeOHL-.js +0 -41
  416. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +0 -1
  417. package/spec-dashboard/dist/assets/launch-Cp579UoJ.css +0 -1
  418. package/spec-dashboard/dist/assets/launch-yxCYr64x.js +0 -276
  419. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +0 -1
  420. package/spec-dashboard/dist/index.html +0 -29
  421. package/spec-dashboard/src/reviewFilters.js +0 -322
  422. package/spec-dashboard/src/reviewQuery.js +0 -174
  423. package/spec-dashboard/src/session.js +0 -199
  424. package/spec-eval/src/cache.ts +0 -57
  425. package/spec-eval/src/cli.ts +0 -894
  426. package/spec-eval/src/evaltab.ts +0 -264
  427. package/spec-eval/src/filing.ts +0 -39
  428. package/spec-eval/src/freshness.ts +0 -836
  429. package/spec-eval/src/humanok.ts +0 -28
  430. package/spec-eval/src/scenariofresh.ts +0 -244
  431. package/spec-eval/src/scenarios.ts +0 -672
  432. package/spec-eval/src/sessioneval.ts +0 -2220
  433. package/spec-eval/src/sidecar.ts +0 -93
  434. package/spec-eval/src/timeline.ts +0 -54
  435. package/spec-forge/src/__fixtures__/github-forge.json +0 -63
  436. package/spec-forge/src/cache.ts +0 -66
  437. package/spec-forge/src/cli.ts +0 -99
  438. package/spec-forge/src/drivers/github.ts +0 -132
  439. package/spec-forge/src/drivers/gitlab.ts +0 -153
  440. package/spec-forge/src/drivers.ts +0 -73
  441. package/spec-forge/src/links.ts +0 -72
  442. package/spec-forge/src/needs-eval.ts +0 -29
  443. package/spec-forge/src/port.ts +0 -36
  444. package/spec-forge/src/resident.ts +0 -46
  445. /package/{spec-cli → node_modules/@spexcode/spec-cli}/README.md +0 -0
  446. /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -0
  447. /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-sed-v0.fixture +0 -0
  448. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/commit-msg +0 -0
  449. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-checkout +0 -0
  450. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-merge +0 -0
  451. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/pre-commit +0 -0
  452. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/prepare-commit-msg +0 -0
  453. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/reference-transaction +0 -0
  454. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/extract/spec.md +0 -0
  455. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/regroup/spec.md +0 -0
  456. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/rename/spec.md +0 -0
  457. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/spec.md +0 -0
  458. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/tidy/spec.md +0 -0
  459. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/comment-altitude/spec.md +0 -0
  460. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/idle.sh +0 -0
  461. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/spec.md +0 -0
  462. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/mark-active.sh +0 -0
  463. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/spec.md +0 -0
  464. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/fail.sh +0 -0
  465. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/spec.md +0 -0
  466. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec.md +0 -0
  467. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec.md +0 -0
  468. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec.md +0 -0
  469. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +0 -0
  470. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/forge-link/spec.md +0 -0
  471. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +0 -0
  472. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +0 -0
  473. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/spec.md +0 -0
  474. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/digest.mjs +0 -0
  475. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/spec.md +0 -0
  476. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/spec.md +0 -0
  477. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/spec.md +0 -0
  478. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/spec.md +0 -0
  479. /package/{spec-cli → node_modules/@spexcode/spec-core}/templates/spexcode.json +0 -0
@@ -0,0 +1,1195 @@
1
+ import { streamSSE } from 'hono/streaming';
2
+ import { watch, mkdirSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { join, dirname, relative, resolve, basename } from 'node:path';
4
+ import { sessionsRoot, gitCommonDir, repoRoot, sessionBranchIndex, mainBranch } from '@spexcode/spec-core';
5
+ import { hotSignature, warmSignature, listSessions, pendingSessionCreateWorktreePaths } from './sessions.js';
6
+ import { getBoard, getBoardForSessionRefresh, invalidateBoard, patrolBoard } from './graphCache.js';
7
+ import { unitize, tagOf, diffUnits } from '@spexcode/spec-core';
8
+ import { holdSessionEvalProjectionObserver, invalidateSessionEvalProjections, releaseSessionEvalProjectionObserver, setSessionEvalProjectionNotify, setSessionEvalProjectionWarmup, } from '@spexcode/spec-eval/sessioneval';
9
+ // @@@ watch transport - the ONE place the platform appears. Observing a tree is a single product-level
10
+ // registration; how many registrations the OS actually holds for it is the transport's business.
11
+ // consolidated-recursive — the OS itself observes a whole subtree from one registration (Darwin
12
+ // FSEvents, Windows ReadDirectoryChangesW). It reports by PATH, so an atomic replacement inside the
13
+ // tree stays visible, and exclusions filter on delivery because nothing is consumed per directory.
14
+ // exact-directory — no such observer exists (Linux), and Node's `recursive` option is a USERSPACE
15
+ // fan-out registering one inotify watch per file AND directory (measured: 201 directories -> 801
16
+ // watches) which, watching files by inode, goes blind the moment a file is atomically replaced. So
17
+ // we enumerate directories once and install one NON-recursive watch each, all multiplexed onto the
18
+ // loop's single shared inotify descriptor, excluding .git/node_modules at traversal time.
19
+ export const consolidatedRecursiveWatch = (platform = process.platform) => platform === 'darwin' || platform === 'win32';
20
+ // The live census across every registry: `sources` is what graph-stream asked the platform for (one per
21
+ // canonical root), `registrations` is what the platform is actually holding for them. Both are read by the
22
+ // watcher-budget tests and logged under SPEXCODE_BOARD_DEBUG, so a plateau is observable on every platform
23
+ // rather than only where /proc exposes inotify descriptors.
24
+ const liveRegistries = new Set();
25
+ let liveRegistrations = 0;
26
+ export function graphWatcherCensus() {
27
+ return { sources: liveRegistries.size, registrations: liveRegistrations };
28
+ }
29
+ export class TreeWatcherRegistry {
30
+ root;
31
+ source;
32
+ scope;
33
+ transport;
34
+ recursive;
35
+ ignore;
36
+ watchFactory;
37
+ onInput;
38
+ onFailure;
39
+ handles = new Map();
40
+ refreshImmediate = null;
41
+ failed = false;
42
+ constructor(options) {
43
+ this.root = resolve(options.root);
44
+ this.source = options.source;
45
+ this.scope = options.scope;
46
+ this.recursive = options.recursive !== false;
47
+ this.transport = options.transport
48
+ ?? (this.recursive && consolidatedRecursiveWatch() ? 'consolidated-recursive' : 'exact-directory');
49
+ this.ignore = options.ignore ?? (() => false);
50
+ this.watchFactory = options.watchFactory ?? watch;
51
+ this.onInput = options.onInput;
52
+ this.onFailure = options.onFailure;
53
+ }
54
+ get size() { return this.handles.size; }
55
+ paths() { return [...this.handles.keys()].sort(); }
56
+ // one kernel-side observer covers the subtree: the root is the only path we register, and no rename can
57
+ // make the desired set drift, so there is nothing to re-walk.
58
+ get consolidated() { return this.recursive && this.transport === 'consolidated-recursive'; }
59
+ desiredDirectories() {
60
+ const desired = new Set();
61
+ const visit = (dir) => {
62
+ desired.add(dir);
63
+ if (!this.recursive || this.consolidated)
64
+ return;
65
+ let entries;
66
+ try {
67
+ entries = readdirSync(dir, { withFileTypes: true });
68
+ }
69
+ catch (error) {
70
+ if (dir !== this.root && error.code === 'ENOENT') {
71
+ desired.delete(dir);
72
+ return;
73
+ }
74
+ throw error;
75
+ }
76
+ for (const entry of entries) {
77
+ if (!entry.isDirectory() || entry.isSymbolicLink())
78
+ continue;
79
+ const path = join(dir, entry.name);
80
+ const rel = relative(this.root, path);
81
+ if (!this.ignore(rel))
82
+ visit(path);
83
+ }
84
+ };
85
+ visit(this.root);
86
+ return desired;
87
+ }
88
+ closeHandles() {
89
+ if (this.refreshImmediate) {
90
+ clearImmediate(this.refreshImmediate);
91
+ this.refreshImmediate = null;
92
+ }
93
+ const handles = [...this.handles.values()];
94
+ this.handles.clear();
95
+ liveRegistrations -= handles.length;
96
+ liveRegistries.delete(this);
97
+ for (const handle of handles) {
98
+ try {
99
+ handle.close();
100
+ }
101
+ catch { /* already gone */ }
102
+ }
103
+ }
104
+ sourceError(path, error) {
105
+ const err = error;
106
+ const reason = error instanceof Error ? error.message : String(error);
107
+ // source + path + errno, always — an exhausted platform must name which syscall budget it refused.
108
+ const errno = err?.code && !reason.includes(err.code) ? ` (errno ${err.code})` : '';
109
+ return new Error(`spec-cli: graph watcher '${this.source}' failed at ${path}: ${reason}${errno}`);
110
+ }
111
+ fail(path, error) {
112
+ if (this.failed)
113
+ return false;
114
+ this.failed = true;
115
+ this.closeHandles();
116
+ this.onFailure(this.sourceError(path, error));
117
+ return false;
118
+ }
119
+ scheduleRefresh() {
120
+ if (this.refreshImmediate || this.failed)
121
+ return;
122
+ this.refreshImmediate = setImmediate(() => {
123
+ this.refreshImmediate = null;
124
+ this.refresh();
125
+ });
126
+ this.refreshImmediate.unref?.();
127
+ }
128
+ refresh() {
129
+ if (this.refreshImmediate) {
130
+ clearImmediate(this.refreshImmediate);
131
+ this.refreshImmediate = null;
132
+ }
133
+ this.failed = false;
134
+ let desired;
135
+ try {
136
+ desired = this.desiredDirectories();
137
+ }
138
+ catch (error) {
139
+ return this.fail(this.root, error);
140
+ }
141
+ const before = this.handles.size;
142
+ for (const [path, handle] of [...this.handles]) {
143
+ if (desired.has(path))
144
+ continue;
145
+ this.handles.delete(path);
146
+ liveRegistrations--;
147
+ try {
148
+ handle.close();
149
+ }
150
+ catch { /* already gone */ }
151
+ }
152
+ const options = this.consolidated ? { recursive: true } : {};
153
+ for (const path of desired) {
154
+ if (this.handles.has(path))
155
+ continue;
156
+ try {
157
+ let handle;
158
+ const callback = (event, filename) => {
159
+ if (this.handles.get(path) !== handle)
160
+ return;
161
+ if (filename == null) {
162
+ this.fail(path, new Error('pathless filesystem event'));
163
+ return;
164
+ }
165
+ const inputPath = resolve(path, String(filename));
166
+ const rel = relative(this.root, inputPath);
167
+ if (rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || rel === '..') {
168
+ this.fail(path, new Error(`event escaped watcher root: ${String(filename)}`));
169
+ return;
170
+ }
171
+ if (this.ignore(rel))
172
+ return;
173
+ this.onInput(event, rel);
174
+ // only the userspace transport's desired set can drift: a new directory under a consolidated
175
+ // observer is already covered by the kernel.
176
+ if (this.recursive && !this.consolidated && event === 'rename')
177
+ this.scheduleRefresh();
178
+ };
179
+ handle = this.watchFactory(path, options, callback);
180
+ this.handles.set(path, handle);
181
+ liveRegistrations++;
182
+ handle.on('error', (error) => {
183
+ if (this.handles.get(path) === handle)
184
+ this.fail(path, error);
185
+ });
186
+ }
187
+ catch (error) {
188
+ return this.fail(path, error);
189
+ }
190
+ }
191
+ if (this.handles.size)
192
+ liveRegistries.add(this);
193
+ if (DEBUG && this.handles.size !== before)
194
+ console.warn(`spec-cli: graph watchers — sources=${liveRegistries.size} registrations=${liveRegistrations} (${this.source}: ${this.transport} ${this.handles.size})`);
195
+ return true;
196
+ }
197
+ close() {
198
+ this.failed = false;
199
+ this.closeHandles();
200
+ }
201
+ }
202
+ const plainSubs = new Set();
203
+ const deltaSubs = new Set();
204
+ let debounce = null;
205
+ let pendingFull = false;
206
+ let pendingSessions = false;
207
+ export const addPendingGraphChange = (pending, scope) => ({
208
+ full: pending.full || scope === 'full',
209
+ sessions: pending.sessions || scope === 'sessions',
210
+ });
211
+ // Git creates the registry entry before the session transaction publishes its durable record. A registry
212
+ // event for that private candidate must not start a full board build while the `git worktree add` child is
213
+ // still running; the create route flushes the deferred event after publication (or cleanup).
214
+ export function isSessionCreateCandidateRegistryEvent(relativePath, candidatePaths) {
215
+ const entry = relativePath.split(/[\\/]+/).filter(Boolean)[0] ?? '';
216
+ if (!entry)
217
+ return false;
218
+ for (const path of candidatePaths)
219
+ if (basename(resolve(path)) === entry)
220
+ return true;
221
+ return false;
222
+ }
223
+ // under SPEXCODE_BOARD_DEBUG=1, every broadcast logs its changed unit keys + trigger tags + build ms.
224
+ const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1';
225
+ function traceLatency(stage, detail = {}) {
226
+ if (DEBUG)
227
+ console.warn(`spec-cli: graph latency ${JSON.stringify({ at: Date.now(), stage, ...detail })}`);
228
+ }
229
+ // the set of trigger tags accrued SINCE THE LAST BROADCAST — each fireChanged adds its scope, the cold tick
230
+ // adds 'patrol'. Cleared on every broadcast. Its job: prove WHO caused a broadcast, so a change that only
231
+ // the patrol saw (tag set === {'patrol'}) is flagged as a repair — some leaf watcher was blind.
232
+ const triggerTags = new Set();
233
+ // watchers a test can amputate to prove the patrol still heals the graph ([[graph-stream]]): a CSV of
234
+ // store,refs,worktrees,project-root makes the matching ensure* a no-op (with a one-time warning), so a change on that
235
+ // path reaches subscribers ONLY via the cold-tick patrol — the missing-watcher scenario, on demand.
236
+ const DISABLED = new Set((process.env.SPEXCODE_DISABLE_WATCHERS || '').split(',').map((s) => s.trim()).filter(Boolean));
237
+ const warnedDisabled = new Set();
238
+ function isDisabled(name) {
239
+ if (!DISABLED.has(name))
240
+ return false;
241
+ if (!warnedDisabled.has(name)) {
242
+ warnedDisabled.add(name);
243
+ console.warn(`spec-cli: graph watcher '${name}' disabled via SPEXCODE_DISABLE_WATCHERS — the cold-tick patrol must cover it`);
244
+ }
245
+ return true;
246
+ }
247
+ // ---- the rebuild→diff→broadcast pipeline (runs only while delta subscribers exist) ----
248
+ // last successfully-broadcast snapshot: the delta chain's anchor. `lastFullFrame` is what a fresh
249
+ // subscriber gets instantly; `lastUnits`+`lastTag` are what the next diff chains from. A snapshot that
250
+ // failed the unitize precondition anchors nothing (lastUnits=null) so every following send is a full.
251
+ let lastUnits = null;
252
+ let lastTag = '';
253
+ let lastFullFrame = null;
254
+ let building = false;
255
+ let dirty = false;
256
+ let patrolPending = false;
257
+ let sessionRefreshRequested = false;
258
+ let wakeSessionRefresh = null;
259
+ async function rebuildAndBroadcast(patrol = false, sessions = false, full = false) {
260
+ if (patrol)
261
+ patrolPending = true;
262
+ if (sessions)
263
+ sessionRefreshRequested = true;
264
+ if (building) {
265
+ dirty = true;
266
+ if (sessions) {
267
+ wakeSessionRefresh?.();
268
+ wakeSessionRefresh = null;
269
+ }
270
+ return;
271
+ }
272
+ building = true;
273
+ try {
274
+ do {
275
+ dirty = false;
276
+ const validate = patrolPending;
277
+ patrolPending = false;
278
+ const sessionsFirst = sessionRefreshRequested;
279
+ sessionRefreshRequested = false;
280
+ let servedSessionProjection = sessionsFirst;
281
+ let board;
282
+ // share the route's single-flight build ([[graph-cache]]); fireChanged() already invalidated the
283
+ // cache (at the accumulated scope), so this gets a fresh build/splice (or joins one a concurrent poll
284
+ // already started). The patrol instead asks that same cache flight to validate its input revision;
285
+ // equal inputs return the anchor without invoking a producer.
286
+ const t0 = Date.now();
287
+ try {
288
+ let wake;
289
+ const sessionWake = new Promise((resolve) => { wake = resolve; });
290
+ wakeSessionRefresh = wake;
291
+ // A sessions-first turn can still fall back to an active route-owned full. Keep its wait wakeable
292
+ // too: a later persisted session change must re-enter the cheap projection rather than queue behind it.
293
+ const boardWait = sessionsFirst
294
+ ? getBoardForSessionRefresh()
295
+ : validate ? patrolBoard() : getBoard();
296
+ const outcome = await Promise.race([
297
+ boardWait.then((value) => ({ value })),
298
+ sessionWake.then(() => ({ value: null })),
299
+ ]);
300
+ if (wakeSessionRefresh === wake)
301
+ wakeSessionRefresh = null;
302
+ if (outcome.value === null) {
303
+ boardWait.catch(() => { });
304
+ board = await getBoardForSessionRefresh();
305
+ servedSessionProjection = true;
306
+ if (validate)
307
+ patrolPending = true;
308
+ dirty = true; // the full wait was preempted only for delivery; it remains owed.
309
+ }
310
+ else {
311
+ board = outcome.value;
312
+ if (sessionsFirst && validate) {
313
+ patrolPending = true;
314
+ dirty = true;
315
+ }
316
+ }
317
+ }
318
+ catch {
319
+ // A failed refresh consumes no cause: graph-cache restores the producer scope, so its stream-side
320
+ // attribution must remain owed too. This also retains watcher causes that arrived while the failed
321
+ // flight was occupied. A later patrol may recover the work, but it is then one cause alongside those
322
+ // healthy leaf signals rather than a false patrol-only blind-watcher repair.
323
+ for (const n of [...plainSubs]) {
324
+ try {
325
+ n();
326
+ }
327
+ catch { /* swept on abort */ }
328
+ }
329
+ continue;
330
+ }
331
+ const buildMs = Date.now() - t0;
332
+ if (servedSessionProjection)
333
+ traceLatency('session-projection-complete', { patrol: validate });
334
+ const boardJson = JSON.stringify(board);
335
+ const { units, ok } = unitize(board);
336
+ const tag = tagOf(units);
337
+ // A session-first frame consumes its own cause, but a full/patrol cause remains owed until structural
338
+ // convergence. Otherwise the first cheap projection would erase patrol accountability before the full
339
+ // result could name it. A normal frame consumes its whole trigger set, including a no-op frame.
340
+ const tags = [...triggerTags];
341
+ triggerTags.clear();
342
+ if (servedSessionProjection)
343
+ for (const tag of tags)
344
+ if (tag === 'full' || tag === 'patrol')
345
+ triggerTags.add(tag);
346
+ if (sessionsFirst && full)
347
+ dirty = true;
348
+ if (tag === lastTag)
349
+ continue;
350
+ // the changed unit keys — computed against the prior anchor when we have one (a first paint has no
351
+ // anchor, so no repair claim can be made against it).
352
+ const changedKeys = lastUnits ? (() => { const { set, del } = diffUnits(lastUnits, units); return [...Object.keys(set), ...del]; })() : [];
353
+ const fullFrame = { event: 'graph-full', data: `{"to":"${tag}","graph":${boardJson}}` };
354
+ let frame = fullFrame;
355
+ if (lastUnits && ok) {
356
+ const { set, del } = diffUnits(lastUnits, units);
357
+ const deltaData = JSON.stringify({ from: lastTag, to: tag, set, del });
358
+ // guaranteed win: ship the patch only when it actually beats the snapshot
359
+ if (deltaData.length < fullFrame.data.length)
360
+ frame = { event: 'graph-delta', data: deltaData };
361
+ }
362
+ // the anchor is only meaningful while a delta subscriber holds the chain live: with none left,
363
+ // nothing rebuilds on change, so a cached anchor would silently age into a stale first frame for the
364
+ // NEXT era's subscriber (issue #70). A build that completes after the last unsub caches nothing
365
+ // (stopSourcesIfIdle cleared the anchor; leaving lastTag/lastUnits stale-cleared is consistent —
366
+ // rebuilds only run while delta subscribers exist, so nothing chains from them meanwhile).
367
+ if (deltaSubs.size) {
368
+ lastUnits = ok ? units : null;
369
+ lastTag = tag;
370
+ lastFullFrame = fullFrame;
371
+ }
372
+ traceLatency('broadcast', { event: frame.event, sessionProjection: servedSessionProjection, tags, changedKeys });
373
+ for (const send of [...deltaSubs]) {
374
+ try {
375
+ send(frame);
376
+ }
377
+ catch { /* swept on abort */ }
378
+ }
379
+ for (const n of [...plainSubs]) {
380
+ try {
381
+ n();
382
+ }
383
+ catch { /* swept on abort */ }
384
+ }
385
+ // ---- repair accounting: a real (tag-moved) broadcast whose ONLY trigger was the cold-tick patrol
386
+ // means a leaf watcher was BLIND — the patrol self-healed it. That is a bug report, not routine, so
387
+ // it is ALWAYS loud (repairs are supposed to be zero — [[graph-stream]]). Under DEBUG, every
388
+ // broadcast logs its changed keys + triggers + build ms.
389
+ if (changedKeys.length && tags.length === 1 && tags[0] === 'patrol')
390
+ console.warn(`spec-cli: PATROL-REPAIR — the cold tick caught a change no leaf watcher pushed; changed units: [${changedKeys.join(', ')}] — a blind watcher, investigate`);
391
+ if (DEBUG)
392
+ console.warn(`spec-cli: graph broadcast — changed [${changedKeys.join(', ')}] triggers {${tags.join(', ')}} build ${buildMs}ms`);
393
+ } while (dirty);
394
+ }
395
+ finally {
396
+ building = false;
397
+ }
398
+ }
399
+ // a merge/launch/close touches several record files at once; collapse the burst into ONE signal. Each call
400
+ // carries its own change SCOPE: full and sessions are independent obligations, not a max-scope replacement.
401
+ // With delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same
402
+ // tag-moved gate — no spurious refetches); without them it stays the zero-build legacy notify.
403
+ function fireChanged(scope = 'full', evalTarget) {
404
+ // Advance eval input generations BEFORE invalidating/building the board, so the first frame caused by an
405
+ // input event is `updating(lastKnown)`. Summary completion calls this function without a target.
406
+ if (scope === 'sessions')
407
+ traceLatency('sessions-signal');
408
+ if (evalTarget)
409
+ invalidateSessionEvalProjections(evalTarget);
410
+ const pending = addPendingGraphChange({ full: pendingFull, sessions: pendingSessions }, scope);
411
+ pendingFull = pending.full;
412
+ pendingSessions = pending.sessions;
413
+ // invalidate the route's board cache ([[graph-cache]]) on EVERY change signal at its OWN scope,
414
+ // before the debounce guard — a plain-mode client that polls /api/graph (no delta rebuild here) must
415
+ // still see fresh data on its next poll, and a delta rebuild below re-reads the same now-stale cache.
416
+ invalidateBoard(scope);
417
+ triggerTags.add(scope);
418
+ // DEBOUNCE = 25ms. Real fs-event bursts (a merge touching many records) were MEASURED to span 0–5ms, so a
419
+ // 25ms window collapses them with room to spare while shaving ~125ms off the old 150ms lag; anything
420
+ // wider than the window is coalesced anyway by the in-flight build's dirty-rerun loop, which is the real
421
+ // burst absorber. So the debounce is a micro-collapse, not the coalescer.
422
+ if (debounce)
423
+ return;
424
+ debounce = setTimeout(() => {
425
+ debounce = null;
426
+ const full = pendingFull, sessions = pendingSessions;
427
+ pendingFull = false;
428
+ pendingSessions = false;
429
+ if (deltaSubs.size)
430
+ void rebuildAndBroadcast(false, sessions, full);
431
+ else
432
+ for (const notify of [...plainSubs]) {
433
+ try {
434
+ notify();
435
+ }
436
+ catch { /* swept on abort */ }
437
+ }
438
+ }, 25);
439
+ }
440
+ // ---- event source 0: an EXPLICIT server-side nudge ----
441
+ // for a server-side mutation that must show instantly regardless of watcher health: /rename writes the
442
+ // session's global record (`session.json` — [[session-rename]]), which lives INSIDE the watched store, so
443
+ // source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
444
+ // (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
445
+ // debounced funnel as every other source; defaults to 'full' but the rename route passes 'sessions'.
446
+ export const notifyBoardChanged = (scope = 'full') => fireChanged(scope, scope === 'full' ? 'all' : undefined);
447
+ // Stable summary batches re-enter the SAME session-unit graph path; this is not a second transport.
448
+ setSessionEvalProjectionNotify(() => fireChanged('sessions'));
449
+ // ---- ONE repair scheduler for every filesystem source ----
450
+ // A source the platform refuses keeps its observer hold and is retried by THIS timer alone — never by a
451
+ // graph build, an HTTP read, a poller tick or a registry event. That is the whole anti-storm rule: the
452
+ // resource being exhausted (a registration budget, a descriptor table) is PROCESS-wide, so its backoff
453
+ // belongs to the process, not to each source racing its own retry and re-walking the corpus every time.
454
+ // `heldSources` is what an ordinary pass may not re-attempt; only the scheduled pass lifts that.
455
+ const heldSources = new Set();
456
+ let repairTimer = null;
457
+ let repairStep = 0;
458
+ let repairing = false;
459
+ const mayAttach = (source) => repairing || !heldSources.has(source);
460
+ // The failure is loud ONCE per episode and names source + path + errno (the registry built that message);
461
+ // every later source felled by the same exhausted budget is counted, not re-printed, and the repair line
462
+ // reports the total. A half-attached registry is already closed by the registry itself before we get here.
463
+ function noteSourceFailure(source, error) {
464
+ const known = heldSources.has(source);
465
+ heldSources.add(source);
466
+ if (!known && heldSources.size === 1)
467
+ console.error(error instanceof Error ? error.message : String(error));
468
+ scheduleWatcherRepair();
469
+ }
470
+ function noteSourceHealthy(source) {
471
+ if (!heldSources.delete(source))
472
+ return;
473
+ if (heldSources.size === 0)
474
+ repairStep = 0;
475
+ }
476
+ function scheduleWatcherRepair() {
477
+ if (repairTimer || !heldSources.size)
478
+ return;
479
+ const delay = Math.min(30_000, 250 * 2 ** Math.min(repairStep, 7));
480
+ repairStep++;
481
+ const era = watcherEra;
482
+ console.error(`spec-cli: graph watcher repair — ${heldSources.size} source(s) held, retrying in ${delay}ms; the cold-tick patrol covers the gap until they reattach`);
483
+ repairTimer = setTimeout(() => {
484
+ repairTimer = null;
485
+ if (era !== watcherEra)
486
+ return;
487
+ repairing = true;
488
+ void ensureBoardFileWatchers()
489
+ .catch((error) => console.error(`spec-cli: graph watcher repair failed — ${error instanceof Error ? error.message : String(error)}`))
490
+ .finally(() => {
491
+ repairing = false;
492
+ if (heldSources.size)
493
+ scheduleWatcherRepair();
494
+ });
495
+ }, delay);
496
+ repairTimer.unref?.();
497
+ }
498
+ // ---- event source 1: the session store (lifecycle status writes) → 'sessions' ----
499
+ let activeStoreRoot = null;
500
+ let activeCommonRoot = null;
501
+ let watcherEra = 0;
502
+ let storeWatcher = null;
503
+ function ensureWatcher(root) {
504
+ if (storeWatcher?.root === root)
505
+ return;
506
+ if (storeWatcher) {
507
+ storeWatcher.close();
508
+ storeWatcher = null;
509
+ }
510
+ if (isDisabled('store') || !mayAttach('store'))
511
+ return;
512
+ try {
513
+ mkdirSync(root, { recursive: true });
514
+ }
515
+ catch (error) {
516
+ console.error(`spec-cli: graph watcher 'store' could not create ${root}: ${error instanceof Error ? error.message : String(error)}`);
517
+ }
518
+ const registry = new TreeWatcherRegistry({
519
+ root,
520
+ source: 'store',
521
+ scope: 'sessions',
522
+ onInput: () => { dropBranchIndex(); fireChanged('sessions'); }, // a created/renamed session may own a new branch
523
+ onFailure: (error) => {
524
+ if (storeWatcher === registry)
525
+ storeWatcher = null;
526
+ noteSourceFailure('store', error);
527
+ fireChanged('sessions');
528
+ },
529
+ });
530
+ storeWatcher = registry;
531
+ if (!registry.refresh()) {
532
+ if (storeWatcher === registry)
533
+ storeWatcher = null;
534
+ return;
535
+ }
536
+ noteSourceHealthy('store');
537
+ }
538
+ let refsWatchers = null;
539
+ const REFS_OBSERVER = 'graph:refs';
540
+ // @@@ the moved ref NAMES its scope - the watcher has always known which ref moved and threw it away, so
541
+ // every ref movement anywhere invalidated every session's evaluation. On a host carrying dozens of branches
542
+ // and a bot that commits continuously that means nothing is ever warm: observed on adopter-a as an input
543
+ // generation of 1208 against a last-known 254. A session's fingerprint reads exactly three refs — its own
544
+ // tip, the base tip, and their merge-base — so a ref that is neither cannot move it. `packed-refs` and
545
+ // `HEAD` stay broad on purpose: a packed update rewrites many refs behind ONE event, so it names nothing.
546
+ // the index is read from the session store, so it is only ever stale when that store moved — and the store
547
+ // has its own watcher, which drops it. A ref burst therefore costs one map lookup, not 76 record reads.
548
+ let branchIndexMemo = null;
549
+ function branchIndex() {
550
+ return (branchIndexMemo ??= sessionBranchIndex());
551
+ }
552
+ function dropBranchIndex() { branchIndexMemo = null; }
553
+ export function evalTargetForRef(ref, base, branches) {
554
+ if (ref === undefined)
555
+ return 'all'; // an unnamed movement must assume the worst
556
+ const rel = ref.replace(/\\/g, '/');
557
+ if (!rel.startsWith('heads/'))
558
+ return undefined; // tags and remotes feed no session fingerprint
559
+ const branch = rel.slice('heads/'.length);
560
+ if (!branch)
561
+ return 'all';
562
+ if (branch === base)
563
+ return 'all'; // every merge-base may have moved
564
+ const id = branches.get(branch);
565
+ return id ? { id } : undefined; // a branch no session owns moves no session's evaluation
566
+ }
567
+ export function watchSessionEvalRefs(common, onInput, onFailure) {
568
+ let attached = [];
569
+ let failed = false;
570
+ let ready = false;
571
+ let attachError = null;
572
+ const close = () => {
573
+ const registries = attached;
574
+ attached = [];
575
+ for (const registry of registries)
576
+ registry.close();
577
+ };
578
+ const fail = (error) => {
579
+ if (failed)
580
+ return;
581
+ failed = true;
582
+ close();
583
+ if (ready)
584
+ onFailure(error);
585
+ else
586
+ attachError = error;
587
+ };
588
+ const refs = new TreeWatcherRegistry({
589
+ root: join(common, 'refs'),
590
+ source: 'refs',
591
+ scope: 'full',
592
+ onInput: (_event, rel) => onInput(rel),
593
+ onFailure: fail,
594
+ });
595
+ attached.push(refs);
596
+ if (!refs.refresh())
597
+ throw attachError ?? new Error(`spec-cli: graph watcher 'refs' failed at ${refs.root}`);
598
+ const commonFiles = new TreeWatcherRegistry({
599
+ root: common,
600
+ source: 'refs-common',
601
+ scope: 'full',
602
+ recursive: false,
603
+ onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD')
604
+ onInput(); }, // names nothing -> 'all'
605
+ onFailure: fail,
606
+ });
607
+ attached.push(commonFiles);
608
+ if (!commonFiles.refresh())
609
+ throw attachError ?? new Error(`spec-cli: graph watcher 'refs-common' failed at ${common}`);
610
+ ready = true;
611
+ return { root: resolve(common), close };
612
+ }
613
+ function refsWatcherFailed(error) {
614
+ refsWatchers = null;
615
+ noteSourceFailure('refs', error);
616
+ if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all'))
617
+ fireChanged('full');
618
+ }
619
+ function ensureRefsWatcher(common = activeCommonRoot) {
620
+ if (!common)
621
+ return;
622
+ if (refsWatchers?.root === common)
623
+ return;
624
+ if (refsWatchers) {
625
+ refsWatchers.close();
626
+ refsWatchers = null;
627
+ }
628
+ if (isDisabled('refs')) {
629
+ if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all'))
630
+ fireChanged('full');
631
+ return;
632
+ }
633
+ if (!mayAttach('refs'))
634
+ return;
635
+ try {
636
+ refsWatchers = watchSessionEvalRefs(common, (ref) => fireChanged('full', evalTargetForRef(ref, mainBranch(), branchIndex())), refsWatcherFailed);
637
+ noteSourceHealthy('refs');
638
+ if (releaseSessionEvalProjectionObserver(REFS_OBSERVER))
639
+ fireChanged('full');
640
+ }
641
+ catch (error) {
642
+ refsWatchers = null;
643
+ noteSourceFailure('refs', error);
644
+ if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all'))
645
+ fireChanged('full');
646
+ }
647
+ }
648
+ // ---- event source 3: worktree registry + working roots + per-worktree indexes → 'full' ----
649
+ // Summary inputs include ordinary dirty source and staged-only changes, not just `.spec`. Each live
650
+ // worktree is therefore TWO canonical roots — its working tree and git's metadata dir for it (`index`) —
651
+ // and that pair is the whole per-worktree registration cost, whatever the corpus inside it holds.
652
+ let registryWatcher = null;
653
+ let registryReady = false;
654
+ const worktreeWatchers = new Map();
655
+ const worktreeObserver = (name) => `graph:worktree:${name}`;
656
+ const worktreeSource = (name) => `worktree:${name}`;
657
+ const PROJECT_ROOT_SOURCE = 'project-root';
658
+ let projectRootWatcher = null;
659
+ const ignoredWorktreePath = (file) => file.split(/[\\/]/).some((segment) => segment === '.git' || segment === 'node_modules');
660
+ // @@@ linked worktrees are not graph input for THIS backend - the board's node statuses derive from the
661
+ // served checkout's own HEAD, so a file under `.worktrees/<node>` belongs to a different branch's tree and
662
+ // cannot move any status here until it lands and this HEAD advances. Watching them registers one inotify
663
+ // watch per directory (Linux takes the exact-directory transport) and buys nothing: measured on this repo,
664
+ // 20,124 of 20,473 watched directories were linked worktrees against 843 in the served tree.
665
+ // The per-worktree registries above keep their own roots; only the project-root sweep skips them.
666
+ export const ignoredProjectRootPath = (file) => ignoredWorktreePath(file) || file.split(/[\\/]/).some((segment) => segment === '.worktrees');
667
+ // The directory whose tree this backend serves is graph input even before it has a `.spec` tree or any live
668
+ // session worktree. Keeping it in the same root registry as linked worktrees means a first `spex init` or
669
+ // agent-created spec invalidates a warmed empty board instead of leaving a confidently stale cache until a
670
+ // delta patrol happens to repair it. `repoRoot()` matches graph-cache's revision owner: a backend launched
671
+ // from a linked worktree must observe that linked root, not the common-dir checkout.
672
+ function ensureProjectRootWatcher() {
673
+ const root = resolve(repoRoot());
674
+ const coveredByWorktree = [...worktreeWatchers.values()].some((row) => row.path === root);
675
+ if (projectRootWatcher?.root === root && !coveredByWorktree)
676
+ return;
677
+ if (projectRootWatcher) {
678
+ projectRootWatcher.close();
679
+ projectRootWatcher = null;
680
+ }
681
+ // A malformed registry that resolves a linked worktree onto the served project must not spend a duplicate root
682
+ // registration. That live worktree watcher already names a full graph change on every root event.
683
+ if (coveredByWorktree) {
684
+ noteSourceHealthy(PROJECT_ROOT_SOURCE);
685
+ return;
686
+ }
687
+ if (isDisabled(PROJECT_ROOT_SOURCE))
688
+ return;
689
+ if (!mayAttach(PROJECT_ROOT_SOURCE))
690
+ return;
691
+ let registry;
692
+ registry = new TreeWatcherRegistry({
693
+ root,
694
+ source: PROJECT_ROOT_SOURCE,
695
+ scope: 'full',
696
+ ignore: ignoredProjectRootPath,
697
+ onInput: () => fireChanged('full', 'all'),
698
+ onFailure: (error) => {
699
+ if (projectRootWatcher === registry)
700
+ projectRootWatcher = null;
701
+ noteSourceFailure(PROJECT_ROOT_SOURCE, error);
702
+ fireChanged('full', 'all');
703
+ },
704
+ });
705
+ projectRootWatcher = registry;
706
+ if (!registry.refresh()) {
707
+ if (projectRootWatcher === registry)
708
+ projectRootWatcher = null;
709
+ return;
710
+ }
711
+ noteSourceHealthy(PROJECT_ROOT_SOURCE);
712
+ }
713
+ export function watchSessionEvalWorktree(wtPath, gitDir, onInput, onFailure) {
714
+ let failed = false;
715
+ let ready = false;
716
+ let attachError = null;
717
+ let root = null;
718
+ let index = null;
719
+ const close = () => {
720
+ root?.close();
721
+ index?.close();
722
+ };
723
+ const fail = (error) => {
724
+ if (failed)
725
+ return;
726
+ failed = true;
727
+ close();
728
+ if (ready)
729
+ onFailure(error);
730
+ else
731
+ attachError = error;
732
+ };
733
+ root = new TreeWatcherRegistry({
734
+ root: wtPath,
735
+ source: `worktree:${resolve(wtPath)}`,
736
+ scope: 'full',
737
+ ignore: ignoredWorktreePath,
738
+ onInput: () => onInput(),
739
+ onFailure: fail,
740
+ });
741
+ if (!root.refresh())
742
+ throw attachError ?? new Error(`spec-cli: graph watcher 'worktree' failed at ${wtPath}`);
743
+ index = new TreeWatcherRegistry({
744
+ root: gitDir,
745
+ source: `worktree-index:${resolve(wtPath)}`,
746
+ scope: 'full',
747
+ recursive: false,
748
+ onInput: (_event, file) => { if (file === 'index')
749
+ onInput(); },
750
+ onFailure: fail,
751
+ });
752
+ if (!index.refresh())
753
+ throw attachError ?? new Error(`spec-cli: graph watcher 'worktree-index' failed at ${gitDir}`);
754
+ ready = true;
755
+ return { path: resolve(wtPath), root, index, close };
756
+ }
757
+ function dropWorktreeWatcher(name) {
758
+ const row = worktreeWatchers.get(name);
759
+ if (!row)
760
+ return null;
761
+ worktreeWatchers.delete(name);
762
+ row.close();
763
+ return row;
764
+ }
765
+ function watcherFailed(name, path, error) {
766
+ // Failure/overflow has no trustworthy path. Keep last-known, mark the target updating, and hand the
767
+ // reattach to the one repair scheduler; the authorized summary build is the one authoritative rescan,
768
+ // never a periodic sweep and never a re-walk driven by whatever read noticed the failure.
769
+ dropWorktreeWatcher(name);
770
+ noteSourceFailure(worktreeSource(name), error);
771
+ if (holdSessionEvalProjectionObserver(worktreeObserver(name), { path }))
772
+ fireChanged('full');
773
+ }
774
+ const forcedWorktreeSessions = new Set();
775
+ let worktreeReconcileFlight = null;
776
+ export function sessionWorktreeWatchPaths(sessions, forcedSessions = new Set()) {
777
+ return new Set(sessions
778
+ .filter((session) => session.liveness !== 'offline' || forcedSessions.has(session.id))
779
+ .map((session) => resolve(session.path)));
780
+ }
781
+ async function reconcileWorktreePass(forcedSessions, era, common) {
782
+ const dir = join(common, 'worktrees');
783
+ let sessions;
784
+ try {
785
+ sessions = await listSessions();
786
+ }
787
+ catch {
788
+ return;
789
+ }
790
+ if (era !== watcherEra)
791
+ return;
792
+ const wantedPaths = sessionWorktreeWatchPaths(sessions, forcedSessions);
793
+ let ents = [];
794
+ try {
795
+ ents = readdirSync(dir, { withFileTypes: true });
796
+ }
797
+ catch { /* no worktrees registry yet */ }
798
+ const wantedNames = new Set();
799
+ let released = false;
800
+ for (const e of ents) {
801
+ if (!e.isDirectory())
802
+ continue;
803
+ let wtPath;
804
+ try {
805
+ wtPath = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim());
806
+ }
807
+ catch {
808
+ continue;
809
+ }
810
+ const normalizedPath = resolve(wtPath);
811
+ if (!wantedPaths.has(normalizedPath)) {
812
+ if (dropWorktreeWatcher(e.name))
813
+ released = releaseSessionEvalProjectionObserver(worktreeObserver(e.name)) || released;
814
+ noteSourceHealthy(worktreeSource(e.name));
815
+ continue;
816
+ }
817
+ wantedNames.add(e.name);
818
+ const existing = worktreeWatchers.get(e.name);
819
+ if (existing?.path === normalizedPath)
820
+ continue;
821
+ const rootChanged = existing != null;
822
+ if (existing)
823
+ dropWorktreeWatcher(e.name);
824
+ // a held worktree is the repair scheduler's to reattach — an ordinary reconcile pass must not re-walk
825
+ // it, which is what turned one refused registration into a per-read attach storm.
826
+ if (!mayAttach(worktreeSource(e.name)))
827
+ continue;
828
+ try {
829
+ // the entry's `gitdir` file points at the worktree's `<tree>/.git` (file or dir); its parent is the tree.
830
+ const row = watchSessionEvalWorktree(wtPath, join(dir, e.name), () => fireChanged('full', { path: wtPath }), (error) => watcherFailed(e.name, wtPath, error));
831
+ if (era !== watcherEra) {
832
+ row.close();
833
+ return;
834
+ }
835
+ worktreeWatchers.set(e.name, row);
836
+ noteSourceHealthy(worktreeSource(e.name));
837
+ if (rootChanged)
838
+ fireChanged('full', { path: wtPath });
839
+ // The replacement is live before its hold is removed. This delta authorizes one double-read rescan,
840
+ // so edits made anywhere in the unwatched interval are inside the new generation's fingerprint.
841
+ if (releaseSessionEvalProjectionObserver(worktreeObserver(e.name)))
842
+ fireChanged('full');
843
+ }
844
+ catch (error) {
845
+ // An attach failure is observable: mark unknown/full now. The repair scheduler owns the reattach; no
846
+ // patrol is allowed to call the eval projection current meanwhile.
847
+ noteSourceFailure(worktreeSource(e.name), error);
848
+ if (holdSessionEvalProjectionObserver(worktreeObserver(e.name), { path: wtPath }))
849
+ fireChanged('full');
850
+ }
851
+ }
852
+ for (const name of worktreeWatchers.keys())
853
+ if (!wantedNames.has(name)) {
854
+ dropWorktreeWatcher(name);
855
+ released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released;
856
+ }
857
+ for (const source of [...heldSources]) {
858
+ const name = source.startsWith('worktree:') ? source.slice('worktree:'.length) : null;
859
+ if (name && !wantedNames.has(name)) {
860
+ noteSourceHealthy(source);
861
+ released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released;
862
+ }
863
+ }
864
+ if (released)
865
+ fireChanged('full');
866
+ }
867
+ function reconcileWorktrees(forceSessionId) {
868
+ // Blinding a leaf must blind it from EVERY entry point. Reconciliation is reached from the liveness
869
+ // poller and from registry events as well as from the ensure pass, so gating only the ensure pass left
870
+ // the per-worktree observers attaching anyway — the injection looked applied while the leaf still saw
871
+ // everything, which makes the patrol's accountability untestable rather than merely untested.
872
+ if (isDisabled('worktrees'))
873
+ return Promise.resolve();
874
+ if (forceSessionId)
875
+ forcedWorktreeSessions.add(forceSessionId);
876
+ if (worktreeReconcileFlight)
877
+ return worktreeReconcileFlight;
878
+ const era = watcherEra;
879
+ const common = activeCommonRoot;
880
+ if (!common)
881
+ return Promise.resolve();
882
+ const flight = (async () => {
883
+ do {
884
+ if (era !== watcherEra)
885
+ return;
886
+ const forced = new Set(forcedWorktreeSessions);
887
+ forcedWorktreeSessions.clear();
888
+ await reconcileWorktreePass(forced, era, common);
889
+ } while (forcedWorktreeSessions.size);
890
+ })().finally(() => { if (worktreeReconcileFlight === flight)
891
+ worktreeReconcileFlight = null; });
892
+ worktreeReconcileFlight = flight;
893
+ void flight.catch((error) => {
894
+ console.warn(`spec-cli: worktree watcher reconciliation failed — ${error instanceof Error ? error.message : String(error)}`);
895
+ });
896
+ return flight;
897
+ }
898
+ const WORKTREE_REGISTRY_OBSERVER = 'graph:worktree-registry';
899
+ let deferredRegistryChange = false;
900
+ let deferredRegistryTimer = null;
901
+ function flushDeferredRegistryChange() {
902
+ if (!deferredRegistryChange)
903
+ return;
904
+ deferredRegistryChange = false;
905
+ if (deferredRegistryTimer) {
906
+ clearTimeout(deferredRegistryTimer);
907
+ deferredRegistryTimer = null;
908
+ }
909
+ void reconcileWorktrees();
910
+ fireChanged('full', 'all');
911
+ }
912
+ // A crashed or disconnected create request must not leave the registry event deferred forever. The normal
913
+ // route flush is immediate; this bounded fallback preserves the watcher contract if the process never reaches
914
+ // its response boundary.
915
+ function deferRegistryChange() {
916
+ deferredRegistryChange = true;
917
+ if (deferredRegistryTimer)
918
+ return;
919
+ deferredRegistryTimer = setTimeout(() => {
920
+ deferredRegistryTimer = null;
921
+ flushDeferredRegistryChange();
922
+ }, 10_000);
923
+ deferredRegistryTimer.unref?.();
924
+ }
925
+ export function flushDeferredWorktreeRegistryChange() {
926
+ flushDeferredRegistryChange();
927
+ }
928
+ export function watchSessionEvalRegistry(dir, onInput, onFailure) {
929
+ let ready = false;
930
+ let attachError = null;
931
+ const registry = new TreeWatcherRegistry({
932
+ root: dir,
933
+ source: 'worktree-registry',
934
+ scope: 'full',
935
+ recursive: false,
936
+ onInput: (_event, relativePath) => onInput(_event, relativePath),
937
+ onFailure: (error) => {
938
+ if (ready)
939
+ onFailure(error);
940
+ else
941
+ attachError = error;
942
+ },
943
+ });
944
+ if (!registry.refresh())
945
+ throw attachError ?? new Error(`spec-cli: graph watcher 'worktree-registry' failed at ${dir}`);
946
+ ready = true;
947
+ return registry;
948
+ }
949
+ function registryWatcherFailed(error) {
950
+ registryWatcher = null;
951
+ registryReady = false;
952
+ noteSourceFailure('worktree-registry', error);
953
+ if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all'))
954
+ fireChanged('full');
955
+ }
956
+ async function ensureWorktreeRegistry(forceSessionId) {
957
+ const common = activeCommonRoot;
958
+ if (!common)
959
+ return;
960
+ const dir = resolve(join(common, 'worktrees'));
961
+ // The registry watcher already reconciles add/remove events. Re-scanning every ordinary graph/evals read
962
+ // turns a large worktree registry into an artificial request latency floor; only a scoped read may demand
963
+ // one target after startup, while the unscoped hot path reuses the attached live watchers.
964
+ // an attached registry short-circuits the scan for ordinary reads, but a REPAIR pass exists precisely to
965
+ // reattach held worktrees, so it must reach the reconcile even when the registry itself is healthy.
966
+ if (registryWatcher?.root === dir) {
967
+ if (forceSessionId || repairing)
968
+ await reconcileWorktrees(forceSessionId);
969
+ return;
970
+ }
971
+ if (registryWatcher) {
972
+ registryWatcher.close();
973
+ registryWatcher = null;
974
+ registryReady = false;
975
+ }
976
+ // A platform may reject registry watches. Once the initial reconciliation has run, ordinary
977
+ // reads must not repeat its full worktree scan while the repair pass is pending; scoped reads can
978
+ // still demand their target explicitly.
979
+ if (registryReady && !forceSessionId && !repairing)
980
+ return;
981
+ if (registryReady && (forceSessionId || repairing)) {
982
+ await reconcileWorktrees(forceSessionId);
983
+ return;
984
+ }
985
+ if (isDisabled('worktrees')) {
986
+ registryReady = true;
987
+ if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all'))
988
+ fireChanged('full');
989
+ return;
990
+ }
991
+ if (!mayAttach('worktree-registry'))
992
+ return;
993
+ registryReady = true;
994
+ try {
995
+ try {
996
+ mkdirSync(dir, { recursive: true });
997
+ }
998
+ catch (error) {
999
+ console.error(`spec-cli: graph watcher 'worktree-registry' could not create ${dir}: ${error instanceof Error ? error.message : String(error)}`);
1000
+ }
1001
+ // a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
1002
+ // reconcile the per-worktree `.spec` watchers on every registry event.
1003
+ registryWatcher = watchSessionEvalRegistry(dir, (_event, relativePath) => {
1004
+ if (isSessionCreateCandidateRegistryEvent(relativePath, pendingSessionCreateWorktreePaths())) {
1005
+ deferRegistryChange();
1006
+ return;
1007
+ }
1008
+ void reconcileWorktrees();
1009
+ fireChanged('full', 'all');
1010
+ }, registryWatcherFailed);
1011
+ noteSourceHealthy('worktree-registry');
1012
+ }
1013
+ catch (error) {
1014
+ registryWatcher = null;
1015
+ registryReady = false;
1016
+ noteSourceFailure('worktree-registry', error);
1017
+ if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all'))
1018
+ fireChanged('full');
1019
+ }
1020
+ await reconcileWorktrees(forceSessionId); // attach for the live/demanded worktrees that already exist
1021
+ if (registryWatcher && releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER))
1022
+ fireChanged('full');
1023
+ }
1024
+ // Attach the canonical filesystem sources before an HTTP snapshot starts summary work. This closes the
1025
+ // request→SSE handoff gap: an edit after the snapshot build has a watcher before it can occur. A source
1026
+ // the platform refused is NOT retried here — it is held for the repair scheduler, so an HTTP read never
1027
+ // becomes a reattach loop.
1028
+ export async function ensureBoardFileWatchers(forceSessionId) {
1029
+ const storeRoot = resolve(sessionsRoot());
1030
+ const commonRoot = resolve(gitCommonDir());
1031
+ if ((activeStoreRoot && activeStoreRoot !== storeRoot) || (activeCommonRoot && activeCommonRoot !== commonRoot))
1032
+ closeBoardFileWatchers();
1033
+ activeStoreRoot = storeRoot;
1034
+ activeCommonRoot = commonRoot;
1035
+ ensureWatcher(storeRoot);
1036
+ ensureRefsWatcher(commonRoot);
1037
+ await ensureWorktreeRegistry(forceSessionId);
1038
+ ensureProjectRootWatcher();
1039
+ }
1040
+ export function closeBoardFileWatchers() {
1041
+ watcherEra++;
1042
+ if (repairTimer) {
1043
+ clearTimeout(repairTimer);
1044
+ repairTimer = null;
1045
+ }
1046
+ if (deferredRegistryTimer) {
1047
+ clearTimeout(deferredRegistryTimer);
1048
+ deferredRegistryTimer = null;
1049
+ }
1050
+ deferredRegistryChange = false;
1051
+ heldSources.clear();
1052
+ repairStep = 0;
1053
+ repairing = false;
1054
+ forcedWorktreeSessions.clear();
1055
+ worktreeReconcileFlight = null;
1056
+ storeWatcher?.close();
1057
+ storeWatcher = null;
1058
+ refsWatchers?.close();
1059
+ refsWatchers = null;
1060
+ registryWatcher?.close();
1061
+ registryWatcher = null;
1062
+ registryReady = false;
1063
+ projectRootWatcher?.close();
1064
+ projectRootWatcher = null;
1065
+ for (const [name, row] of worktreeWatchers) {
1066
+ row.close();
1067
+ releaseSessionEvalProjectionObserver(worktreeObserver(name));
1068
+ }
1069
+ worktreeWatchers.clear();
1070
+ releaseSessionEvalProjectionObserver(REFS_OBSERVER);
1071
+ releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER);
1072
+ activeStoreRoot = null;
1073
+ activeCommonRoot = null;
1074
+ }
1075
+ // ---- event source 4: the two-tier tmux-derived pollers (liveness + activity — never a file write) → 'sessions' ----
1076
+ // The signals a store watch can't see are tmux-derived, and they split by cost ([[sessions]]): a HOT 100ms
1077
+ // poll of a cheap syscall-only fingerprint (a socket dying, a listener wedging) and a WARM 1s poll of the
1078
+ // pane-title self-summaries (a headline change is a tmux round-trip, too dear at 100ms). Both fire 'sessions'.
1079
+ let hotPoller = null;
1080
+ let warmPoller = null;
1081
+ let lastHot = '';
1082
+ let lastWarm = '';
1083
+ function ensurePollers() {
1084
+ if (!hotPoller)
1085
+ hotPoller = setInterval(() => {
1086
+ void hotSignature().then((sig) => {
1087
+ if (sig !== lastHot) {
1088
+ lastHot = sig;
1089
+ void reconcileWorktrees();
1090
+ fireChanged('sessions');
1091
+ }
1092
+ }).catch(() => { });
1093
+ }, 100);
1094
+ if (!warmPoller)
1095
+ warmPoller = setInterval(() => {
1096
+ void warmSignature().then((sig) => { if (sig !== lastWarm) {
1097
+ lastWarm = sig;
1098
+ fireChanged('sessions');
1099
+ } }).catch(() => { });
1100
+ }, 1000);
1101
+ }
1102
+ // ---- event source 5: the cold-tick PATROL — the server-side replacement for every client's slow fallback
1103
+ // poll, AND the self-heal authority. A relaxed tick asks graph-cache's ONE flight to compare its board-input
1104
+ // revision: unchanged state returns the anchor without assembly, while an unobserved ref/worktree/store move
1105
+ // escalates there to a full rebuild. The 'patrol' tag keeps that repair accountable. Delta-gated: plain-only
1106
+ // clients keep their own client-side fallback, so without delta subscribers this does no validation work.
1107
+ let coldTick = null;
1108
+ function ensureColdTick() {
1109
+ if (coldTick)
1110
+ return;
1111
+ coldTick = setInterval(() => {
1112
+ if (!deltaSubs.size)
1113
+ return;
1114
+ triggerTags.add('patrol');
1115
+ void rebuildAndBroadcast(true);
1116
+ }, 15000);
1117
+ }
1118
+ function stopSourcesIfIdle() {
1119
+ // the delta anchor dies with its era's last subscriber: past this point changes invalidate the cache but
1120
+ // never rebuild, so lastFullFrame would drift arbitrarily far from the real board — and the next era's
1121
+ // first subscriber would be anchored on it (its warm-terminal set then drops live sessions' panes, and the
1122
+ // client's recovery lanes can latch each other out — issue #70). A new era opens on a fresh build instead.
1123
+ if (deltaSubs.size === 0) {
1124
+ lastUnits = null;
1125
+ lastTag = '';
1126
+ lastFullFrame = null;
1127
+ patrolPending = false;
1128
+ }
1129
+ if (deltaSubs.size === 0)
1130
+ setSessionEvalProjectionWarmup(false);
1131
+ if (plainSubs.size + deltaSubs.size > 0)
1132
+ return;
1133
+ if (hotPoller) {
1134
+ clearInterval(hotPoller);
1135
+ hotPoller = null;
1136
+ lastHot = '';
1137
+ }
1138
+ if (warmPoller) {
1139
+ clearInterval(warmPoller);
1140
+ warmPoller = null;
1141
+ lastWarm = '';
1142
+ }
1143
+ if (coldTick) {
1144
+ clearInterval(coldTick);
1145
+ coldTick = null;
1146
+ }
1147
+ }
1148
+ // GET /api/graph/stream — one SSE per dashboard tab, server→client only, with a periodic `ping` so an
1149
+ // idle proxy never times the connection out. On a backend hot-reload the stream drops and EventSource
1150
+ // auto-reconnects to the fresh child; a delta subscriber's reconnect lands a fresh `graph-full`, so the
1151
+ // chain re-anchors with no client-side repair logic.
1152
+ export async function boardStream(c) {
1153
+ const delta = c.req.query('mode') === 'delta';
1154
+ await ensureBoardFileWatchers();
1155
+ return streamSSE(c, async (stream) => {
1156
+ let aborted = false;
1157
+ const send = (frame) => { void stream.writeSSE(frame).catch(() => { }); };
1158
+ const notify = () => { void stream.writeSSE({ event: 'graph-changed', data: 'x' }).catch(() => { }); };
1159
+ if (delta) {
1160
+ deltaSubs.add(send);
1161
+ setSessionEvalProjectionWarmup(true);
1162
+ ensureColdTick();
1163
+ }
1164
+ else {
1165
+ plainSubs.add(notify);
1166
+ }
1167
+ ensurePollers();
1168
+ const unsub = () => { deltaSubs.delete(send); plainSubs.delete(notify); stopSourcesIfIdle(); };
1169
+ stream.onAbort(() => { aborted = true; unsub(); });
1170
+ try {
1171
+ await stream.writeSSE({ event: 'ready', data: 'x' });
1172
+ if (delta) {
1173
+ // seed the chain: the cached anchor snapshot immediately (same tag the next delta chains from),
1174
+ // then a fire so a connect during a quiet stretch converges to truly-current within one build.
1175
+ if (lastFullFrame) {
1176
+ await stream.writeSSE(lastFullFrame).catch(() => { });
1177
+ fireChanged();
1178
+ }
1179
+ else
1180
+ void rebuildAndBroadcast();
1181
+ }
1182
+ while (!aborted) {
1183
+ // ping every 10s — the client's heartbeat contract is 2.5× this window ([[graph-stream]]), so a
1184
+ // silent-death gap is caught inside one client watchdog interval, and idle proxies never time out.
1185
+ await stream.sleep(10000);
1186
+ if (aborted)
1187
+ break;
1188
+ await stream.writeSSE({ event: 'ping', data: 'x' });
1189
+ }
1190
+ }
1191
+ finally {
1192
+ unsub();
1193
+ }
1194
+ });
1195
+ }