spexcode 0.6.7 → 0.7.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +12 -7
  2. package/node_modules/@spexcode/session-application/dist/index.d.ts +17 -0
  3. package/node_modules/@spexcode/session-application/dist/index.js +35 -0
  4. package/node_modules/@spexcode/session-application/dist/migration.d.ts +51 -0
  5. package/node_modules/@spexcode/session-application/dist/migration.js +694 -0
  6. package/node_modules/@spexcode/session-application/dist/production.d.ts +109 -0
  7. package/node_modules/@spexcode/session-application/dist/production.js +438 -0
  8. package/node_modules/@spexcode/session-application/dist/schema.d.ts +2 -0
  9. package/node_modules/@spexcode/session-application/dist/schema.js +39 -0
  10. package/node_modules/@spexcode/session-application/package.json +29 -0
  11. package/node_modules/@spexcode/session-events/dist/errors.d.ts +6 -0
  12. package/node_modules/@spexcode/session-events/dist/errors.js +11 -0
  13. package/node_modules/@spexcode/session-events/dist/index.d.ts +46 -0
  14. package/node_modules/@spexcode/session-events/dist/index.js +245 -0
  15. package/node_modules/@spexcode/session-events/dist/schema.d.ts +3 -0
  16. package/node_modules/@spexcode/session-events/dist/schema.js +44 -0
  17. package/node_modules/@spexcode/session-events/package.json +24 -0
  18. package/node_modules/@spexcode/session-protocol/dist/canonical.d.ts +36 -0
  19. package/node_modules/@spexcode/session-protocol/dist/canonical.js +139 -0
  20. package/node_modules/@spexcode/session-protocol/dist/engine.d.ts +22 -0
  21. package/node_modules/@spexcode/session-protocol/dist/engine.js +478 -0
  22. package/node_modules/@spexcode/session-protocol/dist/errors.d.ts +8 -0
  23. package/node_modules/@spexcode/session-protocol/dist/errors.js +39 -0
  24. package/node_modules/@spexcode/session-protocol/dist/index.d.ts +60 -0
  25. package/node_modules/@spexcode/session-protocol/dist/index.js +4 -0
  26. package/node_modules/@spexcode/session-protocol/dist/schema.d.ts +36 -0
  27. package/node_modules/@spexcode/session-protocol/dist/schema.js +192 -0
  28. package/node_modules/@spexcode/{session-core → session-protocol}/package.json +4 -8
  29. package/node_modules/@spexcode/session-runtime/dist/errors.d.ts +6 -0
  30. package/node_modules/@spexcode/session-runtime/dist/errors.js +11 -0
  31. package/node_modules/@spexcode/session-runtime/dist/index.d.ts +33 -0
  32. package/node_modules/@spexcode/session-runtime/dist/index.js +193 -0
  33. package/node_modules/@spexcode/session-runtime/dist/schema.d.ts +2 -0
  34. package/node_modules/@spexcode/session-runtime/dist/schema.js +37 -0
  35. package/node_modules/@spexcode/session-runtime/package.json +24 -0
  36. package/node_modules/@spexcode/session-selflaunch/bin/spex-session.mjs +4 -0
  37. package/node_modules/@spexcode/session-selflaunch/dist/cli.d.ts +25 -0
  38. package/node_modules/@spexcode/session-selflaunch/dist/cli.js +156 -0
  39. package/node_modules/@spexcode/session-selflaunch/dist/index.d.ts +28 -0
  40. package/node_modules/@spexcode/session-selflaunch/dist/index.js +25 -0
  41. package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +31 -0
  42. package/node_modules/@spexcode/session-selflaunch/dist/locality.js +135 -0
  43. package/node_modules/@spexcode/session-selflaunch/dist/path.d.ts +13 -0
  44. package/node_modules/@spexcode/session-selflaunch/dist/path.js +45 -0
  45. package/node_modules/@spexcode/session-selflaunch/package.json +35 -0
  46. package/node_modules/@spexcode/session-topology/dist/errors.d.ts +6 -0
  47. package/node_modules/@spexcode/session-topology/dist/errors.js +11 -0
  48. package/node_modules/@spexcode/session-topology/dist/index.d.ts +24 -0
  49. package/node_modules/@spexcode/session-topology/dist/index.js +173 -0
  50. package/node_modules/@spexcode/session-topology/dist/schema.d.ts +3 -0
  51. package/node_modules/@spexcode/session-topology/dist/schema.js +27 -0
  52. package/node_modules/@spexcode/session-topology/package.json +24 -0
  53. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +175 -21
  54. package/node_modules/@spexcode/spec-cli/dist/cli.js +121 -59
  55. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +1 -3
  56. package/node_modules/@spexcode/spec-cli/dist/client.js +49 -30
  57. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +11 -0
  58. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +46 -9
  59. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.d.ts +2 -0
  60. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.js +58 -0
  61. package/node_modules/@spexcode/spec-cli/dist/doctor.js +53 -11
  62. package/node_modules/@spexcode/spec-cli/dist/file-write.js +14 -2
  63. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
  64. package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
  65. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
  66. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
  67. package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
  68. package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
  69. package/node_modules/@spexcode/spec-cli/dist/guide.js +29 -18
  70. package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
  71. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +24 -6
  72. package/node_modules/@spexcode/spec-cli/dist/harness.js +510 -72
  73. package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
  74. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
  75. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
  76. package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
  77. package/node_modules/@spexcode/spec-cli/dist/host.js +98 -1
  78. package/node_modules/@spexcode/spec-cli/dist/index.js +294 -36
  79. package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
  80. package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
  81. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
  82. package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
  83. package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +2 -1
  84. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +46 -15
  85. package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +2 -2
  86. package/node_modules/@spexcode/spec-cli/dist/materialize.js +176 -35
  87. package/node_modules/@spexcode/spec-cli/dist/opencode.js +10 -2
  88. package/node_modules/@spexcode/spec-cli/dist/pi-harness.js +18 -5
  89. package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +2 -0
  90. package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +27 -0
  91. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +14 -14
  92. package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
  93. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
  94. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
  95. package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
  96. package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
  97. package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
  98. package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
  99. package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
  100. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
  101. package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
  102. package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
  103. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
  104. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
  105. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
  106. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
  107. package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +57 -0
  108. package/node_modules/@spexcode/spec-cli/dist/session-transcript.js +137 -0
  109. package/node_modules/@spexcode/spec-cli/dist/session-web.js +4 -4
  110. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +112 -15
  111. package/node_modules/@spexcode/spec-cli/dist/sessions.js +1553 -764
  112. package/node_modules/@spexcode/spec-cli/dist/shim-runtime.js +14 -0
  113. package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
  114. package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
  115. package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
  116. package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
  117. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
  118. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
  119. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
  120. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
  121. package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
  122. package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
  123. package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
  124. package/node_modules/@spexcode/spec-cli/package.json +8 -6
  125. package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
  126. package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
  127. package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
  128. package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
  129. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
  130. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  131. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  132. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  133. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  134. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  135. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  136. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  137. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  138. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
  139. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  140. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  141. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  142. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
  143. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
  144. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
  145. package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
  146. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  147. package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
  148. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -1
  149. package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
  150. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
  151. package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
  152. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
  153. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
  154. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
  155. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
  156. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
  157. package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
  158. package/node_modules/@spexcode/spec-core/package.json +4 -1
  159. package/node_modules/@spexcode/spec-core/templates/spexcode.json +36 -13
  160. package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
  161. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
  162. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
  163. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
  164. package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
  165. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
  166. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
  167. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
  168. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  169. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  170. package/node_modules/@spexcode/transcript/dist/frames.d.ts +44 -0
  171. package/node_modules/@spexcode/transcript/dist/frames.js +85 -0
  172. package/node_modules/@spexcode/transcript/dist/index.d.ts +5 -0
  173. package/node_modules/@spexcode/transcript/dist/index.js +5 -0
  174. package/node_modules/@spexcode/transcript/dist/live.d.ts +16 -0
  175. package/node_modules/@spexcode/transcript/dist/live.js +65 -0
  176. package/node_modules/@spexcode/transcript/dist/parsers.d.ts +47 -0
  177. package/node_modules/@spexcode/transcript/dist/parsers.js +285 -0
  178. package/node_modules/@spexcode/transcript/dist/readers.d.ts +10 -0
  179. package/node_modules/@spexcode/transcript/dist/readers.js +296 -0
  180. package/node_modules/@spexcode/transcript/dist/turns.d.ts +42 -0
  181. package/node_modules/@spexcode/transcript/dist/turns.js +11 -0
  182. package/node_modules/@spexcode/transcript/package.json +30 -0
  183. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  184. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  185. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  186. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  187. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  188. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  189. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  190. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  191. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  192. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  193. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  194. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  195. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  196. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  197. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  198. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  199. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  200. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  201. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  202. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  203. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  204. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  205. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  206. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  207. package/node_modules/smol-toml/LICENSE +24 -0
  208. package/node_modules/smol-toml/README.md +418 -0
  209. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  210. package/node_modules/smol-toml/dist/date.js +127 -0
  211. package/node_modules/smol-toml/dist/error.d.ts +38 -0
  212. package/node_modules/smol-toml/dist/error.js +63 -0
  213. package/node_modules/smol-toml/dist/extract.js +69 -0
  214. package/node_modules/smol-toml/dist/index.cjs +734 -0
  215. package/node_modules/smol-toml/dist/index.d.ts +43 -0
  216. package/node_modules/smol-toml/dist/index.js +33 -0
  217. package/node_modules/smol-toml/dist/parse.d.ts +36 -0
  218. package/node_modules/smol-toml/dist/parse.js +149 -0
  219. package/node_modules/smol-toml/dist/primitive.js +238 -0
  220. package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
  221. package/node_modules/smol-toml/dist/stringify.js +181 -0
  222. package/node_modules/smol-toml/dist/struct.js +179 -0
  223. package/node_modules/smol-toml/dist/util.d.ts +38 -0
  224. package/node_modules/smol-toml/dist/util.js +89 -0
  225. package/node_modules/smol-toml/package.json +68 -0
  226. package/package.json +9 -3
  227. package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -24
  228. package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -206
  229. package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
  230. package/node_modules/@spexcode/session-core/dist/index.js +0 -5
  231. package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
  232. package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
  233. package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
  234. package/node_modules/@spexcode/session-core/dist/message.js +0 -53
  235. package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
  236. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -62
  237. package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -326
  238. package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
  239. package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
  240. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -47
  241. package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
  242. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +0 -26
  243. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +0 -475
  244. package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +0 -10
  245. package/node_modules/@spexcode/spec-cli/dist/session-execution.js +0 -70
  246. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  247. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-sed-v0.fixture +0 -46
@@ -117,6 +117,8 @@ const spexShimRuntime = (cfg) => {
117
117
 
118
118
  // The per-session rendezvous control socket is a best-effort same-turn poke. The timeline remains the
119
119
  // message's durable copy, so this listener only receives reply lines and never confirms or rejects them.
120
+ // An "interrupt" line is the one CONFIRMED message: the host's native abort runs and the socket answers
121
+ // interrupt-done or interrupt-rejected, because an interrupt has no durable copy to fall back on.
120
122
  const serveRendezvous = (inject, opts) => {
121
123
  const sock = (process.env.CLAUDE_BG_RENDEZVOUS_SOCK || "").trim()
122
124
  if (!sock) return null
@@ -125,6 +127,7 @@ const spexShimRuntime = (cfg) => {
125
127
  const server = __spexCreateServer((c) => {
126
128
  let buf = ""
127
129
  c.on("error", () => { /* probes disconnect abruptly — expected */ })
130
+ const answer = (o) => { try { c.write(JSON.stringify(o) + "\\n") } catch { /* the asker left — nothing to confirm to */ } }
128
131
  c.on("data", (d) => {
129
132
  buf += d.toString("utf8")
130
133
  let nl
@@ -133,6 +136,17 @@ const spexShimRuntime = (cfg) => {
133
136
  buf = buf.slice(nl + 1)
134
137
  let msg
135
138
  try { msg = JSON.parse(line) } catch { continue }
139
+ if (msg && msg.type === "interrupt") {
140
+ const abort = opts && opts.interrupt
141
+ if (!abort) { answer({ type: "interrupt-rejected", error: "this " + HARNESS + " shim exposes no native abort" }); continue }
142
+ try {
143
+ Promise.resolve(abort()).then(
144
+ () => answer({ type: "interrupt-done" }),
145
+ (e) => answer({ type: "interrupt-rejected", error: String((e && e.message) || e) }),
146
+ )
147
+ } catch (e) { answer({ type: "interrupt-rejected", error: String((e && e.message) || e) }) }
148
+ continue
149
+ }
136
150
  if (msg && msg.type === "reply" && typeof msg.text === "string") {
137
151
  if (opts && opts.canInject && !opts.canInject()) continue
138
152
  const mid = typeof msg.mid === "string" ? msg.mid : ""
@@ -0,0 +1,13 @@
1
+ import { type SourcePolicy } from './source-files.js';
2
+ export declare const SOURCE_LIST_MAX_ENTRIES = 500;
3
+ export type SourceDirEntry = {
4
+ name: string;
5
+ path: string;
6
+ kind: 'dir' | 'file';
7
+ };
8
+ export type SourceListing = {
9
+ dir: string;
10
+ entries: SourceDirEntry[];
11
+ truncated: boolean;
12
+ };
13
+ export declare function listSourceDir(root: string, dir: string, policy: SourcePolicy, governedRoots: string[]): SourceListing;
@@ -0,0 +1,99 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join, normalize, relative, sep } from 'node:path';
3
+ import { isSourceFile } from './source-files.js';
4
+ import { SourceReadError } from './source-read.js';
5
+ // The LISTING half of the governed-source surface. `source-read` opens one file; this names what is there
6
+ // to open, one directory at a time, so a reader can browse the project the way any editor lets them browse
7
+ // it instead of having to already know a path.
8
+ //
9
+ // @@@ one gate, not a second definition - a FILE appears here exactly when `isSourceFile` would let
10
+ // `/api/source` open it. That is the whole reason this shares the predicate rather than growing a listing
11
+ // policy of its own: a row the reader clicks and gets a 404 from is worse than a row that was never drawn,
12
+ // and two predicates that agree today are two predicates free to disagree tomorrow.
13
+ // Bounded like [[node-attachments]]'s walk, and for the same reason: a directory that has accumulated ten
14
+ // thousand generated files should degrade into a long list rather than a wedged read. Unlike that walk this
15
+ // one is ONE level deep — the client expands a level at a time — so a depth cap would be a cap on a
16
+ // recursion that does not exist here; the bound that matters is the entry count.
17
+ export const SOURCE_LIST_MAX_ENTRIES = 500;
18
+ // Directories that are never the project's own code and would drown the listing they appear in. This is a
19
+ // listing-hygiene rule and it is deliberately NAMED rather than derived: `isSourceFile` has no opinion about
20
+ // a directory, and with no include globs configured it would happily admit a dependency's shipped `.js`.
21
+ // Keeping the list short and explicit is the honest form — a file inside one of these is still readable by
22
+ // direct address if the policy admits it; it simply is not offered as something to browse.
23
+ const SKIP_DIRS = new Set(['.git', 'node_modules']);
24
+ const skipDir = (name) => SKIP_DIRS.has(name) || name.startsWith('.');
25
+ // Repo-relative, worktree-contained, or a loud 400. Containment is checked by RESOLVING and comparing, not
26
+ // by pattern-matching the string: a `..` that normalises back inside is legitimate, one that escapes is not,
27
+ // and an absolute path is refused outright because `join` would silently reinterpret it as relative and hand
28
+ // the caller a confusing answer about a path they did not mean.
29
+ function resolveDir(root, dir) {
30
+ const raw = String(dir || '');
31
+ if (!raw || raw === '.')
32
+ return { rel: '', full: root };
33
+ if (raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw))
34
+ throw new SourceReadError(`dir must be repo-relative and inside the worktree: ${dir}`, 400);
35
+ const rel = normalize(raw).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
36
+ const full = normalize(join(root, rel));
37
+ const back = relative(root, full);
38
+ if (rel === '..' || rel.startsWith('../') || back.startsWith('..') || back.split(sep).some((s) => s === '..'))
39
+ throw new SourceReadError(`dir must be repo-relative and inside the worktree: ${dir}`, 400);
40
+ return { rel, full };
41
+ }
42
+ // A directory is browsable when it lies inside a governed root, or CONTAINS one — the second case is what
43
+ // makes `governedRoots: ['spec-cli/src']` reachable at all, since every ancestor on the way down to it is
44
+ // outside every root while still being the only path there. `.` as a root means the whole project, which
45
+ // makes every directory inside.
46
+ const withinGovernedRoots = (rel, roots) => roots.some((raw) => {
47
+ const root = String(raw || '').replace(/\/+$/, '').replace(/^\.\//, '');
48
+ if (!root || root === '.')
49
+ return true;
50
+ if (!rel)
51
+ return true;
52
+ return rel === root || rel.startsWith(`${root}/`) || root.startsWith(`${rel}/`);
53
+ });
54
+ // One level of a governed directory. The roots themselves are the listing at `dir: ''`, so the tree has a
55
+ // top without the client having to know the config — it asks for the same thing at every level.
56
+ export function listSourceDir(root, dir, policy, governedRoots) {
57
+ const { rel, full } = resolveDir(root, dir);
58
+ if (!withinGovernedRoots(rel, governedRoots))
59
+ throw new SourceReadError(`not inside this project's governed roots: ${rel}`, 404);
60
+ let names;
61
+ try {
62
+ names = readdirSync(full);
63
+ }
64
+ catch (e) {
65
+ // the CODE, never the message — Node puts the absolute host path into the exception text and these
66
+ // strings are API responses ([[source-read]]).
67
+ throw new SourceReadError(`cannot list ${rel || '.'}: ${e?.code ?? 'read failed'}`, 404);
68
+ }
69
+ const dirs = [];
70
+ const files = [];
71
+ let truncated = false;
72
+ for (const name of names.sort()) {
73
+ if (dirs.length + files.length >= SOURCE_LIST_MAX_ENTRIES) {
74
+ truncated = true;
75
+ break;
76
+ }
77
+ const path = rel ? `${rel}/${name}` : name;
78
+ let st;
79
+ // a vanishing entry mid-listing is skipped, never thrown out of the endpoint.
80
+ try {
81
+ st = statSync(join(full, name));
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ if (st.isDirectory()) {
87
+ if (skipDir(name) || !withinGovernedRoots(path, governedRoots))
88
+ continue;
89
+ dirs.push({ name, path, kind: 'dir' });
90
+ continue;
91
+ }
92
+ if (!isSourceFile(root, path, policy))
93
+ continue;
94
+ files.push({ name, path, kind: 'file' });
95
+ }
96
+ // directories first, then files, each already alphabetical — the order every file browser uses, decided
97
+ // here rather than in the client so two clients cannot sort one listing two ways.
98
+ return { dir: rel, entries: [...dirs, ...files], truncated };
99
+ }
@@ -0,0 +1,16 @@
1
+ import { type SourcePolicy } from './source-files.js';
2
+ export declare const SOURCE_SLICE_MAX_BYTES: number;
3
+ export declare class SourceReadError extends Error {
4
+ readonly status: number;
5
+ constructor(message: string, status: number);
6
+ }
7
+ export type SourceSlice = {
8
+ path: string;
9
+ size: number;
10
+ offset: number;
11
+ bytes: number;
12
+ text: string;
13
+ eof: boolean;
14
+ };
15
+ export declare function readSlice(full: string, rel: string, offset?: number, limit?: number): SourceSlice;
16
+ export declare function readSourceSlice(root: string, path: string, policy: SourcePolicy, offset?: number, limit?: number): SourceSlice;
@@ -0,0 +1,84 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ import { join, normalize } from 'node:path';
3
+ import { isSourceFile } from './source-files.js';
4
+ // A source read is a WINDOW over a file, never the whole file. The viewer asks for a byte range and gets
5
+ // back that slice plus the file's total size, so the first paint of a 40 MB file costs the same as a 4 KB
6
+ // one and the client decides how much more to pull. Whole-file delivery is not an option that exists here:
7
+ // it is the shape that has to be walked back once a repository contains one generated bundle.
8
+ export const SOURCE_SLICE_MAX_BYTES = 256 * 1024;
9
+ export class SourceReadError extends Error {
10
+ status;
11
+ constructor(message, status) {
12
+ super(message);
13
+ this.name = 'SourceReadError';
14
+ this.status = status;
15
+ }
16
+ }
17
+ // @@@ one gate, not a second definition - a file is readable here exactly when the lint/coverage walk
18
+ // would call it a source file. Sharing `isSourceFile` means "what the product will show you" and "what the
19
+ // product will govern" can never drift apart into two answers.
20
+ function resolveGoverned(root, path, policy) {
21
+ if (!path)
22
+ throw new SourceReadError('path is required', 400);
23
+ const rel = normalize(path).replace(/\\/g, '/').replace(/^\.\//, '');
24
+ if (rel.startsWith('/') || rel === '..' || rel.startsWith('../'))
25
+ throw new SourceReadError(`path must be repo-relative and inside the worktree: ${path}`, 400);
26
+ if (!isSourceFile(root, rel, policy))
27
+ throw new SourceReadError(`not a readable source file under this project's source policy: ${rel}`, 404);
28
+ return join(root, rel);
29
+ }
30
+ // Snap the slice to the last newline it contains. A byte window cut mid-line would also cut mid-codepoint,
31
+ // so the viewer would render a replacement char and a half row that the next slice repeats. Reporting the
32
+ // SNAPPED length as `bytes` keeps the client's cursor arithmetic honest: it always resumes on a line start.
33
+ // The final slice of a file is returned whole — there is no following slice to hand the remainder to.
34
+ function snapToLine(buf, atEof) {
35
+ if (atEof)
36
+ return buf;
37
+ const cut = buf.lastIndexOf(0x0a);
38
+ if (cut < 0)
39
+ return buf; // a single line longer than the window: hand it over and let the client ask again
40
+ return buf.subarray(0, cut + 1);
41
+ }
42
+ // @@@ the window is shared, the GATE is not - what may be read differs by surface (governed source answers
43
+ // to the coverage policy; a node's own folder answers to the spec tree), but "read a byte window and snap it
44
+ // to a line" is one behaviour and exists once. A second copy of the windowing is how the two would drift
45
+ // into disagreeing about what `bytes` means.
46
+ export function readSlice(full, rel, offset = 0, limit = SOURCE_SLICE_MAX_BYTES) {
47
+ const start = Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0;
48
+ const want = Math.min(Math.max(Math.floor(Number.isFinite(limit) ? limit : 0), 1), SOURCE_SLICE_MAX_BYTES);
49
+ let fd = null;
50
+ try {
51
+ fd = openSync(full, 'r');
52
+ const size = fstatSync(fd).size;
53
+ if (start >= size)
54
+ return { path: rel, size, offset: start, bytes: 0, text: '', eof: true };
55
+ const raw = Buffer.allocUnsafe(Math.min(want, size - start));
56
+ const read = readSync(fd, raw, 0, raw.length, start);
57
+ const slice = snapToLine(raw.subarray(0, read), start + read >= size);
58
+ return {
59
+ path: rel,
60
+ size,
61
+ offset: start,
62
+ bytes: slice.length,
63
+ text: slice.toString('utf8'),
64
+ eof: start + slice.length >= size,
65
+ };
66
+ }
67
+ catch (e) {
68
+ if (e instanceof SourceReadError)
69
+ throw e;
70
+ // The CODE, never the message: Node puts the absolute path it tried to open into `e.message`, and this
71
+ // string is an API response. A caller is owed what went wrong with the path it asked for, not where
72
+ // this checkout happens to live on the host.
73
+ throw new SourceReadError(`cannot read ${rel}: ${e?.code ?? 'read failed'}`, 404);
74
+ }
75
+ finally {
76
+ if (fd !== null)
77
+ closeSync(fd);
78
+ }
79
+ }
80
+ // The governed-source surface: readable exactly when the coverage walk would call it a source file.
81
+ export function readSourceSlice(root, path, policy, offset = 0, limit = SOURCE_SLICE_MAX_BYTES) {
82
+ const rel = normalize(path).replace(/\\/g, '/').replace(/^\.\//, '');
83
+ return readSlice(resolveGoverned(root, path, policy), rel, offset, limit);
84
+ }
@@ -0,0 +1,7 @@
1
+ import { type SourceSlice } from './source-read.js';
2
+ export type NodeAttachment = {
3
+ name: string;
4
+ size: number;
5
+ };
6
+ export declare function listNodeAttachments(root: string, id: string): NodeAttachment[];
7
+ export declare function readNodeAttachment(root: string, id: string, name: string, offset?: number, limit?: number): SourceSlice;
@@ -0,0 +1,89 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, normalize, relative, sep } from 'node:path';
3
+ import { specDir } from '@spexcode/spec-core';
4
+ import { readSlice, SourceReadError, SOURCE_SLICE_MAX_BYTES } from './source-read.js';
5
+ // [[node-attachments]]: a spec node's folder is the unit, and until now the board could see exactly one
6
+ // file in it. Everything else a node carries — its eval contract, an evidence directory, a raw capture, a
7
+ // working note written beside the spec that cites it — existed on disk and nowhere in the product.
8
+ //
9
+ // This is deliberately NOT the governed-source surface. `/api/source` answers to the coverage policy, and
10
+ // that policy excludes `.spec/**` on purpose: the spec tree is the product's own data, not code it governs.
11
+ // Loosening that predicate to reach these files would have broken the one invariant that keeps "what the
12
+ // product shows" and "what the product governs" the same set. So the gate is different and the window read
13
+ // is shared — which is the right way round.
14
+ // Bounded so a node that accumulates an evidence dump degrades into a long list rather than a wedged read.
15
+ const MAX_ENTRIES = 500;
16
+ const MAX_DEPTH = 4;
17
+ // The two files with their own surfaces: the body IS the node's document, and readings ARE the eval
18
+ // timeline. Listing them here would offer a second, worse way to read what the board already renders well.
19
+ const OWN_SURFACE = new Set(['spec.md', 'evals.ndjson']);
20
+ function nodeRoot(root, id) {
21
+ const dir = specDir(id);
22
+ if (!dir)
23
+ throw new SourceReadError(`no such spec node: ${id}`, 404);
24
+ return join(root, dir);
25
+ }
26
+ // Every read is confined to the node's own folder by RESOLVING and then checking containment, never by
27
+ // pattern-matching the input: a `..` that normalises back inside is fine, and one that escapes is caught
28
+ // wherever it came from.
29
+ function resolveInside(base, name) {
30
+ if (!name)
31
+ throw new SourceReadError('name is required', 400);
32
+ // An absolute name would be silently reinterpreted as relative by `join`. Containment would still hold,
33
+ // but the caller would get a confusing 404 about a path it did not mean; refusing it says what happened.
34
+ if (name.startsWith('/') || /^[A-Za-z]:[\\/]/.test(name))
35
+ throw new SourceReadError(`attachment name must be relative to the node's folder: ${name}`, 400);
36
+ const full = normalize(join(base, name));
37
+ const rel = relative(base, full);
38
+ if (!rel || rel.startsWith('..') || rel.split(sep).some((s) => s === '..'))
39
+ throw new SourceReadError(`attachment must stay inside the node's own folder: ${name}`, 400);
40
+ if (OWN_SURFACE.has(rel.split(sep)[rel.split(sep).length - 1]) && !rel.includes(sep))
41
+ throw new SourceReadError(`${rel} has its own surface in the dashboard, not an attachment`, 400);
42
+ return full;
43
+ }
44
+ export function listNodeAttachments(root, id) {
45
+ const base = nodeRoot(root, id);
46
+ const out = [];
47
+ const walk = (dir, depth) => {
48
+ if (depth > MAX_DEPTH || out.length >= MAX_ENTRIES)
49
+ return;
50
+ let entries = [];
51
+ try {
52
+ entries = readdirSync(dir);
53
+ }
54
+ catch {
55
+ return;
56
+ }
57
+ for (const entry of entries.sort()) {
58
+ if (out.length >= MAX_ENTRIES)
59
+ return;
60
+ const full = join(dir, entry);
61
+ let st;
62
+ try {
63
+ st = statSync(full);
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ if (st.isDirectory()) {
69
+ // A node's folder ends where a CHILD node's folder begins. A directory holding a spec.md is another
70
+ // node, and everything under it belongs to that node — listing it here would let a parent claim its
71
+ // children's evidence and make the same file appear under several nodes.
72
+ if (existsSync(join(full, 'spec.md')))
73
+ continue;
74
+ walk(full, depth + 1);
75
+ continue;
76
+ }
77
+ if (OWN_SURFACE.has(entry))
78
+ continue;
79
+ out.push({ name: relative(base, full).split(sep).join('/'), size: st.size });
80
+ }
81
+ };
82
+ walk(base, 0);
83
+ return out;
84
+ }
85
+ export function readNodeAttachment(root, id, name, offset = 0, limit = SOURCE_SLICE_MAX_BYTES) {
86
+ const base = nodeRoot(root, id);
87
+ const full = resolveInside(base, name);
88
+ return readSlice(full, name, offset, limit);
89
+ }
@@ -0,0 +1,23 @@
1
+ export declare class SpecBodyEditError extends Error {
2
+ readonly status: 400 | 404 | 409;
3
+ readonly code: string;
4
+ readonly detail?: Record<string, unknown> | undefined;
5
+ constructor(message: string, status: 400 | 404 | 409, code: string, detail?: Record<string, unknown> | undefined);
6
+ }
7
+ export type SpecBodyEdit = {
8
+ startLine: number;
9
+ endLine: number;
10
+ original: string;
11
+ replacement: string;
12
+ reason?: string;
13
+ };
14
+ export type SpecBodyEditResult = {
15
+ ok: true;
16
+ changed: boolean;
17
+ path: string;
18
+ commit: string | null;
19
+ startLine: number;
20
+ endLine: number;
21
+ };
22
+ export declare function readSpecBodyEdit(raw: unknown): SpecBodyEdit;
23
+ export declare function editSpecBody(id: string, patch: SpecBodyEdit): Promise<SpecBodyEditResult>;
@@ -0,0 +1,138 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseFrontmatter, repoRoot, specDir, gitTry } from '@spexcode/spec-core';
4
+ // [[spec-body-edit]]: the one seam through which a human at the board edits a spec BODY and lands it as a
5
+ // real commit — the "edit this file on the web" affordance, held to this project's law rather than
6
+ // GitHub's.
7
+ //
8
+ // Four structural guarantees, each enforced by construction rather than by checking a request field:
9
+ //
10
+ // 1. ONLY A SPEC BODY. The target file is DERIVED from the node id through the spec tree's own reader, so
11
+ // no request can name a path; and only the region between the frontmatter and the end of the file is
12
+ // rewritten, so no request can move a `code:` anchor, a status, or a single byte of source.
13
+ // 2. ONLY THE REGION THE READER SAW. The caller sends back the exact lines it rendered. If the file has
14
+ // moved underneath them the write is REFUSED with the current text — never merged, never guessed. Every
15
+ // such precondition is checked while the tree is still untouched, so a refusal costs nothing.
16
+ // 3. THE GATES STAY UP. The commit opens exactly ONE door in [[main-guard]] — `SPEXCODE_ALLOW_MAIN`, the
17
+ // named escape hatch — and never `--no-verify`. The neighbouring programmatic writers ([[local-issues]],
18
+ // [[human-ok]]) may skip the hook because their paths are unanchored DATA; a `spec.md` is the contract
19
+ // itself, so it must pass the same spec-lint gate a session's commit passes. A refusal is reported
20
+ // verbatim, with the tree put back the way it was found.
21
+ // 4. NOTHING IS STORED. The commit is the whole record: [[source-of-truth]] recomputes the node's version
22
+ // from its count of content commits, so the version bumps and drift re-derives with no extra bookkeeping.
23
+ export class SpecBodyEditError extends Error {
24
+ status;
25
+ code;
26
+ detail;
27
+ constructor(message, status, code, detail) {
28
+ super(message);
29
+ this.status = status;
30
+ this.code = code;
31
+ this.detail = detail;
32
+ }
33
+ }
34
+ // The body a line number addresses is the TRIMMED body — the same text `/api/specs/:id/content` serves and
35
+ // the same text the reader's line stamps were computed against. The file's leading and trailing whitespace
36
+ // is held aside and put back untouched, so an edit changes the region and nothing else in the file.
37
+ function splitFile(source) {
38
+ const { body } = parseFrontmatter(source);
39
+ const head = source.slice(0, source.length - body.length);
40
+ const lead = body.length - body.trimStart().length;
41
+ const trimmed = body.trim();
42
+ return { prefix: head + body.slice(0, lead), trimmed, suffix: body.slice(lead + trimmed.length) };
43
+ }
44
+ const isInt = (v) => Number.isInteger(v);
45
+ export function readSpecBodyEdit(raw) {
46
+ const b = (raw ?? {});
47
+ if (!isInt(b.startLine) || !isInt(b.endLine) || b.startLine < 1 || b.endLine < b.startLine) {
48
+ throw new SpecBodyEditError('body needs a 1-based line range { startLine, endLine } with endLine >= startLine', 400, 'bad-range');
49
+ }
50
+ if (typeof b.original !== 'string' || typeof b.replacement !== 'string') {
51
+ throw new SpecBodyEditError('body needs { original, replacement } — the text the reader saw and the text to put in its place', 400, 'bad-text');
52
+ }
53
+ return {
54
+ startLine: b.startLine,
55
+ endLine: b.endLine,
56
+ original: b.original,
57
+ replacement: b.replacement,
58
+ ...(typeof b.reason === 'string' && b.reason.trim() ? { reason: b.reason.trim() } : {}),
59
+ };
60
+ }
61
+ export async function editSpecBody(id, patch) {
62
+ const root = repoRoot();
63
+ const dir = specDir(id);
64
+ if (!dir)
65
+ throw new SpecBodyEditError(`no such spec node: ${id}`, 404, 'no-node');
66
+ // guarantee 1, made structural: the path came from the spec tree, and it must still look like one. A
67
+ // node whose folder resolved outside `.spec/` is a broken tree, not an edit target.
68
+ const relPath = `${dir}/spec.md`;
69
+ if (!relPath.startsWith('.spec/') || !relPath.endsWith('/spec.md') || relPath.includes('..')) {
70
+ throw new SpecBodyEditError(`node ${id} does not resolve to a spec body inside .spec/`, 404, 'no-node');
71
+ }
72
+ const file = join(root, relPath);
73
+ if (!existsSync(file))
74
+ throw new SpecBodyEditError(`node ${id} has no spec.md on disk`, 404, 'no-node');
75
+ const source = readFileSync(file, 'utf8');
76
+ const { prefix, trimmed, suffix } = splitFile(source);
77
+ const lines = trimmed.split('\n');
78
+ if (patch.endLine > lines.length) {
79
+ throw new SpecBodyEditError(`lines ${patch.startLine}-${patch.endLine} run past the end of the body (${lines.length} lines)`, 409, 'stale-region', { bodyLines: lines.length });
80
+ }
81
+ // guarantee 2. The reader's copy of the region is the precondition; a mismatch is a CONCURRENT MODIFICATION,
82
+ // reported with the text that is actually there so the human can see what moved.
83
+ const current = lines.slice(patch.startLine - 1, patch.endLine).join('\n');
84
+ if (current !== patch.original) {
85
+ throw new SpecBodyEditError(`lines ${patch.startLine}-${patch.endLine} of ${relPath} changed since they were read — the edit was not applied`, 409, 'stale-region', { current });
86
+ }
87
+ const next = prefix + [...lines.slice(0, patch.startLine - 1), ...patch.replacement.split('\n'), ...lines.slice(patch.endLine)].join('\n') + suffix;
88
+ if (next === source)
89
+ return { ok: true, changed: false, path: relPath, commit: null, startLine: patch.startLine, endLine: patch.endLine };
90
+ // Everything above is read-only, and deliberately: the caller gets the diagnosis that is actually about
91
+ // their edit before the tree's state is allowed to have an opinion. Only a real write needs a clean index
92
+ // — a path already carrying a STAGED change belongs to whoever staged it, and committing here would sweep
93
+ // their work into this edit's commit, so it refuses rather than guess whose change it is.
94
+ const staged = await gitTry(['-C', root, 'diff', '--cached', '--name-only', '--', relPath]);
95
+ if (staged.ok && staged.stdout.trim()) {
96
+ throw new SpecBodyEditError(`${relPath} already has a staged change — commit or unstage it before editing here`, 409, 'staged-conflict');
97
+ }
98
+ writeFileSync(file, next);
99
+ const restore = () => {
100
+ writeFileSync(file, source);
101
+ return gitTry(['-C', root, 'reset', '--quiet', '--', relPath]);
102
+ };
103
+ const message = [
104
+ `spec: ${id} — edited in the dashboard`,
105
+ '',
106
+ `Body lines ${patch.startLine}-${patch.endLine} replaced through the dashboard's spec editor ([[spec-body-edit]]).`,
107
+ ...(patch.reason ? ['', patch.reason] : []),
108
+ '',
109
+ // Server-derived, exactly as [[human-ok]] derives its actor: the identity of a board edit is the person
110
+ // at the board, and no request body gets to claim to be someone else.
111
+ 'Session: human',
112
+ ].join('\n');
113
+ const add = await gitTry(['-C', root, 'add', '--', relPath]);
114
+ if (!add.ok) {
115
+ await restore();
116
+ throw new SpecBodyEditError(`git could not stage ${relPath}: ${add.stderr.trim() || 'unknown failure'}`, 409, 'git-failed');
117
+ }
118
+ // guarantee 3: the ONE named door. `SPEXCODE_ALLOW_MAIN` waives only the main-authoring guard, so the
119
+ // spec-lint shim and the reference-transaction candidate gate both still judge this commit. On a
120
+ // node branch the flag changes nothing at all — the same call works from either checkout.
121
+ const commit = await gitTry(['-C', root, 'commit', '--quiet', '-m', message, '--', relPath], {
122
+ extraEnv: { SPEXCODE_ALLOW_MAIN: '1' },
123
+ });
124
+ if (!commit.ok) {
125
+ await restore();
126
+ const why = [commit.stderr, commit.stdout].map((s) => s.trim()).filter(Boolean).join('\n') || 'git commit failed with no output';
127
+ throw new SpecBodyEditError(`the commit was refused, so ${relPath} was put back unchanged:\n${why}`, 409, 'commit-refused');
128
+ }
129
+ const head = await gitTry(['-C', root, 'rev-parse', 'HEAD']);
130
+ return {
131
+ ok: true,
132
+ changed: true,
133
+ path: relPath,
134
+ commit: head.ok ? head.stdout.trim() : null,
135
+ startLine: patch.startLine,
136
+ endLine: patch.startLine + patch.replacement.split('\n').length - 1,
137
+ };
138
+ }
@@ -9,13 +9,13 @@ import { stat, readdir } from 'node:fs/promises';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { installProcessGuards } from '@spexcode/spec-core';
12
- import { listenOrExit } from './listen.js';
12
+ import { listenOrExit, resolveConfiguredPort } from './listen.js';
13
13
  import { resolvePublicConfig, startGateway, resolveDistDir } from './gateway.js';
14
14
  import { publishEndpoint, dropOwnEndpoint } from './host.js';
15
15
  import { repoRoot as servedRepoRoot } from '@spexcode/spec-core';
16
16
  import { resolveProjectIdentity } from '@spexcode/spec-core';
17
17
  import { startResourceMonitor } from './host-resources.js';
18
- import { registerBackendInstance, unregisterBackendInstance } from './runtime-ownership.js';
18
+ import { reapOrphanBackendInstances, registerBackendInstance, unregisterBackendInstance } from './runtime-ownership.js';
19
19
  import { sessionIdentityEnvVars } from './harness.js';
20
20
  import { serverEntrypointArgs } from './tsx-bin.js';
21
21
  // the supervisor OWNS the public port, so it must outlive any transient throw: an uncaught error here is
@@ -26,7 +26,14 @@ const packageRoot = join(here, '..');
26
26
  const sourceRoot = join(packageRoot, 'src');
27
27
  const workspaceRoot = existsSync(sourceRoot) ? join(packageRoot, '..') : null;
28
28
  const entryArgs = serverEntrypointArgs(packageRoot, here);
29
- const publicPort = Number(process.env.PORT || 8787);
29
+ let publicPort;
30
+ try {
31
+ publicPort = resolveConfiguredPort(process.env.PORT);
32
+ }
33
+ catch (error) {
34
+ console.error(`spec-cli: invalid PORT — ${error.message}`);
35
+ process.exit(2);
36
+ }
30
37
  const projectRoot = servedRepoRoot(); // the actual git tree whose source/spec/config the child serves
31
38
  // @@@ public mode ([[public-mode]]) - with `spex serve --public`, the supervisor is NOT the internet face:
32
39
  // the gateway is. The raw-TCP proxy retreats to a loopback internal port (the trusted boundary local agents
@@ -42,7 +49,7 @@ const childApiBase = `http://127.0.0.1:${proxyPort}`;
42
49
  // observe the source closure, compile it, then make the supervisor's normal zero-downtime swap. A source edit
43
50
  // must never cause a restart that still runs the old dist.
44
51
  const watchRoots = workspaceRoot
45
- ? [sourceRoot, join(workspaceRoot, 'spec-forge', 'src'), join(workspaceRoot, 'spec-eval', 'src'), join(workspaceRoot, 'packages', 'spec-core', 'src'), join(workspaceRoot, 'packages', 'session-core', 'src')]
52
+ ? [sourceRoot, join(workspaceRoot, 'spec-forge', 'src'), join(workspaceRoot, 'spec-eval', 'src'), join(workspaceRoot, 'packages', 'spec-core', 'src'), join(workspaceRoot, 'packages', 'transcript', 'src')]
46
53
  : [here];
47
54
  function buildWorkspace() {
48
55
  if (!workspaceRoot)
@@ -67,6 +74,8 @@ for (const key of sessionIdentityEnvVars())
67
74
  delete process.env[key];
68
75
  process.env.SPEXCODE_PROJECT_ROOT = projectRoot;
69
76
  process.env.SPEXCODE_INSTANCE_ID = instanceId;
77
+ for (const orphan of reapOrphanBackendInstances(projectRoot))
78
+ console.warn(`[supervisor] reaped stale backend ${orphan.instanceId} PID ${orphan.candidatePid}`);
70
79
  registerBackendInstance(instanceId, process.pid, projectRoot);
71
80
  let current = null; // which internal port new proxy connections forward to
72
81
  let reloading = false; // single-flight guard for reload()
@@ -246,11 +255,11 @@ catch { /* already gone */ } };
246
255
  if (publicCfg) {
247
256
  // public mode: the raw proxy stays on loopback; the password-gated gateway owns the public port.
248
257
  const distDir = resolveDistDir();
249
- listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: () => recordEndpoint(childApiBase), ready: `spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})` });
258
+ listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: (actualPort) => recordEndpoint(`http://127.0.0.1:${actualPort}`), ready: (actualPort) => `spec-cli supervisor on loopback :${actualPort} (zero-downtime reloads, backend :${first.port})` });
250
259
  startGateway({ publicPort, upstreamPort: proxyPort, password: publicCfg.password, tls: publicCfg.tls, distDir, onBindFail: reapChild });
251
260
  }
252
261
  else {
253
- listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () => recordEndpoint(childApiBase), ready: `spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})` });
262
+ listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: (actualPort) => recordEndpoint(`http://127.0.0.1:${actualPort}`), ready: (actualPort) => `spec-cli supervisor serving on http://localhost:${actualPort} (zero-downtime reloads, backend :${first.port})` });
254
263
  }
255
264
  startResourceMonitor();
256
265
  // Watch the source closure in a workspace and the emitted directory in an installed package. Debounce a