smol-symphony 0.2.0 → 0.3.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 (716) hide show
  1. package/AGENTS.md +41 -22
  2. package/DESIGN.md +494 -273
  3. package/README.md +109 -57
  4. package/SPEC.md +33 -24
  5. package/WORKFLOW.minimal.yaml +34 -0
  6. package/{WORKFLOW.template.md → WORKFLOW.template.yaml} +409 -256
  7. package/WORKFLOW.yaml +487 -0
  8. package/assets/skills/symphony-issues/SKILL.md +136 -0
  9. package/assets/symphony-mise.system.toml +68 -0
  10. package/dist/src/bin/symphony.js +30 -0
  11. package/dist/src/bin/symphony.js.map +1 -0
  12. package/dist/src/core/actions/context.js +109 -0
  13. package/dist/src/core/actions/context.js.map +1 -0
  14. package/dist/{actions/parsing.js → src/core/actions/parse.js} +33 -114
  15. package/dist/src/core/actions/parse.js.map +1 -0
  16. package/dist/src/core/actions/plan.js +197 -0
  17. package/dist/src/core/actions/plan.js.map +1 -0
  18. package/dist/src/core/actions/predicates.js +111 -0
  19. package/dist/src/core/actions/predicates.js.map +1 -0
  20. package/dist/src/core/actions/run-fold.js +248 -0
  21. package/dist/src/core/actions/run-fold.js.map +1 -0
  22. package/dist/src/core/actions/template.js +118 -0
  23. package/dist/src/core/actions/template.js.map +1 -0
  24. package/dist/src/core/cli/args.js +116 -0
  25. package/dist/src/core/cli/args.js.map +1 -0
  26. package/dist/src/core/coerce.js +75 -0
  27. package/dist/src/core/coerce.js.map +1 -0
  28. package/dist/src/core/credential/account-id.js +20 -0
  29. package/dist/src/core/credential/account-id.js.map +1 -0
  30. package/dist/src/core/credential/adapter-config.js +136 -0
  31. package/dist/src/core/credential/adapter-config.js.map +1 -0
  32. package/dist/src/core/credential/availability.js +98 -0
  33. package/dist/src/core/credential/availability.js.map +1 -0
  34. package/dist/src/core/credential/extract.js +228 -0
  35. package/dist/src/core/credential/extract.js.map +1 -0
  36. package/dist/src/core/credential/fake-creds.js +171 -0
  37. package/dist/src/core/credential/fake-creds.js.map +1 -0
  38. package/dist/src/core/credential/identity.js +125 -0
  39. package/dist/src/core/credential/identity.js.map +1 -0
  40. package/dist/src/core/credential/shape.js +230 -0
  41. package/dist/src/core/credential/shape.js.map +1 -0
  42. package/dist/src/core/credential/strings.js +15 -0
  43. package/dist/src/core/credential/strings.js.map +1 -0
  44. package/dist/src/core/doctor/checks.js +303 -0
  45. package/dist/src/core/doctor/checks.js.map +1 -0
  46. package/dist/src/core/git/result.js +107 -0
  47. package/dist/src/core/git/result.js.map +1 -0
  48. package/dist/src/core/http/decisions.js +225 -0
  49. package/dist/src/core/http/decisions.js.map +1 -0
  50. package/dist/{http.js → src/core/http/render.js} +472 -738
  51. package/dist/src/core/http/render.js.map +1 -0
  52. package/dist/{http-handlers.js → src/core/http/routes.js} +52 -87
  53. package/dist/src/core/http/routes.js.map +1 -0
  54. package/dist/src/core/http/views.js +181 -0
  55. package/dist/src/core/http/views.js.map +1 -0
  56. package/dist/src/core/image/managed-image.js +95 -0
  57. package/dist/src/core/image/managed-image.js.map +1 -0
  58. package/dist/src/core/issue/file.js +149 -0
  59. package/dist/src/core/issue/file.js.map +1 -0
  60. package/dist/src/core/issue/parse.js +210 -0
  61. package/dist/src/core/issue/parse.js.map +1 -0
  62. package/dist/src/core/mcp/dispatch.js +239 -0
  63. package/dist/src/core/mcp/dispatch.js.map +1 -0
  64. package/dist/src/core/mcp/post-move.js +92 -0
  65. package/dist/src/core/mcp/post-move.js.map +1 -0
  66. package/dist/src/core/mcp/protocol.js +293 -0
  67. package/dist/src/core/mcp/protocol.js.map +1 -0
  68. package/dist/src/core/mcp/url.js +162 -0
  69. package/dist/src/core/mcp/url.js.map +1 -0
  70. package/dist/src/core/path.js +63 -0
  71. package/dist/src/core/path.js.map +1 -0
  72. package/dist/src/core/reconcile/image-decide.js +48 -0
  73. package/dist/src/core/reconcile/image-decide.js.map +1 -0
  74. package/dist/src/core/reconcile/ledger.js +142 -0
  75. package/dist/src/core/reconcile/ledger.js.map +1 -0
  76. package/dist/src/core/reconcile/pr-classify.js +62 -0
  77. package/dist/src/core/reconcile/pr-classify.js.map +1 -0
  78. package/dist/{reconciler → src/core/reconcile}/pr-decide.js +25 -12
  79. package/dist/src/core/reconcile/pr-decide.js.map +1 -0
  80. package/dist/src/core/reconcile/pr-loop.js +161 -0
  81. package/dist/src/core/reconcile/pr-loop.js.map +1 -0
  82. package/dist/src/core/reconcile/pr-notes.js +35 -0
  83. package/dist/src/core/reconcile/pr-notes.js.map +1 -0
  84. package/dist/src/core/reconcile/vm-decide.js +70 -0
  85. package/dist/src/core/reconcile/vm-decide.js.map +1 -0
  86. package/dist/src/core/reconcile/vm-reap.js +207 -0
  87. package/dist/src/core/reconcile/vm-reap.js.map +1 -0
  88. package/dist/src/core/reconcile/workspace-decide.js +162 -0
  89. package/dist/src/core/reconcile/workspace-decide.js.map +1 -0
  90. package/dist/src/core/runlog/summary.js +231 -0
  91. package/dist/src/core/runlog/summary.js.map +1 -0
  92. package/dist/src/core/runner/dispatch-config.js +95 -0
  93. package/dist/src/core/runner/dispatch-config.js.map +1 -0
  94. package/dist/src/core/runner/injection.js +61 -0
  95. package/dist/src/core/runner/injection.js.map +1 -0
  96. package/dist/src/core/runner/mise.js +210 -0
  97. package/dist/src/core/runner/mise.js.map +1 -0
  98. package/dist/src/core/runner/prompt.js +720 -0
  99. package/dist/src/core/runner/prompt.js.map +1 -0
  100. package/dist/src/core/runner/turn.js +242 -0
  101. package/dist/src/core/runner/turn.js.map +1 -0
  102. package/dist/src/core/runner/vm-plan.js +390 -0
  103. package/dist/src/core/runner/vm-plan.js.map +1 -0
  104. package/dist/src/core/schedule/admission.js +123 -0
  105. package/dist/src/core/schedule/admission.js.map +1 -0
  106. package/dist/src/core/schedule/circuit-breaker.js +111 -0
  107. package/dist/src/core/schedule/circuit-breaker.js.map +1 -0
  108. package/dist/src/core/schedule/eligibility.js +83 -0
  109. package/dist/src/core/schedule/eligibility.js.map +1 -0
  110. package/dist/src/core/schedule/reconcile-issue.js +82 -0
  111. package/dist/src/core/schedule/reconcile-issue.js.map +1 -0
  112. package/dist/src/core/schedule/retry.js +96 -0
  113. package/dist/src/core/schedule/retry.js.map +1 -0
  114. package/dist/src/core/schedule/sleep-cycle.js +133 -0
  115. package/dist/src/core/schedule/sleep-cycle.js.map +1 -0
  116. package/dist/src/core/schedule/slots.js +124 -0
  117. package/dist/src/core/schedule/slots.js.map +1 -0
  118. package/dist/src/core/schedule/tick.js +553 -0
  119. package/dist/src/core/schedule/tick.js.map +1 -0
  120. package/dist/src/core/schedule/token-fold.js +181 -0
  121. package/dist/src/core/schedule/token-fold.js.map +1 -0
  122. package/dist/src/core/state-resolve.js +86 -0
  123. package/dist/src/core/state-resolve.js.map +1 -0
  124. package/dist/src/core/vm-guards.js +278 -0
  125. package/dist/src/core/vm-guards.js.map +1 -0
  126. package/dist/src/core/workflow/derive.js +107 -0
  127. package/dist/src/core/workflow/derive.js.map +1 -0
  128. package/dist/src/core/workflow/parse.js +687 -0
  129. package/dist/src/core/workflow/parse.js.map +1 -0
  130. package/dist/src/core/workflow/prompt-probe.js +78 -0
  131. package/dist/src/core/workflow/prompt-probe.js.map +1 -0
  132. package/dist/src/core/workflow/validate.js +189 -0
  133. package/dist/src/core/workflow/validate.js.map +1 -0
  134. package/dist/src/core/workspace-key.js +19 -0
  135. package/dist/src/core/workspace-key.js.map +1 -0
  136. package/dist/src/shell/actions-runner.js +356 -0
  137. package/dist/src/shell/actions-runner.js.map +1 -0
  138. package/dist/src/shell/adapter/adapter-registry.js +45 -0
  139. package/dist/src/shell/adapter/adapter-registry.js.map +1 -0
  140. package/dist/src/shell/adapter/clock-random.js +96 -0
  141. package/dist/src/shell/adapter/clock-random.js.map +1 -0
  142. package/dist/src/shell/adapter/gondolin-dispatch-helpers.js +158 -0
  143. package/dist/src/shell/adapter/gondolin-dispatch-helpers.js.map +1 -0
  144. package/dist/src/shell/adapter/gondolin-dispatch.js +385 -0
  145. package/dist/src/shell/adapter/gondolin-dispatch.js.map +1 -0
  146. package/dist/src/shell/adapter/gondolin-image-converter.js +233 -0
  147. package/dist/src/shell/adapter/gondolin-image-converter.js.map +1 -0
  148. package/dist/src/shell/adapter/gondolin-image-fetch.js +180 -0
  149. package/dist/src/shell/adapter/gondolin-image-fetch.js.map +1 -0
  150. package/dist/src/shell/adapter/launcher-asset.js +57 -0
  151. package/dist/src/shell/adapter/launcher-asset.js.map +1 -0
  152. package/dist/src/shell/adapter/mise-config-asset.js +65 -0
  153. package/dist/src/shell/adapter/mise-config-asset.js.map +1 -0
  154. package/dist/src/shell/adapter/workflow-loader.js +304 -0
  155. package/dist/src/shell/adapter/workflow-loader.js.map +1 -0
  156. package/dist/src/shell/cli/doctor.js +268 -0
  157. package/dist/src/shell/cli/doctor.js.map +1 -0
  158. package/dist/src/shell/effect-interpreter-families.js +314 -0
  159. package/dist/src/shell/effect-interpreter-families.js.map +1 -0
  160. package/dist/src/shell/effect-interpreter.js +29 -0
  161. package/dist/src/shell/effect-interpreter.js.map +1 -0
  162. package/dist/src/shell/interp/acp-frame.js +137 -0
  163. package/dist/src/shell/interp/acp-frame.js.map +1 -0
  164. package/dist/src/shell/interp/acp-ws-conn.js +320 -0
  165. package/dist/src/shell/interp/acp-ws-conn.js.map +1 -0
  166. package/dist/src/shell/interp/acp-ws-frames.js +159 -0
  167. package/dist/src/shell/interp/acp-ws-frames.js.map +1 -0
  168. package/dist/src/shell/interp/acp-ws.js +197 -0
  169. package/dist/src/shell/interp/acp-ws.js.map +1 -0
  170. package/dist/src/shell/interp/acp.js +319 -0
  171. package/dist/src/shell/interp/acp.js.map +1 -0
  172. package/dist/src/shell/interp/credential-defaults.js +128 -0
  173. package/dist/src/shell/interp/credential-defaults.js.map +1 -0
  174. package/dist/src/shell/interp/credential-hooks.js +149 -0
  175. package/dist/src/shell/interp/credential-hooks.js.map +1 -0
  176. package/dist/src/shell/interp/credential-registry.js +226 -0
  177. package/dist/src/shell/interp/credential-registry.js.map +1 -0
  178. package/dist/src/shell/interp/credential.js +103 -0
  179. package/dist/src/shell/interp/credential.js.map +1 -0
  180. package/dist/src/shell/interp/gh.js +163 -0
  181. package/dist/src/shell/interp/gh.js.map +1 -0
  182. package/dist/src/shell/interp/git.js +28 -0
  183. package/dist/src/shell/interp/git.js.map +1 -0
  184. package/dist/src/shell/interp/log.js +213 -0
  185. package/dist/src/shell/interp/log.js.map +1 -0
  186. package/dist/src/shell/interp/process.js +178 -0
  187. package/dist/src/shell/interp/process.js.map +1 -0
  188. package/dist/src/shell/interp/runlog.js +193 -0
  189. package/dist/src/shell/interp/runlog.js.map +1 -0
  190. package/dist/src/shell/interp/timer.js +64 -0
  191. package/dist/src/shell/interp/timer.js.map +1 -0
  192. package/dist/src/shell/interp/tracker-disk.js +99 -0
  193. package/dist/src/shell/interp/tracker-disk.js.map +1 -0
  194. package/dist/src/shell/interp/tracker-parse.js +71 -0
  195. package/dist/src/shell/interp/tracker-parse.js.map +1 -0
  196. package/dist/src/shell/interp/tracker-scan.js +238 -0
  197. package/dist/src/shell/interp/tracker-scan.js.map +1 -0
  198. package/dist/src/shell/interp/tracker-write.js +91 -0
  199. package/dist/src/shell/interp/tracker-write.js.map +1 -0
  200. package/dist/src/shell/interp/tracker.js +41 -0
  201. package/dist/src/shell/interp/tracker.js.map +1 -0
  202. package/dist/src/shell/interp/tty.js +48 -0
  203. package/dist/src/shell/interp/tty.js.map +1 -0
  204. package/dist/src/shell/interp/vm.js +199 -0
  205. package/dist/src/shell/interp/vm.js.map +1 -0
  206. package/dist/src/shell/interp/workspace.js +310 -0
  207. package/dist/src/shell/interp/workspace.js.map +1 -0
  208. package/dist/src/shell/main-acp.js +78 -0
  209. package/dist/src/shell/main-acp.js.map +1 -0
  210. package/dist/src/shell/main-adapters.js +222 -0
  211. package/dist/src/shell/main-adapters.js.map +1 -0
  212. package/dist/src/shell/main-credential.js +122 -0
  213. package/dist/src/shell/main-credential.js.map +1 -0
  214. package/dist/src/shell/main-doctor.js +22 -0
  215. package/dist/src/shell/main-doctor.js.map +1 -0
  216. package/dist/src/shell/main-entry.js +46 -0
  217. package/dist/src/shell/main-entry.js.map +1 -0
  218. package/dist/src/shell/main-http-csrf.js +45 -0
  219. package/dist/src/shell/main-http-csrf.js.map +1 -0
  220. package/dist/src/shell/main-http-handler.js +389 -0
  221. package/dist/src/shell/main-http-handler.js.map +1 -0
  222. package/dist/src/shell/main-http-mcp.js +122 -0
  223. package/dist/src/shell/main-http-mcp.js.map +1 -0
  224. package/dist/src/shell/main-http-views.js +253 -0
  225. package/dist/src/shell/main-http-views.js.map +1 -0
  226. package/dist/src/shell/main-http.js +76 -0
  227. package/dist/src/shell/main-http.js.map +1 -0
  228. package/dist/src/shell/main-loops.js +130 -0
  229. package/dist/src/shell/main-loops.js.map +1 -0
  230. package/dist/src/shell/main-mcp.js +129 -0
  231. package/dist/src/shell/main-mcp.js.map +1 -0
  232. package/dist/src/shell/main-orchestrator.js +120 -0
  233. package/dist/src/shell/main-orchestrator.js.map +1 -0
  234. package/dist/src/shell/main-preflight.js +43 -0
  235. package/dist/src/shell/main-preflight.js.map +1 -0
  236. package/dist/src/shell/main-reconcilers-helpers.js +244 -0
  237. package/dist/src/shell/main-reconcilers-helpers.js.map +1 -0
  238. package/dist/src/shell/main-reconcilers-pr.js +148 -0
  239. package/dist/src/shell/main-reconcilers-pr.js.map +1 -0
  240. package/dist/src/shell/main-reconcilers.js +225 -0
  241. package/dist/src/shell/main-reconcilers.js.map +1 -0
  242. package/dist/src/shell/main-runner.js +355 -0
  243. package/dist/src/shell/main-runner.js.map +1 -0
  244. package/dist/src/shell/main-scaffold.js +116 -0
  245. package/dist/src/shell/main-scaffold.js.map +1 -0
  246. package/dist/src/shell/main-shutdown.js +115 -0
  247. package/dist/src/shell/main-shutdown.js.map +1 -0
  248. package/dist/src/shell/main-startup.js +48 -0
  249. package/dist/src/shell/main-startup.js.map +1 -0
  250. package/dist/src/shell/main-substrates.js +43 -0
  251. package/dist/src/shell/main-substrates.js.map +1 -0
  252. package/dist/src/shell/main.js +385 -0
  253. package/dist/src/shell/main.js.map +1 -0
  254. package/dist/src/shell/orchestrator-feedback.js +69 -0
  255. package/dist/src/shell/orchestrator-feedback.js.map +1 -0
  256. package/dist/src/shell/orchestrator-image.js +167 -0
  257. package/dist/src/shell/orchestrator-image.js.map +1 -0
  258. package/dist/src/shell/orchestrator-loop.js +468 -0
  259. package/dist/src/shell/orchestrator-loop.js.map +1 -0
  260. package/dist/src/shell/orchestrator-reconcile.js +36 -0
  261. package/dist/src/shell/orchestrator-reconcile.js.map +1 -0
  262. package/dist/src/shell/reconciler-loop.js +228 -0
  263. package/dist/src/shell/reconciler-loop.js.map +1 -0
  264. package/dist/src/shell/runner-loop-turn.js +301 -0
  265. package/dist/src/shell/runner-loop-turn.js.map +1 -0
  266. package/dist/src/shell/runner-loop.js +338 -0
  267. package/dist/src/shell/runner-loop.js.map +1 -0
  268. package/dist/src/shell/server/http.js +208 -0
  269. package/dist/src/shell/server/http.js.map +1 -0
  270. package/dist/src/shell/server/mcp-runtime-effects.js +237 -0
  271. package/dist/src/shell/server/mcp-runtime-effects.js.map +1 -0
  272. package/dist/src/shell/server/mcp-runtime.js +99 -0
  273. package/dist/src/shell/server/mcp-runtime.js.map +1 -0
  274. package/dist/src/shell/workspace-key.js +14 -0
  275. package/dist/src/shell/workspace-key.js.map +1 -0
  276. package/dist/src/types/acp.js +8 -0
  277. package/dist/src/types/acp.js.map +1 -0
  278. package/dist/src/types/actions/plan.js +6 -0
  279. package/dist/src/types/actions/plan.js.map +1 -0
  280. package/dist/src/types/actions/predicates.js +6 -0
  281. package/dist/src/types/actions/predicates.js.map +1 -0
  282. package/dist/src/types/actions/run-fold.js +8 -0
  283. package/dist/src/types/actions/run-fold.js.map +1 -0
  284. package/dist/src/types/actions.js +7 -0
  285. package/dist/src/types/actions.js.map +1 -0
  286. package/dist/src/types/adapter/clock-random.js +4 -0
  287. package/dist/src/types/adapter/clock-random.js.map +1 -0
  288. package/dist/src/types/adapter/gondolin-image-converter.js +5 -0
  289. package/dist/src/types/adapter/gondolin-image-converter.js.map +1 -0
  290. package/dist/src/types/adapter/gondolin-image-fetch.js +5 -0
  291. package/dist/src/types/adapter/gondolin-image-fetch.js.map +1 -0
  292. package/dist/src/types/adapter/workflow-loader.js +4 -0
  293. package/dist/src/types/adapter/workflow-loader.js.map +1 -0
  294. package/dist/src/types/cli/args.js +8 -0
  295. package/dist/src/types/cli/args.js.map +1 -0
  296. package/dist/src/types/config.js +8 -0
  297. package/dist/src/types/config.js.map +1 -0
  298. package/dist/src/types/credential-interp.js +6 -0
  299. package/dist/src/types/credential-interp.js.map +1 -0
  300. package/dist/src/types/credentials.js +10 -0
  301. package/dist/src/types/credentials.js.map +1 -0
  302. package/dist/src/types/doctor.js +7 -0
  303. package/dist/src/types/doctor.js.map +1 -0
  304. package/dist/src/types/domain.js +7 -0
  305. package/dist/src/types/domain.js.map +1 -0
  306. package/dist/src/types/effect.js +15 -0
  307. package/dist/src/types/effect.js.map +1 -0
  308. package/dist/src/types/errors.js +39 -0
  309. package/dist/src/types/errors.js.map +1 -0
  310. package/dist/src/types/http/decisions.js +6 -0
  311. package/dist/src/types/http/decisions.js.map +1 -0
  312. package/dist/src/types/http/render.js +10 -0
  313. package/dist/src/types/http/render.js.map +1 -0
  314. package/dist/src/types/http/views.js +6 -0
  315. package/dist/src/types/http/views.js.map +1 -0
  316. package/dist/src/types/http.js +9 -0
  317. package/dist/src/types/http.js.map +1 -0
  318. package/dist/src/types/image/managed-image.js +7 -0
  319. package/dist/src/types/image/managed-image.js.map +1 -0
  320. package/dist/src/types/interp/effect-interpreter.js +8 -0
  321. package/dist/src/types/interp/effect-interpreter.js.map +1 -0
  322. package/dist/src/types/interp/tracker.js +7 -0
  323. package/dist/src/types/interp/tracker.js.map +1 -0
  324. package/dist/src/types/issue/file.js +6 -0
  325. package/dist/src/types/issue/file.js.map +1 -0
  326. package/dist/src/types/issue/parse.js +8 -0
  327. package/dist/src/types/issue/parse.js.map +1 -0
  328. package/dist/src/types/main-acp.js +13 -0
  329. package/dist/src/types/main-acp.js.map +1 -0
  330. package/dist/src/types/main-adapters.js +5 -0
  331. package/dist/src/types/main-adapters.js.map +1 -0
  332. package/dist/src/types/main-credential.js +21 -0
  333. package/dist/src/types/main-credential.js.map +1 -0
  334. package/dist/src/types/main-doctor.js +6 -0
  335. package/dist/src/types/main-doctor.js.map +1 -0
  336. package/dist/src/types/main-http-handler.js +12 -0
  337. package/dist/src/types/main-http-handler.js.map +1 -0
  338. package/dist/src/types/main-http.js +5 -0
  339. package/dist/src/types/main-http.js.map +1 -0
  340. package/dist/src/types/main-loops.js +5 -0
  341. package/dist/src/types/main-loops.js.map +1 -0
  342. package/dist/src/types/main-mcp.js +12 -0
  343. package/dist/src/types/main-mcp.js.map +1 -0
  344. package/dist/src/types/main-orchestrator.js +5 -0
  345. package/dist/src/types/main-orchestrator.js.map +1 -0
  346. package/dist/src/types/main-reconcilers.js +11 -0
  347. package/dist/src/types/main-reconcilers.js.map +1 -0
  348. package/dist/src/types/main-runner.js +13 -0
  349. package/dist/src/types/main-runner.js.map +1 -0
  350. package/dist/src/types/main-startup.js +5 -0
  351. package/dist/src/types/main-startup.js.map +1 -0
  352. package/dist/src/types/main-substrates.js +5 -0
  353. package/dist/src/types/main-substrates.js.map +1 -0
  354. package/dist/src/types/mcp/dispatch.js +4 -0
  355. package/dist/src/types/mcp/dispatch.js.map +1 -0
  356. package/dist/src/types/mcp/post-move.js +7 -0
  357. package/dist/src/types/mcp/post-move.js.map +1 -0
  358. package/dist/src/types/mcp.js +9 -0
  359. package/dist/src/types/mcp.js.map +1 -0
  360. package/dist/src/types/ports.js +12 -0
  361. package/dist/src/types/ports.js.map +1 -0
  362. package/dist/src/types/reconcile/image-decide.js +5 -0
  363. package/dist/src/types/reconcile/image-decide.js.map +1 -0
  364. package/dist/src/types/reconcile/ledger.js +7 -0
  365. package/dist/src/types/reconcile/ledger.js.map +1 -0
  366. package/dist/src/types/reconcile/pr-loop.js +8 -0
  367. package/dist/src/types/reconcile/pr-loop.js.map +1 -0
  368. package/dist/src/types/reconcile/vm-reap.js +8 -0
  369. package/dist/src/types/reconcile/vm-reap.js.map +1 -0
  370. package/dist/src/types/reconcile/workspace-decide.js +7 -0
  371. package/dist/src/types/reconcile/workspace-decide.js.map +1 -0
  372. package/dist/src/types/reconcile.js +9 -0
  373. package/dist/src/types/reconcile.js.map +1 -0
  374. package/dist/src/types/runlog.js +7 -0
  375. package/dist/src/types/runlog.js.map +1 -0
  376. package/dist/src/types/runner/actions-runner.js +12 -0
  377. package/dist/src/types/runner/actions-runner.js.map +1 -0
  378. package/dist/src/types/runner/gondolin-dispatch.js +5 -0
  379. package/dist/src/types/runner/gondolin-dispatch.js.map +1 -0
  380. package/dist/src/types/runner/injection.js +6 -0
  381. package/dist/src/types/runner/injection.js.map +1 -0
  382. package/dist/src/types/runner/runner-loop.js +5 -0
  383. package/dist/src/types/runner/runner-loop.js.map +1 -0
  384. package/dist/src/types/runner/turn.js +4 -0
  385. package/dist/src/types/runner/turn.js.map +1 -0
  386. package/dist/src/types/runner/vm-plan.js +4 -0
  387. package/dist/src/types/runner/vm-plan.js.map +1 -0
  388. package/dist/src/types/runtime.js +9 -0
  389. package/dist/src/types/runtime.js.map +1 -0
  390. package/dist/src/types/schedule/admission.js +7 -0
  391. package/dist/src/types/schedule/admission.js.map +1 -0
  392. package/dist/src/types/schedule/circuit-breaker.js +2 -0
  393. package/dist/src/types/schedule/circuit-breaker.js.map +1 -0
  394. package/dist/src/types/schedule/eligibility.js +9 -0
  395. package/dist/src/types/schedule/eligibility.js.map +1 -0
  396. package/dist/src/types/schedule/orchestrator-loop.js +10 -0
  397. package/dist/src/types/schedule/orchestrator-loop.js.map +1 -0
  398. package/dist/src/types/schedule/sleep-cycle.js +4 -0
  399. package/dist/src/types/schedule/sleep-cycle.js.map +1 -0
  400. package/dist/src/types/schedule/slots.js +8 -0
  401. package/dist/src/types/schedule/slots.js.map +1 -0
  402. package/dist/src/types/schedule/tick.js +9 -0
  403. package/dist/src/types/schedule/tick.js.map +1 -0
  404. package/dist/src/types/server/mcp-runtime.js +8 -0
  405. package/dist/src/types/server/mcp-runtime.js.map +1 -0
  406. package/dist/src/types/workflow/parse.js +4 -0
  407. package/dist/src/types/workflow/parse.js.map +1 -0
  408. package/dist/tests/core/account-id.test.js +35 -0
  409. package/dist/tests/core/account-id.test.js.map +1 -0
  410. package/dist/tests/core/actions-parse.test.js +176 -0
  411. package/dist/tests/core/actions-parse.test.js.map +1 -0
  412. package/dist/tests/core/adapter-config.test.js +133 -0
  413. package/dist/tests/core/adapter-config.test.js.map +1 -0
  414. package/dist/tests/core/admission.test.js +215 -0
  415. package/dist/tests/core/admission.test.js.map +1 -0
  416. package/dist/tests/core/args.test.js +132 -0
  417. package/dist/tests/core/args.test.js.map +1 -0
  418. package/dist/tests/core/availability.test.js +62 -0
  419. package/dist/tests/core/availability.test.js.map +1 -0
  420. package/dist/tests/core/checks.test.js +395 -0
  421. package/dist/tests/core/checks.test.js.map +1 -0
  422. package/dist/tests/core/circuit-breaker.test.js +172 -0
  423. package/dist/tests/core/circuit-breaker.test.js.map +1 -0
  424. package/dist/tests/core/coerce.test.js +87 -0
  425. package/dist/tests/core/coerce.test.js.map +1 -0
  426. package/dist/tests/core/context.test.js +228 -0
  427. package/dist/tests/core/context.test.js.map +1 -0
  428. package/dist/tests/core/decisions.test.js +310 -0
  429. package/dist/tests/core/decisions.test.js.map +1 -0
  430. package/dist/tests/core/derive.test.js +205 -0
  431. package/dist/tests/core/derive.test.js.map +1 -0
  432. package/dist/tests/core/dispatch-config.test.js +164 -0
  433. package/dist/tests/core/dispatch-config.test.js.map +1 -0
  434. package/dist/tests/core/dispatch.test.js +302 -0
  435. package/dist/tests/core/dispatch.test.js.map +1 -0
  436. package/dist/tests/core/eligibility.test.js +163 -0
  437. package/dist/tests/core/eligibility.test.js.map +1 -0
  438. package/dist/tests/core/extract.test.js +139 -0
  439. package/dist/tests/core/extract.test.js.map +1 -0
  440. package/dist/tests/core/fake-creds.test.js +134 -0
  441. package/dist/tests/core/fake-creds.test.js.map +1 -0
  442. package/dist/tests/core/file.test.js +197 -0
  443. package/dist/tests/core/file.test.js.map +1 -0
  444. package/dist/tests/core/git-result.test.js +113 -0
  445. package/dist/tests/core/git-result.test.js.map +1 -0
  446. package/dist/tests/core/identity.test.js +180 -0
  447. package/dist/tests/core/identity.test.js.map +1 -0
  448. package/dist/tests/core/image-decide.test.js +59 -0
  449. package/dist/tests/core/image-decide.test.js.map +1 -0
  450. package/dist/tests/core/injection.test.js +163 -0
  451. package/dist/tests/core/injection.test.js.map +1 -0
  452. package/dist/tests/core/ledger.test.js +218 -0
  453. package/dist/tests/core/ledger.test.js.map +1 -0
  454. package/dist/tests/core/managed-image.test.js +68 -0
  455. package/dist/tests/core/managed-image.test.js.map +1 -0
  456. package/dist/tests/core/mise.test.js +138 -0
  457. package/dist/tests/core/mise.test.js.map +1 -0
  458. package/dist/tests/core/parse.test.js +174 -0
  459. package/dist/tests/core/parse.test.js.map +1 -0
  460. package/dist/tests/core/path.test.js +50 -0
  461. package/dist/tests/core/path.test.js.map +1 -0
  462. package/dist/tests/core/plan.test.js +218 -0
  463. package/dist/tests/core/plan.test.js.map +1 -0
  464. package/dist/tests/core/post-move.test.js +162 -0
  465. package/dist/tests/core/post-move.test.js.map +1 -0
  466. package/dist/tests/core/pr-classify.test.js +117 -0
  467. package/dist/tests/core/pr-classify.test.js.map +1 -0
  468. package/dist/tests/core/pr-decide.test.js +298 -0
  469. package/dist/tests/core/pr-decide.test.js.map +1 -0
  470. package/dist/tests/core/pr-loop.test.js +301 -0
  471. package/dist/tests/core/pr-loop.test.js.map +1 -0
  472. package/dist/tests/core/pr-notes.test.js +165 -0
  473. package/dist/tests/core/pr-notes.test.js.map +1 -0
  474. package/dist/tests/core/predicates.test.js +154 -0
  475. package/dist/tests/core/predicates.test.js.map +1 -0
  476. package/dist/tests/core/prompt.test.js +189 -0
  477. package/dist/tests/core/prompt.test.js.map +1 -0
  478. package/dist/tests/core/protocol.test.js +195 -0
  479. package/dist/tests/core/protocol.test.js.map +1 -0
  480. package/dist/tests/core/reconcile-issue.test.js +116 -0
  481. package/dist/tests/core/reconcile-issue.test.js.map +1 -0
  482. package/dist/tests/core/render.test.js +549 -0
  483. package/dist/tests/core/render.test.js.map +1 -0
  484. package/dist/tests/core/retry.test.js +186 -0
  485. package/dist/tests/core/retry.test.js.map +1 -0
  486. package/dist/tests/core/routes.test.js +247 -0
  487. package/dist/tests/core/routes.test.js.map +1 -0
  488. package/dist/tests/core/run-fold.test.js +299 -0
  489. package/dist/tests/core/run-fold.test.js.map +1 -0
  490. package/dist/tests/core/shape.test.js +185 -0
  491. package/dist/tests/core/shape.test.js.map +1 -0
  492. package/dist/tests/core/sleep-cycle.test.js +150 -0
  493. package/dist/tests/core/sleep-cycle.test.js.map +1 -0
  494. package/dist/tests/core/slots.test.js +201 -0
  495. package/dist/tests/core/slots.test.js.map +1 -0
  496. package/dist/tests/core/state-resolve.test.js +80 -0
  497. package/dist/tests/core/state-resolve.test.js.map +1 -0
  498. package/dist/tests/core/summary.test.js +200 -0
  499. package/dist/tests/core/summary.test.js.map +1 -0
  500. package/dist/tests/core/template.test.js +116 -0
  501. package/dist/tests/core/template.test.js.map +1 -0
  502. package/dist/tests/core/tick.test.js +558 -0
  503. package/dist/tests/core/tick.test.js.map +1 -0
  504. package/dist/tests/core/token-fold.test.js +176 -0
  505. package/dist/tests/core/token-fold.test.js.map +1 -0
  506. package/dist/tests/core/turn.test.js +388 -0
  507. package/dist/tests/core/turn.test.js.map +1 -0
  508. package/dist/tests/core/url.test.js +118 -0
  509. package/dist/tests/core/url.test.js.map +1 -0
  510. package/dist/tests/core/validate.test.js +247 -0
  511. package/dist/tests/core/validate.test.js.map +1 -0
  512. package/dist/tests/core/views.test.js +252 -0
  513. package/dist/tests/core/views.test.js.map +1 -0
  514. package/dist/tests/core/vm-decide.test.js +110 -0
  515. package/dist/tests/core/vm-decide.test.js.map +1 -0
  516. package/dist/tests/core/vm-guards.test.js +153 -0
  517. package/dist/tests/core/vm-guards.test.js.map +1 -0
  518. package/dist/tests/core/vm-plan.test.js +332 -0
  519. package/dist/tests/core/vm-plan.test.js.map +1 -0
  520. package/dist/tests/core/vm-reap.test.js +196 -0
  521. package/dist/tests/core/vm-reap.test.js.map +1 -0
  522. package/dist/tests/core/workflow-parse.test.js +493 -0
  523. package/dist/tests/core/workflow-parse.test.js.map +1 -0
  524. package/dist/tests/core/workspace-decide.test.js +236 -0
  525. package/dist/tests/core/workspace-decide.test.js.map +1 -0
  526. package/dist/tests/helpers/fixtures.js +167 -0
  527. package/dist/tests/helpers/fixtures.js.map +1 -0
  528. package/dist/tests/shell/acp-substrate.test.js +101 -0
  529. package/dist/tests/shell/acp-substrate.test.js.map +1 -0
  530. package/dist/tests/shell/actions-runner-push.test.js +203 -0
  531. package/dist/tests/shell/actions-runner-push.test.js.map +1 -0
  532. package/dist/tests/shell/credential-hooks.test.js +36 -0
  533. package/dist/tests/shell/credential-hooks.test.js.map +1 -0
  534. package/dist/tests/shell/credential-registry.test.js +165 -0
  535. package/dist/tests/shell/credential-registry.test.js.map +1 -0
  536. package/dist/tests/shell/credential-substrate.test.js +179 -0
  537. package/dist/tests/shell/credential-substrate.test.js.map +1 -0
  538. package/dist/tests/shell/dockerfile-mise-pin.test.js +51 -0
  539. package/dist/tests/shell/dockerfile-mise-pin.test.js.map +1 -0
  540. package/dist/tests/shell/doctor.test.js +101 -0
  541. package/dist/tests/shell/doctor.test.js.map +1 -0
  542. package/dist/tests/shell/effect-vm-create.test.js +52 -0
  543. package/dist/tests/shell/effect-vm-create.test.js.map +1 -0
  544. package/dist/tests/shell/gh-port.test.js +63 -0
  545. package/dist/tests/shell/gh-port.test.js.map +1 -0
  546. package/dist/tests/shell/gondolin-dispatch-guard.test.js +144 -0
  547. package/dist/tests/shell/gondolin-dispatch-guard.test.js.map +1 -0
  548. package/dist/tests/shell/gondolin-dispatch-shquote.test.js +168 -0
  549. package/dist/tests/shell/gondolin-dispatch-shquote.test.js.map +1 -0
  550. package/dist/tests/shell/gondolin-image-converter.test.js +208 -0
  551. package/dist/tests/shell/gondolin-image-converter.test.js.map +1 -0
  552. package/dist/tests/shell/gondolin-image-fetch.test.js +93 -0
  553. package/dist/tests/shell/gondolin-image-fetch.test.js.map +1 -0
  554. package/dist/tests/shell/http-handler.test.js +608 -0
  555. package/dist/tests/shell/http-handler.test.js.map +1 -0
  556. package/dist/tests/shell/http-server.test.js +53 -0
  557. package/dist/tests/shell/http-server.test.js.map +1 -0
  558. package/dist/tests/shell/mcp-runtime.test.js +366 -0
  559. package/dist/tests/shell/mcp-runtime.test.js.map +1 -0
  560. package/dist/tests/shell/mise-config-asset.test.js +87 -0
  561. package/dist/tests/shell/mise-config-asset.test.js.map +1 -0
  562. package/dist/tests/shell/orchestrator-loop.test.js +583 -0
  563. package/dist/tests/shell/orchestrator-loop.test.js.map +1 -0
  564. package/dist/tests/shell/reconciler-passes.test.js +314 -0
  565. package/dist/tests/shell/reconciler-passes.test.js.map +1 -0
  566. package/dist/tests/shell/runner-loop-turn.test.js +97 -0
  567. package/dist/tests/shell/runner-loop-turn.test.js.map +1 -0
  568. package/dist/tests/shell/runner-slice.test.js +536 -0
  569. package/dist/tests/shell/runner-slice.test.js.map +1 -0
  570. package/dist/tests/shell/scaffold.test.js +65 -0
  571. package/dist/tests/shell/scaffold.test.js.map +1 -0
  572. package/dist/tests/shell/tick-config.test.js +83 -0
  573. package/dist/tests/shell/tick-config.test.js.map +1 -0
  574. package/dist/tests/shell/tracker-parse-dates.test.js +44 -0
  575. package/dist/tests/shell/tracker-parse-dates.test.js.map +1 -0
  576. package/dist/tests/shell/tracker-write-issue.test.js +154 -0
  577. package/dist/tests/shell/tracker-write-issue.test.js.map +1 -0
  578. package/dist/tests/shell/workflow-prompt-split.test.js +208 -0
  579. package/dist/tests/shell/workflow-prompt-split.test.js.map +1 -0
  580. package/dist/tests/shell/workspace-live-config.test.js +140 -0
  581. package/dist/tests/shell/workspace-live-config.test.js.map +1 -0
  582. package/package.json +21 -9
  583. package/patches/@earendil-works+gondolin+0.12.0.patch +173 -0
  584. package/prompts/Reflect.md +91 -0
  585. package/prompts/Review.md +97 -0
  586. package/prompts/Todo.md +96 -0
  587. package/prompts/_footer.md +41 -0
  588. package/prompts/_preamble.md +42 -0
  589. package/prompts-minimal/Todo.md +26 -0
  590. package/scripts/postinstall.mjs +63 -0
  591. package/scripts/vm-agent.mjs +312 -90
  592. package/WORKFLOW.md +0 -744
  593. package/dist/acp-bridge.js +0 -324
  594. package/dist/acp-bridge.js.map +0 -1
  595. package/dist/actions/cache.js +0 -191
  596. package/dist/actions/cache.js.map +0 -1
  597. package/dist/actions/effects.js +0 -41
  598. package/dist/actions/effects.js.map +0 -1
  599. package/dist/actions/executor.js +0 -570
  600. package/dist/actions/executor.js.map +0 -1
  601. package/dist/actions/index.js +0 -13
  602. package/dist/actions/index.js.map +0 -1
  603. package/dist/actions/parsing.js.map +0 -1
  604. package/dist/actions/predicate-env.js +0 -27
  605. package/dist/actions/predicate-env.js.map +0 -1
  606. package/dist/actions/predicates.js +0 -49
  607. package/dist/actions/predicates.js.map +0 -1
  608. package/dist/actions/templating.js +0 -66
  609. package/dist/actions/templating.js.map +0 -1
  610. package/dist/actions/types.js +0 -15
  611. package/dist/actions/types.js.map +0 -1
  612. package/dist/agent/acp.js +0 -473
  613. package/dist/agent/acp.js.map +0 -1
  614. package/dist/agent/adapter-names.js +0 -159
  615. package/dist/agent/adapter-names.js.map +0 -1
  616. package/dist/agent/adapters.js +0 -511
  617. package/dist/agent/adapters.js.map +0 -1
  618. package/dist/agent/credential-extractors.js +0 -342
  619. package/dist/agent/credential-extractors.js.map +0 -1
  620. package/dist/agent/credential-secrets.js +0 -628
  621. package/dist/agent/credential-secrets.js.map +0 -1
  622. package/dist/agent/credential-ticker.js +0 -57
  623. package/dist/agent/credential-ticker.js.map +0 -1
  624. package/dist/agent/gondolin-creds-staging.js +0 -356
  625. package/dist/agent/gondolin-creds-staging.js.map +0 -1
  626. package/dist/agent/gondolin-dispatch.js +0 -375
  627. package/dist/agent/gondolin-dispatch.js.map +0 -1
  628. package/dist/agent/gondolin.js +0 -124
  629. package/dist/agent/gondolin.js.map +0 -1
  630. package/dist/agent/runner-decisions.js +0 -134
  631. package/dist/agent/runner-decisions.js.map +0 -1
  632. package/dist/agent/runner.js +0 -1456
  633. package/dist/agent/runner.js.map +0 -1
  634. package/dist/agent/tool-call-summary.js +0 -102
  635. package/dist/agent/tool-call-summary.js.map +0 -1
  636. package/dist/agent/vm-acp-mapping.js +0 -73
  637. package/dist/agent/vm-acp-mapping.js.map +0 -1
  638. package/dist/agent/vm-guards.js +0 -262
  639. package/dist/agent/vm-guards.js.map +0 -1
  640. package/dist/agent/vm-port.js +0 -22
  641. package/dist/agent/vm-port.js.map +0 -1
  642. package/dist/agent/vm-process-registry.js +0 -79
  643. package/dist/agent/vm-process-registry.js.map +0 -1
  644. package/dist/bin/cli-args.js +0 -105
  645. package/dist/bin/cli-args.js.map +0 -1
  646. package/dist/bin/symphony.js +0 -794
  647. package/dist/bin/symphony.js.map +0 -1
  648. package/dist/errors.js +0 -15
  649. package/dist/errors.js.map +0 -1
  650. package/dist/http-disk.js +0 -135
  651. package/dist/http-disk.js.map +0 -1
  652. package/dist/http-handlers.js.map +0 -1
  653. package/dist/http.js.map +0 -1
  654. package/dist/issues.js +0 -178
  655. package/dist/issues.js.map +0 -1
  656. package/dist/logging.js +0 -203
  657. package/dist/logging.js.map +0 -1
  658. package/dist/mcp.js +0 -706
  659. package/dist/mcp.js.map +0 -1
  660. package/dist/memory.js +0 -85
  661. package/dist/memory.js.map +0 -1
  662. package/dist/orchestrator-decisions.js +0 -331
  663. package/dist/orchestrator-decisions.js.map +0 -1
  664. package/dist/orchestrator.js +0 -1569
  665. package/dist/orchestrator.js.map +0 -1
  666. package/dist/prompt.js +0 -65
  667. package/dist/prompt.js.map +0 -1
  668. package/dist/reconciler/cache.js +0 -65
  669. package/dist/reconciler/cache.js.map +0 -1
  670. package/dist/reconciler/index.js +0 -448
  671. package/dist/reconciler/index.js.map +0 -1
  672. package/dist/reconciler/ledger.js +0 -131
  673. package/dist/reconciler/ledger.js.map +0 -1
  674. package/dist/reconciler/pr-adapters.js +0 -174
  675. package/dist/reconciler/pr-adapters.js.map +0 -1
  676. package/dist/reconciler/pr-decide.js.map +0 -1
  677. package/dist/reconciler/pr.js +0 -422
  678. package/dist/reconciler/pr.js.map +0 -1
  679. package/dist/reconciler/types.js +0 -12
  680. package/dist/reconciler/types.js.map +0 -1
  681. package/dist/reconciler/vm.js +0 -243
  682. package/dist/reconciler/vm.js.map +0 -1
  683. package/dist/reconciler/workspace-defaults.js +0 -83
  684. package/dist/reconciler/workspace-defaults.js.map +0 -1
  685. package/dist/reconciler/workspace.js +0 -272
  686. package/dist/reconciler/workspace.js.map +0 -1
  687. package/dist/runlog.js +0 -403
  688. package/dist/runlog.js.map +0 -1
  689. package/dist/scaffold.js +0 -165
  690. package/dist/scaffold.js.map +0 -1
  691. package/dist/trackers/local.js +0 -445
  692. package/dist/trackers/local.js.map +0 -1
  693. package/dist/trackers/types.js +0 -10
  694. package/dist/trackers/types.js.map +0 -1
  695. package/dist/types.js +0 -3
  696. package/dist/types.js.map +0 -1
  697. package/dist/util/clock.js +0 -12
  698. package/dist/util/clock.js.map +0 -1
  699. package/dist/util/crypto.js +0 -25
  700. package/dist/util/crypto.js.map +0 -1
  701. package/dist/util/frontmatter.js +0 -70
  702. package/dist/util/frontmatter.js.map +0 -1
  703. package/dist/util/fs-issues.js +0 -22
  704. package/dist/util/fs-issues.js.map +0 -1
  705. package/dist/util/process.js +0 -152
  706. package/dist/util/process.js.map +0 -1
  707. package/dist/util/workspace-key.js +0 -10
  708. package/dist/util/workspace-key.js.map +0 -1
  709. package/dist/workflow-loader.js +0 -147
  710. package/dist/workflow-loader.js.map +0 -1
  711. package/dist/workflow.js +0 -822
  712. package/dist/workflow.js.map +0 -1
  713. package/dist/workspace-types.js +0 -8
  714. package/dist/workspace-types.js.map +0 -1
  715. package/dist/workspace.js +0 -443
  716. package/dist/workspace.js.map +0 -1
@@ -1,145 +1,42 @@
1
- // HTTP server extension (SPEC §13.7) plus the local-tracker UI for creating issues and
2
- // watching status. The UI polls `/api/v1/state` so no SSE/WebSocket infrastructure is
3
- // needed.
4
- import { createServer } from 'node:http';
5
- import { readFile } from 'node:fs/promises';
6
- import path from 'node:path';
7
- import { log } from './logging.js';
8
- import { writeIssueFile } from './issues.js';
9
- import { matchRoute, resolvePartialName, extractBearerToken, classifyContentType, checkSteeringCsrf, checkTriageCsrf, extractFormText, extractJsonText, decideCreateIssue, decideTriageTransition, } from './http-handlers.js';
10
- import { listIssuesFromDisk, readIssueFromDisk, } from './http-disk.js';
11
- function jsonResponse(res, status, body) {
12
- const text = JSON.stringify(body);
13
- res.statusCode = status;
14
- res.setHeader('content-type', 'application/json; charset=utf-8');
15
- res.setHeader('content-length', Buffer.byteLength(text));
16
- res.end(text);
17
- }
18
- function methodNotAllowed(res) {
19
- jsonResponse(res, 405, { error: { code: 'method_not_allowed', message: 'method not allowed' } });
20
- }
21
- function notFound(res, code = 'not_found', message = 'not found') {
22
- jsonResponse(res, 404, { error: { code, message } });
23
- }
24
- function badRequest(res, message) {
25
- jsonResponse(res, 400, { error: { code: 'bad_request', message } });
26
- }
27
- async function readJsonBody(req, maxBytes = 1_000_000) {
28
- return new Promise((resolve, reject) => {
29
- const chunks = [];
30
- let size = 0;
31
- req.on('data', (chunk) => {
32
- size += chunk.length;
33
- if (size > maxBytes) {
34
- req.destroy();
35
- reject(new Error('request body too large'));
36
- return;
37
- }
38
- chunks.push(chunk);
39
- });
40
- req.on('end', () => {
41
- const text = Buffer.concat(chunks).toString('utf8');
42
- if (text.length === 0)
43
- return resolve({});
44
- try {
45
- resolve(JSON.parse(text));
46
- }
47
- catch (err) {
48
- reject(new Error(`invalid JSON: ${err.message}`));
49
- }
50
- });
51
- req.on('error', reject);
52
- });
53
- }
54
- // Cross-site form POSTs are "simple" CORS requests that bypass preflight, so an
55
- // unauthenticated endpoint that accepts form-encoded bodies is CSRFable from any
56
- // origin. Steering reply uses this check (in addition to requiring HX-Request) to
57
- // reject form bodies whose Origin doesn't match the Host the request hit.
1
+ // FCIS rewrite PURE HTML/markdown render layer for the dashboard + issue-detail
2
+ // pages and the HTMX partials. Ported faithfully from the original src/http.ts
3
+ // render functions (renderDashboardHtml, board/column/row partials, header/health/
4
+ // attention partials, renderSteeringInline, renderNewIssuePanel, renderTotalsPartial,
5
+ // renderIssueDetailPage, the minimal Markdown engine, and the small formatters).
58
6
  //
59
- // We treat the request as same-origin if either:
60
- // An Origin header is present and its host:port equals the Host header (the
61
- // normal browser case), or
62
- // There is no Origin header at all AND no Referer header (curl, internal calls,
63
- // non-browser tools). Browsers always send Origin on cross-origin form POSTs.
64
- function isSameOriginRequest(req) {
65
- const host = (req.headers['host'] ?? '').toString().trim();
66
- const origin = (req.headers['origin'] ?? '').toString().trim();
67
- if (origin) {
68
- try {
69
- const parsed = new URL(origin);
70
- return parsed.host === host;
71
- }
72
- catch {
73
- return false;
74
- }
75
- }
76
- // No Origin header only trust if there's no Referer either (a real browser
77
- // form POST sets at least one).
78
- return !(req.headers['referer'] && req.headers['referer'].length > 0);
79
- }
80
- // HTMX's default response handling does not swap content on 4xx/5xx responses, so
81
- // returning a 4xx with an HTML partial silently leaves the region stale. For HTMX
82
- // callers (currently only steering-reply, which targets the #ticker region) we
83
- // return 200 with the ticker partial plus an inline error chip; the operator's
84
- // textarea content sits in the board row (separate hx-preserve cycle) and is
85
- // unaffected. JSON callers still get the appropriate status code and a structured
86
- // error.
87
- async function htmxOrJsonError(res, isHtmx, orch, view, jsonStatus, code, message) {
88
- if (isHtmx) {
89
- const p = await gatherPartialInputs(orch, view);
90
- res.statusCode = 200;
91
- res.setHeader('content-type', 'text/html; charset=utf-8');
92
- res.setHeader('cache-control', 'no-store');
93
- res.end(renderAttentionPartial(p, { errorMessage: message }));
94
- return;
95
- }
96
- jsonResponse(res, jsonStatus, { error: { code, message } });
97
- }
98
- async function readTextBody(req, maxBytes = 1_000_000) {
99
- return new Promise((resolve, reject) => {
100
- const chunks = [];
101
- let size = 0;
102
- req.on('data', (chunk) => {
103
- size += chunk.length;
104
- if (size > maxBytes) {
105
- req.destroy();
106
- reject(new Error('request body too large'));
107
- return;
108
- }
109
- chunks.push(chunk);
110
- });
111
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
112
- req.on('error', reject);
113
- });
114
- }
115
- async function gatherPartialInputs(orch, view) {
116
- const trackerRoot = view.trackerRoot ?? '(unset)';
117
- let diskIssues = [];
118
- if (view.trackerRoot) {
119
- try {
120
- diskIssues = await listIssuesFromDisk(view.trackerRoot);
121
- }
122
- catch {
123
- diskIssues = [];
124
- }
125
- }
126
- return {
127
- workflowName: path.basename(view.workflowPath || 'workflow.md'),
128
- workflowPath: view.workflowPath || '',
129
- trackerRoot,
130
- states: view.states,
131
- snapshot: orch.snapshot(),
132
- diskIssues,
133
- };
7
+ // EVERYTHING here is (data) -> string. ZERO IO, ZERO async, ZERO node: imports, no
8
+ // process / fetch / wall-clock / randomness. Imports ONLY from src/types/**.
9
+ //
10
+ // Two faithful-port notes on purity:
11
+ // formatTimeShort EXTRACTS the HH:MM:SS field straight out of a supplied ISO
12
+ // string with a regex — no `new Date`, no clock read, no randomness — so it
13
+ // is a pure (string)->(string) transform that the determinism gate accepts.
14
+ // It renders the UTC time component of the timestamp (the upstream values are
15
+ // always `…Z` produced by toISOString), giving a stable 24-hour clock.
16
+ // • basename() comes from the pure core/path.ts helper (no `node:path`) so the
17
+ // workflow name can be derived from a path string purely.
18
+ //
19
+ // The render-input view types (RunningRow / RetryRow / HealthView / PartialInputs
20
+ // / RenderSnapshot / TotalsView / DetailView) live in src/types/http/render.ts
21
+ // per the hexagonal rule; the four the unit test imports are re-exported below so
22
+ // existing importers keep their specifier.
23
+ import { asString, asStringList, asInt, asTimestamp } from '../coerce.js';
24
+ import { basename } from '../path.js';
25
+ import { isInFlowTransition } from '../state-resolve.js';
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // Tiny formatters / escaping
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ const HTML_ESCAPES = {
30
+ '&': '&',
31
+ '<': '&lt;',
32
+ '>': '&gt;',
33
+ '"': '&quot;',
34
+ "'": '&#39;',
35
+ };
36
+ export function escapeHtml(s) {
37
+ return s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
134
38
  }
135
- /**
136
- * Map a declared state name to its pill colour class. Active states pulse green
137
- * (`running`), terminals settle into the muted "done" palette, holdings (Triage)
138
- * use the same idle palette they always have. Unknown names — issues sitting in a
139
- * directory that's no longer declared in `states:` — fall back to idle so the
140
- * dashboard still renders something legible.
141
- */
142
- function pillClassForState(states, stateName) {
39
+ export function pillClassForState(states, stateName) {
143
40
  const lower = stateName.toLowerCase();
144
41
  const match = states.find((s) => s.name.toLowerCase() === lower);
145
42
  if (!match)
@@ -165,14 +62,14 @@ function trackerStatus(snap) {
165
62
  return 'working';
166
63
  return 'idle';
167
64
  }
168
- function formatTokens(n) {
65
+ export function formatTokens(n) {
169
66
  if (n < 1000)
170
67
  return String(n);
171
68
  if (n < 1_000_000)
172
69
  return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
173
70
  return `${(n / 1_000_000).toFixed(1)}M`;
174
71
  }
175
- function formatRuntime(seconds) {
72
+ export function formatRuntime(seconds) {
176
73
  const s = Math.max(0, Math.round(seconds));
177
74
  if (s < 60)
178
75
  return `${s}s`;
@@ -183,42 +80,121 @@ function formatRuntime(seconds) {
183
80
  const rem = m % 60;
184
81
  return rem === 0 ? `${h}h` : `${h}h${rem}m`;
185
82
  }
186
- // `hour12: false` pins the 24-hour clock so the dashboard reads e.g. `14:30:05`
187
- // instead of locale-default AM/PM (`02:30:05 PM`) on 12-hour locales like en-US.
83
+ // Lifts the `HH:MM:SS` field straight out of an ISO timestamp (the upstream
84
+ // values are always `…Z` from toISOString), giving a stable 24-hour clock such
85
+ // as `14:30:05`. PURE: a regex over the supplied string — no `new Date`, no
86
+ // current-wall-clock read — so the determinism gate accepts it. Returns '' for a
87
+ // nullish or non-ISO input.
88
+ const ISO_TIME_RE = /T(\d{2}:\d{2}:\d{2})/;
188
89
  export function formatTimeShort(iso) {
189
- if (!iso)
190
- return '';
191
- const d = new Date(iso);
192
- if (Number.isNaN(d.getTime()))
193
- return '';
194
- return d.toLocaleTimeString(undefined, {
195
- hour: '2-digit',
196
- minute: '2-digit',
197
- second: '2-digit',
198
- hour12: false,
199
- });
90
+ return (iso && ISO_TIME_RE.exec(iso)?.[1]) || '';
200
91
  }
201
- function truncate(text, max) {
92
+ export function truncate(text, max) {
202
93
  if (!text)
203
94
  return '';
204
95
  const trimmed = text.replace(/\s+/g, ' ').trim();
205
96
  return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed;
206
97
  }
207
- // The header partial returns ONLY the tracker-state badge content. Brand, workflow name,
208
- // tracker root, and the refresh button live in the static shell and never repoll, so the
209
- // 2s heartbeat doesn't flash the whole strip.
210
- function renderHeaderPartial(p) {
98
+ // ─────────────────────────────────────────────────────────────────────────────
99
+ // Header + system-health strip
100
+ // ─────────────────────────────────────────────────────────────────────────────
101
+ export function renderHeaderPartial(p) {
211
102
  const status = trackerStatus(p.snapshot);
212
103
  const statusLabel = status === 'attention' ? 'attention' : status === 'working' ? 'working' : 'idle';
213
104
  return `<span class="badge badge-${status}" aria-label="tracker state: ${statusLabel}">${statusLabel}</span>`;
214
105
  }
215
- // ─── Top-of-page attention ticker ───────────────────────────────────────
216
- // A horizontal strip listing what currently needs the operator's eyes: pending
217
- // steering replies and stuck retries. Each identifier is an anchor link to the
218
- // row inside the board the operator scans the ticker, jumps to the column,
219
- // and replies in place. The steering reply form itself now lives inline on its
220
- // row in the board, so this ticker stays thin.
221
- function renderAttentionPartial(p, opts) {
106
+ // Shorten a gondolin image ref for display: a content-addressed hex digest
107
+ // collapses to its first 12 chars (the conventional "short digest"), a long
108
+ // opaque ref is clipped, and a plain `name:tag` is shown as-is. The full value
109
+ // always rides along in the field title so the exact identifier is never lost.
110
+ function shortenImageRef(image) {
111
+ if (!image)
112
+ return 'unset';
113
+ const hex = /([0-9a-f]{12,64})/i.exec(image);
114
+ if (hex) {
115
+ const short = hex[1].slice(0, 12);
116
+ return image.startsWith('sha256:') ? `sha256:${short}` : short;
117
+ }
118
+ return image.length > 28 ? `${image.slice(0, 27)}…` : image;
119
+ }
120
+ function credentialField(creds) {
121
+ if (creds.length === 0)
122
+ return { key: 'cred', value: 'n/a' };
123
+ const missing = creds.filter((c) => !c.ok).map((c) => c.adapter);
124
+ if (missing.length === 0)
125
+ return { key: 'cred', value: 'ok' };
126
+ return { key: 'cred', value: `missing:${missing.join(',')}`, bad: true };
127
+ }
128
+ function trackerField(writable) {
129
+ if (writable === null)
130
+ return { key: 'tracker', value: 'unset', bad: true };
131
+ return writable
132
+ ? { key: 'tracker', value: 'writable' }
133
+ : { key: 'tracker', value: 'readonly', bad: true };
134
+ }
135
+ // One `<State>=<adapter>/<model>` field per active state, with per-state
136
+ // overrides applied (a state inheriting the adapter's default renders
137
+ // `/default`). Reported per state — never a single global `model=` — so the
138
+ // readout cannot imply a default model for a state that overrides it.
139
+ function dispatchFields(dispatch) {
140
+ if (dispatch.length === 0)
141
+ return [{ key: 'dispatch', value: '(no active states)' }];
142
+ return dispatch.map((d) => ({ key: d.state, value: `${d.adapter}/${d.model ?? 'default'}` }));
143
+ }
144
+ function healthFields(health) {
145
+ return [
146
+ ...dispatchFields(health.dispatch),
147
+ { key: 'image', value: shortenImageRef(health.gondolin_image), full: health.gondolin_image ?? 'unset' },
148
+ credentialField(health.credentials),
149
+ trackerField(health.tracker_root_writable),
150
+ { key: 'poll', value: formatTimeShort(health.last_poll_at) || '—' },
151
+ ];
152
+ }
153
+ function renderHealthField(f) {
154
+ const cls = `hf${f.bad ? ' hf-bad' : ''}`;
155
+ const title = f.full && f.full !== f.value ? ` title="${escapeHtml(f.full)}"` : '';
156
+ return `<span class="${cls}"${title}>${escapeHtml(`${f.key}=${f.value}`)}</span>`;
157
+ }
158
+ // One-time microVM image build banner (issue 206): while the reconcile loop is
159
+ // converting `gondolin.oci_image` into a Gondolin asset, surface a prominent
160
+ // (error-coloured, so it reads as "attention, but expected") banner explaining the
161
+ // hold so "why is nothing dispatching" has an answer. Cleared once the asset caches.
162
+ function renderBuildingImageBanner(building) {
163
+ const phase = building.phase ? ` · ${escapeHtml(building.phase)}` : '';
164
+ return (`<span class="hf hf-bad" title="${escapeHtml(building.ref)}">` +
165
+ `building microVM image (one-time setup) — this can take several minutes${phase}` +
166
+ `</span>`);
167
+ }
168
+ // Failed one-time microVM image build banner (issue 206 review): the conversion
169
+ // threw and dispatch stays HELD (the reconcile gate suppresses an auto-retry for
170
+ // this digest), so surface a PERSISTENT error banner with the failing ref + the
171
+ // converter's message + remediation — otherwise a failed build reads as "nothing is
172
+ // dispatching" with no explanation. Stays until the ref/digest converts or a fresh
173
+ // digest supersedes it.
174
+ function renderFailedImageBanner(failed) {
175
+ const detail = failed.message ? ` — ${escapeHtml(failed.message)}` : '';
176
+ return (`<span class="hf hf-bad" title="${escapeHtml(failed.ref)}">` +
177
+ `microVM image build failed (VM dispatch held)${detail} — ` +
178
+ `fix gondolin.oci_image or push a new image, then restart symphony to retry` +
179
+ `</span>`);
180
+ }
181
+ export function renderHealthPartial(health) {
182
+ if (!health)
183
+ return `<span class="hf">health=unavailable</span>`;
184
+ // An in-flight build takes precedence over a prior failure (they're mutually
185
+ // exclusive at the source, but render defensively): show "building" while
186
+ // converting, the failure banner only once the build cleared but dispatch is held.
187
+ const banner = health.building_image
188
+ ? renderBuildingImageBanner(health.building_image)
189
+ : health.failed_image
190
+ ? renderFailedImageBanner(health.failed_image)
191
+ : '';
192
+ return banner + healthFields(health).map(renderHealthField).join('');
193
+ }
194
+ // ─────────────────────────────────────────────────────────────────────────────
195
+ // Top-of-page attention ticker
196
+ // ─────────────────────────────────────────────────────────────────────────────
197
+ export function renderAttentionPartial(p, opts) {
222
198
  const awaiting = p.snapshot.running.filter((r) => r.steering_requested);
223
199
  // Continuations are calm post-transition resumes, not failures — keep them
224
200
  // off the attention ticker so a clean handoff doesn't read as a stuck retry
@@ -237,9 +213,7 @@ function renderAttentionPartial(p, opts) {
237
213
  const links = retrying
238
214
  .map((r) => `<a href="#row-${escapeHtml(r.issue_identifier)}" class="tick-id">${escapeHtml(r.issue_identifier)}</a>`)
239
215
  .join(' ');
240
- const label = stuck > 0
241
- ? `${retrying.length} retrying · ${stuck} with error`
242
- : `${retrying.length} retrying`;
216
+ const label = stuck > 0 ? `${retrying.length} retrying · ${stuck} with error` : `${retrying.length} retrying`;
243
217
  segments.push(`<span class="tick tick-retry"><span class="tick-label">${label}</span> ${links}</span>`);
244
218
  }
245
219
  if (errorMessage) {
@@ -247,28 +221,20 @@ function renderAttentionPartial(p, opts) {
247
221
  }
248
222
  return segments.join('');
249
223
  }
250
- // ─── Board (kanban) ─────────────────────────────────────────────────────
251
- // One column per declared state in workflow declaration order. Each on-disk
252
- // issue renders as a flat row in its state's column; transient orchestrator
253
- // state (running / retrying / awaiting steering) is overlaid via a pill, a
254
- // metadata trail, and — for awaiting — an inline question + reply form. Issues
255
- // sitting in an undeclared state directory (e.g. after a workflow rename) get
256
- // trailing columns so they stay visible until reconciled rather than silently
257
- // dropping out of the dashboard.
258
- function renderBoardPartial(p) {
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+ // Board (kanban)
226
+ // ─────────────────────────────────────────────────────────────────────────────
227
+ export function renderBoardPartial(p) {
259
228
  const runningById = new Map();
260
229
  for (const r of p.snapshot.running)
261
230
  runningById.set(r.issue_identifier, r);
262
231
  const retryingById = new Map();
263
232
  for (const r of p.snapshot.retrying)
264
233
  retryingById.set(r.issue_identifier, r);
265
- // Group disk issues by lower-cased state name. LocalMarkdownTracker matches
266
- // state directories case-insensitively against the declared `states:` keys
267
- // (src/trackers/local.ts §scanAllAt: `declared.has(dirEntry.toLowerCase())`),
268
- // so the dashboard must mirror that or a `todo/` directory under declared
269
- // `Todo` would be misclassified as an orphan and split off into its own
270
- // trailing column. `displayName` keeps the on-disk casing for orphan column
271
- // headers (the only place we need to show a non-declared name).
234
+ // Group disk issues by lower-cased state name (LocalMarkdownTracker matches
235
+ // state directories case-insensitively against declared `states:` keys), so a
236
+ // `todo/` dir under declared `Todo` is not misclassified as an orphan.
237
+ // `displayName` keeps the on-disk casing for orphan column headers.
272
238
  const byStateLower = new Map();
273
239
  for (const i of p.diskIssues) {
274
240
  const key = i.state.toLowerCase();
@@ -279,22 +245,17 @@ function renderBoardPartial(p) {
279
245
  byStateLower.set(key, { displayName: i.state, items: [i] });
280
246
  }
281
247
  // Terminal columns (Done, Cancelled, …) are deliberately omitted from the
282
- // board. The orchestrator's glance test "is anything stuck, is anything
283
- // running" — doesn't read off finished work, and a wall of archived rows
284
- // dilutes the active surface. Terminal issues stay reachable through their
285
- // /issues/<id> detail page; a dedicated history surface is the natural place
286
- // to surface them again once we have something to anchor the row on (a PR
287
- // link, a final token total, a closed-at timestamp).
248
+ // board: the glance test "is anything stuck, is anything running" doesn't read
249
+ // off finished work. Terminal issues stay reachable through /issues/<id>.
288
250
  const declared = p.states
289
251
  .filter((state) => state.role !== 'terminal')
290
252
  .map((state) => {
291
253
  const items = byStateLower.get(state.name.toLowerCase())?.items ?? [];
292
- return renderColumn(state, items, runningById, retryingById);
254
+ return renderColumn(state, items, runningById, retryingById, p.states);
293
255
  });
294
- // Orphan columns: on-disk state directories whose names are not in the
295
- // declared `states:` set. These render even when they look "terminal" because
296
- // they're an error condition the operator needs to see (a workflow rename
297
- // left files behind), not a clean archive.
256
+ // Orphan columns: on-disk state directories whose names are not declared.
257
+ // These render even when terminal-looking because they signal an error the
258
+ // operator needs to see (a workflow rename left files behind).
298
259
  const declaredLower = new Set(p.states.map((s) => s.name.toLowerCase()));
299
260
  const orphans = [];
300
261
  for (const [key, entry] of byStateLower) {
@@ -303,10 +264,25 @@ function renderBoardPartial(p) {
303
264
  orphans.push({ name: entry.displayName, items: entry.items });
304
265
  }
305
266
  orphans.sort((a, b) => a.name.localeCompare(b.name));
306
- const orphanColumns = orphans.map((o) => renderColumn({ name: o.name, role: 'terminal' }, o.items, runningById, retryingById, { orphan: true }));
307
- return `<div class="kanban">${declared.join('')}${orphanColumns.join('')}</div>`;
267
+ const orphanColumns = orphans.map((o) => renderColumn({ name: o.name, role: 'terminal' }, o.items, runningById, retryingById, p.states, {
268
+ orphan: true,
269
+ }));
270
+ const emptyState = p.diskIssues.length === 0 ? renderBoardEmptyState(p.states) : '';
271
+ return `${emptyState}<div class="kanban">${declared.join('')}${orphanColumns.join('')}</div>`;
272
+ }
273
+ // Whole-board empty state (first run): rendered only when the entire tracker
274
+ // holds zero issues. Teaches the two first-run affordances once (column `+` and
275
+ // the on-disk `issues/<FirstActive>/<id>.md` path), then disappears.
276
+ function renderBoardEmptyState(states) {
277
+ const firstActive = states.find((s) => s.role === 'active')?.name ?? 'Todo';
278
+ const esc = escapeHtml(firstActive);
279
+ return `<section class="board-empty" aria-label="no issues yet">
280
+ <p class="be-lead">no issues yet — dispatch your first</p>
281
+ <p class="be-how">press <span class="be-key">+</span> on a column, or add <code>issues/${esc}/&lt;id&gt;.md</code></p>
282
+ <p class="be-note dim">agents pick up <code>${esc}</code> on the next poll</p>
283
+ </section>`;
308
284
  }
309
- function renderColumn(state, items, runningById, retryingById, opts) {
285
+ function renderColumn(state, items, runningById, retryingById, allStates, opts) {
310
286
  const orphan = opts?.orphan === true;
311
287
  const canAdd = !orphan && state.role !== 'terminal';
312
288
  // Sort within a column so anything needing attention floats to the top:
@@ -334,7 +310,10 @@ function renderColumn(state, items, runningById, retryingById, opts) {
334
310
  return a.identifier.localeCompare(b.identifier);
335
311
  });
336
312
  const rowsHtml = sorted
337
- .map((i) => renderIssueRow(i, state, runningById.get(i.identifier), retryingById.get(i.identifier), { orphan }))
313
+ .map((i) => renderIssueRow(i, state, runningById.get(i.identifier), retryingById.get(i.identifier), {
314
+ orphan,
315
+ allStates,
316
+ }))
338
317
  .join('');
339
318
  const addBtn = canAdd
340
319
  ? `<button type="button" class="col-add"
@@ -346,7 +325,7 @@ function renderColumn(state, items, runningById, retryingById, opts) {
346
325
  ? `<p class="col-empty">drop into <code>${escapeHtml(state.name)}/</code></p>`
347
326
  : '';
348
327
  const orphanBadge = orphan
349
- ? `<span class="col-orphan" title="state not declared in workflow.md">undeclared</span>`
328
+ ? `<span class="col-orphan" title="state not declared in workflow.yaml">undeclared</span>`
350
329
  : '';
351
330
  return `<section class="col col-${escapeHtml(state.role)}${orphan ? ' col-orphan-wrap' : ''}" data-state="${escapeHtml(state.name)}">
352
331
  <header class="col-head">
@@ -359,19 +338,16 @@ function renderColumn(state, items, runningById, retryingById, opts) {
359
338
  <div class="col-body">${rowsHtml}${emptyHint}</div>
360
339
  </section>`;
361
340
  }
362
- function rowFlags(state, running, retrying, orphan) {
341
+ function rowFlags(running, retrying, orphan) {
363
342
  const isAwaiting = !!running?.steering_requested;
364
- // A `continuation` retry is the slot-holding resume of a clean handoff (e.g.
365
- // Review Todo): the orchestrator still holds the issue's slot and redispatches
366
- // within ~1s, so the card stays "running" across the handoff instead of flashing
367
- // a transient pill. Only `failure`-backoff retries are "retrying" (issues
368
- // 146/154; the design system's Three-Pill Rule — running/retrying/idle only).
343
+ // A `continuation` retry is the slot-holding resume of a clean handoff: the
344
+ // card stays "running" across the handoff instead of flashing a transient
345
+ // pill. Only `failure`-backoff retries are "retrying" (Three-Pill Rule).
369
346
  const isContinuation = retrying?.kind === 'continuation';
370
347
  return {
371
348
  isAwaiting,
372
349
  isRunning: (!!running || isContinuation) && !isAwaiting,
373
350
  isRetrying: !!retrying && !isContinuation,
374
- isHolding: !orphan && state.role === 'holding',
375
351
  orphan,
376
352
  };
377
353
  }
@@ -389,10 +365,9 @@ function rowMetaHtml(running, retrying) {
389
365
  const tokens = formatTokens(running.tokens.total_tokens || 0);
390
366
  return `<span class="row-meta">turn ${running.turn_count} · ${escapeHtml(tokens)} tok</span>`;
391
367
  }
392
- // A `continuation` carries no operator-facing meta: the card reads as "running"
393
- // across the handoff (see rowFlags), so the brief slot-holding window stays
394
- // silent rather than surfacing an "attempt N · due" trail. Only `failure`
395
- // retries show the backoff schedule.
368
+ // A `continuation` carries no operator-facing meta: the card reads as
369
+ // "running" across the handoff, so the slot-holding window stays silent. Only
370
+ // `failure` retries show the backoff schedule.
396
371
  if (retrying && retrying.kind !== 'continuation') {
397
372
  const dueAt = formatTimeShort(retrying.due_at) || '—';
398
373
  return `<span class="row-meta">attempt ${retrying.attempt} · due ${escapeHtml(dueAt)}</span>`;
@@ -410,21 +385,52 @@ function rowRetryErrHtml(retrying) {
410
385
  return '';
411
386
  return `<div class="row-err">${escapeHtml(truncate(retrying.error, 200))}</div>`;
412
387
  }
413
- function rowTriageActionsHtml(ident, f) {
414
- if (!(f.isHolding && !f.isRunning && !f.isAwaiting && !f.isRetrying))
388
+ // The `<option>` list for a move picker: every declared state except `current`,
389
+ // each flagged `data-offflow` when the target is outside `current`'s non-null
390
+ // `allowed_transitions` (the exact off-flow condition the agent path enforces).
391
+ // Shared by the board row picker and the issue-detail picker so the two never
392
+ // drift. Returns '' when `current` is the only declared state.
393
+ function moveTargetOptionsHtml(current, allStates) {
394
+ const fromLower = current.name.toLowerCase();
395
+ const fromAllowed = current.allowed_transitions ?? null;
396
+ return allStates
397
+ .filter((s) => s.name.toLowerCase() !== fromLower)
398
+ .map((s) => {
399
+ const offAttr = isInFlowTransition(fromAllowed, current.name, s.name) ? '' : ' data-offflow="1"';
400
+ const esc = escapeHtml(s.name);
401
+ return `<option value="${esc}"${offAttr}>${esc}</option>`;
402
+ })
403
+ .join('');
404
+ }
405
+ // Per-row move picker (issue 126): a `<select>` of every declared state except
406
+ // the row's own, rendered for every DECLARED row — settled OR mid-flight. On a
407
+ // LIVE row (running / awaiting / retrying) the move is also the operator's cancel
408
+ // verb (issue 189): picking a state stops the in-flight attempt and moves the
409
+ // issue, so the placeholder reads `cancel / move to…` and the select carries
410
+ // `data-live` so the dashboard script confirms before stopping the run. An orphan
411
+ // row's directory is not a declared state to move within, so it is still skipped.
412
+ // Each option carries a server-computed `data-offflow` flag (the target is outside
413
+ // the from-state's non-null `allowed_transitions`); the script confirms before
414
+ // POSTing an off-flow move. The endpoint never blocks — these are soft guardrails.
415
+ function rowMoveActionsHtml(ident, current, f, allStates) {
416
+ if (f.orphan)
417
+ return '';
418
+ if (!allStates || allStates.length === 0)
419
+ return '';
420
+ const options = moveTargetOptionsHtml(current, allStates);
421
+ if (!options)
415
422
  return '';
416
423
  const enc = encodeURIComponent(ident);
424
+ const live = f.isRunning || f.isAwaiting || f.isRetrying;
425
+ const liveAttr = live ? ' data-live="1"' : '';
426
+ const placeholder = live ? 'cancel / move to…' : 'move to…';
417
427
  return `<div class="row-actions">
418
- <form class="row-action"
419
- hx-post="/api/v1/issues/${enc}/approve"
420
- hx-target="#board" hx-swap="morph:innerHTML">
421
- <button type="submit" class="ghost-sm" title="approve into the first active state">approve</button>
422
- </form>
423
- <form class="row-action"
424
- hx-post="/api/v1/issues/${enc}/discard"
425
- hx-target="#board" hx-swap="morph:innerHTML">
426
- <button type="submit" class="ghost-sm danger" title="discard the proposal">discard</button>
427
- </form>
428
+ <select class="row-move"${liveAttr}
429
+ data-ident="${enc}" data-from="${escapeHtml(current.name)}"
430
+ aria-label="move ${escapeHtml(ident)} to another state">
431
+ <option value="" selected disabled>${placeholder}</option>
432
+ ${options}
433
+ </select>
428
434
  </div>`;
429
435
  }
430
436
  function rowClassList(f, retryHasErr) {
@@ -438,8 +444,8 @@ function rowClassList(f, retryHasErr) {
438
444
  .filter(Boolean)
439
445
  .join(' ');
440
446
  }
441
- function renderIssueRow(i, state, running, retrying, opts) {
442
- const f = rowFlags(state, running, retrying, opts?.orphan === true);
447
+ export function renderIssueRow(i, state, running, retrying, opts) {
448
+ const f = rowFlags(running, retrying, opts?.orphan === true);
443
449
  const escIdent = escapeHtml(i.identifier);
444
450
  const href = `/issues/${encodeURIComponent(i.identifier)}`;
445
451
  const steering = f.isAwaiting ? renderSteeringInline(running) : '';
@@ -454,10 +460,13 @@ function renderIssueRow(i, state, running, retrying, opts) {
454
460
  <a class="row-title" href="${href}">${escapeHtml(i.title)}</a>
455
461
  ${rowPeekHtml(running, f.isAwaiting)}
456
462
  ${rowRetryErrHtml(retrying)}
457
- ${rowTriageActionsHtml(i.identifier, f)}
463
+ ${rowMoveActionsHtml(i.identifier, state, f, opts?.allStates)}
458
464
  ${steering}
459
465
  </article>`;
460
466
  }
467
+ // ─────────────────────────────────────────────────────────────────────────────
468
+ // Inline steering on awaiting rows
469
+ // ─────────────────────────────────────────────────────────────────────────────
461
470
  function steeringSummaryLabel(hasOriginalTask, hasContext) {
462
471
  if (hasOriginalTask && hasContext)
463
472
  return 'original task & agent’s context';
@@ -493,13 +502,18 @@ function steeringExtraPanel(title, body, context) {
493
502
  </div>
494
503
  </details>`;
495
504
  }
496
- function renderSteeringInline(r) {
505
+ export function renderSteeringInline(r) {
497
506
  const question = (r.steering_question ?? '').trim() || '(no question text)';
498
507
  const context = (r.steering_context ?? '').trim();
499
508
  const issueTitle = (r.issue_title ?? '').trim();
500
509
  const issueBody = (r.issue_body ?? '').trim();
501
- // Stable textarea id + hx-preserve so the every-2s board repoll keeps the
502
- // operator's draft reply intact across morph swaps.
510
+ // Stable textarea id so the every-2s board repoll's idiomorph swap REUSES this
511
+ // node (focus + caret intact) rather than recreating it, and so the draft VALUE
512
+ // survives the swap via the board's `ignoreActiveValue` morph config (see
513
+ // renderDashboardHtml). The old `hx-preserve="true"` is gone on purpose: under a
514
+ // morph swap htmx's preserve logic detaches and re-inserts the live textarea on
515
+ // every poll, which blurred it and snapped the caret back to the top, making the
516
+ // box unwritable while a reply was being typed (issue 228).
503
517
  const textareaId = `reply-${r.issue_identifier}`;
504
518
  return `<div class="steering">
505
519
  <div class="steering-q">${renderMarkdown(question)}</div>
@@ -509,8 +523,7 @@ function renderSteeringInline(r) {
509
523
  hx-target="#ticker" hx-swap="morph:innerHTML">
510
524
  <textarea id="${escapeHtml(textareaId)}" name="text" required
511
525
  placeholder="your reply…"
512
- aria-label="reply to ${escapeHtml(r.issue_identifier)}"
513
- hx-preserve="true"></textarea>
526
+ aria-label="reply to ${escapeHtml(r.issue_identifier)}"></textarea>
514
527
  <div class="reply-row">
515
528
  <span class="hint dim">enter to send · shift+enter for newline</span>
516
529
  <button type="submit" class="ghost">send reply</button>
@@ -518,13 +531,10 @@ function renderSteeringInline(r) {
518
531
  </form>
519
532
  </div>`;
520
533
  }
521
- // ─── Right-side new-issue panel (the One Card) ─────────────────────────
522
- // The dashboard's only card-shaped surface (DESIGN.md §5 One-Card Rule). Slides
523
- // in from the right edge when the operator clicks a column's `+` button; the
524
- // column the click came from pre-selects the state field. Non-modal: the board
525
- // stays visible and interactive on the left. Esc closes; backdrop clicks do not
526
- // (avoid accidental discards of in-progress drafts).
527
- function renderNewIssuePanel(p) {
534
+ // ─────────────────────────────────────────────────────────────────────────────
535
+ // Right-side new-issue panel (the One Card)
536
+ // ─────────────────────────────────────────────────────────────────────────────
537
+ export function renderNewIssuePanel(p) {
528
538
  const targets = p.states.filter((s) => s.role !== 'terminal');
529
539
  const firstActive = p.states.find((s) => s.role === 'active');
530
540
  const defaultState = (firstActive ?? targets[0])?.name ?? '';
@@ -553,15 +563,15 @@ function renderNewIssuePanel(p) {
553
563
  </form>
554
564
  </aside>`;
555
565
  }
556
- function renderTotalsPartial(p) {
566
+ export function renderTotalsPartial(p) {
557
567
  const t = p.snapshot.session_totals;
558
568
  if (!t || (t.input_tokens === 0 && t.output_tokens === 0 && t.seconds_running === 0))
559
569
  return '';
560
570
  return `${formatTokens(t.input_tokens)} in · ${formatTokens(t.output_tokens)} out · ${formatTokens(t.total_tokens)} total · ${formatRuntime(t.seconds_running)} runtime`;
561
571
  }
562
- // Dashboard CSS, hoisted to a top-level constant so renderDashboardHtml stays
563
- // within the imperative-shell max-lines budget. Pure presentation contentno
564
- // interpolated values — so it lives outside the function.
572
+ // ─────────────────────────────────────────────────────────────────────────────
573
+ // Dashboard CSS + client JS (hoisted top-level constantspure presentation).
574
+ // ─────────────────────────────────────────────────────────────────────────────
565
575
  const DASHBOARD_CSS = `
566
576
  :root {
567
577
  color-scheme: dark;
@@ -624,6 +634,20 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fo
624
634
  #header .refresh:hover { border-color: var(--muted); }
625
635
  #header .refresh:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
626
636
 
637
+ /* ── system-health strip ─────────────────────────────────────────────── */
638
+ #health {
639
+ display: flex; flex-wrap: wrap; align-items: baseline;
640
+ gap: 0.25rem 1rem;
641
+ padding: 0.35rem 1.25rem;
642
+ background: var(--bench);
643
+ border-bottom: 1px solid var(--rule-soft);
644
+ font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
645
+ color: var(--muted);
646
+ font-variant-numeric: tabular-nums;
647
+ }
648
+ #health .hf { color: var(--muted); white-space: nowrap; }
649
+ #health .hf-bad { color: var(--err); }
650
+
627
651
  /* ── ticker ──────────────────────────────────────────────────────────── */
628
652
  #ticker {
629
653
  display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: center;
@@ -698,6 +722,23 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fo
698
722
  }
699
723
  .col-empty code { color: var(--muted); }
700
724
 
725
+ /* ── whole-board empty state (first run) ─────────────────────────────── */
726
+ .board-empty {
727
+ margin: 0 0 1.25rem; padding: 0 0 1rem;
728
+ border-bottom: 1px solid var(--rule-soft);
729
+ display: grid; gap: 0.25rem; max-width: 64ch;
730
+ }
731
+ .board-empty p { margin: 0; line-height: 1.5; }
732
+ .board-empty .be-lead { color: var(--base); }
733
+ .board-empty .be-how { color: var(--muted); }
734
+ .board-empty .be-note { font-size: 0.9em; }
735
+ .board-empty code { color: var(--muted); }
736
+ .board-empty .be-key {
737
+ display: inline-block; padding: 0 0.35rem;
738
+ border: 1px solid var(--rule-firm); border-radius: 4px;
739
+ color: var(--muted);
740
+ }
741
+
701
742
  /* ── row ─────────────────────────────────────────────────────────────── */
702
743
  .row {
703
744
  padding: 0.55rem 0.6rem; margin: 0 -0.6rem;
@@ -748,7 +789,18 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fo
748
789
  overflow-wrap: anywhere; word-break: break-word;
749
790
  }
750
791
  .row-actions { display: flex; gap: 0.4rem; margin-top: 0.2rem; }
751
- .row-action { display: inline; margin: 0; padding: 0; }
792
+ /* per-row move picker (issue 126): a compact select offering every other
793
+ declared state; off-flow options are confirmed in the client before POST. */
794
+ .row-move {
795
+ background: transparent; color: var(--muted);
796
+ border: 1px solid var(--rule-firm); border-radius: 3px;
797
+ padding: 0.18rem 0.5rem; font: inherit; font-size: 0.82em; cursor: pointer;
798
+ max-width: 100%;
799
+ transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
800
+ }
801
+ .row-move:hover { color: var(--strong); border-color: var(--muted); }
802
+ .row-move:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
803
+ .row-move:disabled { opacity: 0.6; cursor: default; }
752
804
  .ghost {
753
805
  background: var(--chip); color: var(--base);
754
806
  border: 1px solid var(--rule-firm); border-radius: 4px;
@@ -757,15 +809,6 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fo
757
809
  }
758
810
  .ghost:hover { border-color: var(--muted); }
759
811
  .ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
760
- .ghost-sm {
761
- background: transparent; color: var(--muted);
762
- border: 1px solid var(--rule-firm); border-radius: 3px;
763
- padding: 0.18rem 0.6rem; font: inherit; font-size: 0.82em; cursor: pointer;
764
- transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
765
- }
766
- .ghost-sm:hover { color: var(--strong); border-color: var(--muted); }
767
- .ghost-sm.danger:hover { color: var(--err); border-color: var(--err); }
768
- .ghost-sm:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
769
812
 
770
813
  /* ── inline steering on awaiting rows ────────────────────────────────── */
771
814
  .steering {
@@ -932,9 +975,54 @@ footer.totals:empty { display: none; }
932
975
  .htmx-request .refresh { opacity: 0.6; }
933
976
  .htmx-settling .row-peek { opacity: 0.85; }
934
977
  `;
935
- // Dashboard new-issue panel controller. Stored as a top-level constant so the
936
- // renderDashboardHtml shell stays inside the imperative-shell line budget.
978
+ // Shared client move-submit logic, embedded by BOTH the dashboard board picker
979
+ // (event-delegated the board morphs every poll) and the issue-detail picker
980
+ // (one static select, page reloads). Defines `__symphonyMove(sel, onSuccess)`:
981
+ // confirm first when the move stops a live run (issue 189: a `data-live` select)
982
+ // and/or the chosen option is off-flow, POST the move (the server never blocks —
983
+ // soft guardrail), then run `onSuccess`. The two warnings collapse into ONE
984
+ // confirm so a live off-flow move never double-prompts. Factored out so the
985
+ // confirm text and the POST shape can't drift between the two pages.
986
+ const MOVE_SUBMIT_JS = `
987
+ async function __symphonyMove(sel, onSuccess) {
988
+ const target = sel.value;
989
+ if (!target) return;
990
+ const ident = sel.getAttribute('data-ident') || '';
991
+ const from = sel.getAttribute('data-from') || '';
992
+ const opt = sel.options[sel.selectedIndex];
993
+ const offFlow = !!opt && opt.getAttribute('data-offflow') === '1';
994
+ const live = sel.getAttribute('data-live') === '1';
995
+ const decId = decodeURIComponent(ident);
996
+ let warn = '';
997
+ if (live) warn = 'Stop the in-flight attempt for ' + decId + ' and move it to "' + target + '"?';
998
+ else if (offFlow) warn = 'Move ' + decId + ' to "' + target + '"?';
999
+ if (warn && offFlow) warn += ' That is outside the declared flow from "' + from + '".';
1000
+ if (warn && !window.confirm(warn)) {
1001
+ sel.value = '';
1002
+ return;
1003
+ }
1004
+ sel.disabled = true;
1005
+ try {
1006
+ const res = await fetch('/api/v1/issues/' + ident + '/move', {
1007
+ method: 'POST',
1008
+ headers: { 'content-type': 'application/json' },
1009
+ body: JSON.stringify({ state: target, from_state: from }),
1010
+ });
1011
+ if (!res.ok) {
1012
+ const data = await res.json().catch(() => null);
1013
+ throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status));
1014
+ }
1015
+ onSuccess();
1016
+ } catch (err) {
1017
+ sel.disabled = false;
1018
+ sel.value = '';
1019
+ window.alert('move failed: ' + err.message);
1020
+ }
1021
+ }
1022
+ `;
1023
+ // Dashboard new-issue panel controller. Top-level constant — pure presentation.
937
1024
  const DASHBOARD_SCRIPT = `
1025
+ ${MOVE_SUBMIT_JS}
938
1026
  (() => {
939
1027
  const $ = (id) => document.getElementById(id);
940
1028
  const panel = $('new-panel');
@@ -989,6 +1077,18 @@ const DASHBOARD_SCRIPT = `
989
1077
  }
990
1078
  });
991
1079
 
1080
+ // Per-row move picker (issue 126): selecting a target state moves the issue
1081
+ // (event-delegated because the board morphs every poll). On success the board
1082
+ // is refreshed; __symphonyMove handles the off-flow confirm + the POST.
1083
+ document.addEventListener('change', (ev) => {
1084
+ const sel = ev.target;
1085
+ if (!(sel instanceof HTMLSelectElement) || !sel.classList.contains('row-move')) return;
1086
+ __symphonyMove(sel, () => {
1087
+ fetch('/api/v1/refresh', { method: 'POST' }).catch(() => {});
1088
+ document.body.dispatchEvent(new CustomEvent('refreshed', { bubbles: true }));
1089
+ });
1090
+ });
1091
+
992
1092
  form.addEventListener('submit', async (ev) => {
993
1093
  ev.preventDefault();
994
1094
  setMsg('creating…');
@@ -1017,7 +1117,7 @@ const DASHBOARD_SCRIPT = `
1017
1117
  });
1018
1118
  })();
1019
1119
  `;
1020
- function renderDashboardHtml(p) {
1120
+ export function renderDashboardHtml(p, health) {
1021
1121
  return `<!doctype html>
1022
1122
  <html lang="en"><head>
1023
1123
  <meta charset="utf-8">
@@ -1041,13 +1141,17 @@ function renderDashboardHtml(p) {
1041
1141
  aria-label="refresh now" title="poll &amp; reconcile">⟳</button>
1042
1142
  </header>
1043
1143
 
1144
+ <div id="health" aria-label="system health"
1145
+ hx-get="/api/v1/partials/health" hx-trigger="every 2s, refreshed from:body"
1146
+ hx-swap="morph:innerHTML">${renderHealthPartial(health)}</div>
1147
+
1044
1148
  <div id="ticker"
1045
1149
  hx-get="/api/v1/partials/attention" hx-trigger="every 2s, refreshed from:body"
1046
1150
  hx-swap="morph:innerHTML">${renderAttentionPartial(p)}</div>
1047
1151
 
1048
1152
  <main id="board"
1049
1153
  hx-get="/api/v1/partials/board" hx-trigger="every 2s, refreshed from:body"
1050
- hx-swap="morph:innerHTML">${renderBoardPartial(p)}</main>
1154
+ hx-swap='morph:{"morphStyle":"innerHTML","ignoreActiveValue":true,"restoreFocus":true}'>${renderBoardPartial(p)}</main>
1051
1155
 
1052
1156
  ${renderNewIssuePanel(p)}
1053
1157
 
@@ -1059,9 +1163,6 @@ ${renderNewIssuePanel(p)}
1059
1163
 
1060
1164
  </body></html>`;
1061
1165
  }
1062
- function escapeHtml(s) {
1063
- return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
1064
- }
1065
1166
  const FENCE_OPEN_RE = /^```([\w-]*)\s*$/;
1066
1167
  const FENCE_CLOSE_RE = /^```\s*$/;
1067
1168
  const HEADER_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
@@ -1116,12 +1217,12 @@ function matchBlockquote(lines, i) {
1116
1217
  return { html: `<blockquote>${renderMarkdown(buf.join('\n'))}</blockquote>`, nextI: j };
1117
1218
  }
1118
1219
  function isBlockBoundary(line) {
1119
- return line.trim() === ''
1120
- || FENCE_OPEN_RE.test(line)
1121
- || /^#{1,6}\s+/.test(line)
1122
- || UL_RE.test(line)
1123
- || OL_RE.test(line)
1124
- || BLOCKQUOTE_RE.test(line);
1220
+ return (line.trim() === '' ||
1221
+ FENCE_OPEN_RE.test(line) ||
1222
+ /^#{1,6}\s+/.test(line) ||
1223
+ UL_RE.test(line) ||
1224
+ OL_RE.test(line) ||
1225
+ BLOCKQUOTE_RE.test(line));
1125
1226
  }
1126
1227
  function matchParagraph(lines, i) {
1127
1228
  const para = [];
@@ -1141,23 +1242,23 @@ export function renderMarkdown(input) {
1141
1242
  i++;
1142
1243
  continue;
1143
1244
  }
1144
- const m = matchFencedCode(lines, i)
1145
- ?? matchHeader(lines, i)
1146
- ?? matchList(lines, i, UL_RE, UL_ITEM_RE, 'ul')
1147
- ?? matchList(lines, i, OL_RE, OL_ITEM_RE, 'ol')
1148
- ?? matchBlockquote(lines, i)
1149
- ?? matchParagraph(lines, i);
1245
+ const m = matchFencedCode(lines, i) ??
1246
+ matchHeader(lines, i) ??
1247
+ matchList(lines, i, UL_RE, UL_ITEM_RE, 'ul') ??
1248
+ matchList(lines, i, OL_RE, OL_ITEM_RE, 'ol') ??
1249
+ matchBlockquote(lines, i) ??
1250
+ matchParagraph(lines, i);
1150
1251
  blocks.push(m.html);
1151
1252
  i = m.nextI;
1152
1253
  }
1153
1254
  return blocks.join('\n');
1154
1255
  }
1155
- function renderInlineMarkdown(input) {
1256
+ export function renderInlineMarkdown(input) {
1156
1257
  const codes = [];
1157
1258
  let text = input.replace(/`([^`\n]+)`/g, (_m, code) => {
1158
1259
  const idx = codes.length;
1159
1260
  codes.push(`<code>${escapeHtml(code)}</code>`);
1160
- return `C${idx}`;
1261
+ return ` C${idx} `;
1161
1262
  });
1162
1263
  text = escapeHtml(text);
1163
1264
  text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
@@ -1170,38 +1271,22 @@ function renderInlineMarkdown(input) {
1170
1271
  text = text.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1<em>$2</em>');
1171
1272
  text = text.replace(/(^|[^_\w])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
1172
1273
  text = text.replace(/\n/g, '<br>');
1173
- text = text.replace(/C(\d+)/g, (_m, idx) => codes[Number(idx)]);
1274
+ text = text.replace(/ C(\d+) /g, (_m, idx) => codes[Number(idx)]);
1174
1275
  return text;
1175
1276
  }
1176
1277
  // ─────────────────────────────────────────────────────────────────────────────
1177
- // Issue-detail page. Linked from the identifier on every "on disk" and triage
1178
- // row. Shows what the on-disk Markdown file says: the front-matter metadata
1179
- // (labels, priority, blocked_by, provenance, timestamps) and the body rendered
1180
- // through the same minimal Markdown engine the steering panel uses. Read-only
1181
- // — actions live on the dashboard, not here. Follows the design system's
1182
- // quiet-workshop rules: flat panels, tabular numerics, no shadows, status pill
1183
- // uses the existing palette.
1278
+ // Issue-detail page. Read-only HTML view of one on-disk issue: front-matter
1279
+ // metadata (labels, priority, blocked_by, provenance, timestamps) + the Markdown
1280
+ // body rendered through the same minimal engine the steering panel uses.
1184
1281
  // ─────────────────────────────────────────────────────────────────────────────
1185
- function asStringList(v) {
1186
- if (Array.isArray(v))
1187
- return v.filter((x) => typeof x === 'string');
1188
- return [];
1189
- }
1190
- function asString(v) {
1191
- return typeof v === 'string' && v.length > 0 ? v : null;
1192
- }
1193
- function asNumber(v) {
1194
- if (typeof v === 'number' && Number.isFinite(v))
1195
- return v;
1196
- return null;
1197
- }
1198
- function formatTimestamp(v) {
1199
- if (typeof v === 'string' && v.length > 0)
1200
- return v;
1201
- if (v instanceof Date)
1202
- return v.toISOString();
1203
- return null;
1204
- }
1282
+ // Front-matter coercers (asString/asStringList/asInt/asTimestamp) are the
1283
+ // canonical ones from core/coerce.ts. NOTE(drift resolved): the old local copies
1284
+ // here carried a `v.length > 0` length-guard on asString and `formatTimestamp`
1285
+ // that the issue parser did not; the canonical asString has NO length-guard
1286
+ // (matching issue/parse.ts:toIssue), so the detail page now coerces front-matter
1287
+ // IDENTICALLY to the way an Issue is normalized — they can no longer drift. The
1288
+ // old formatTimestamp folds into asTimestamp (which DID keep the empty-string→
1289
+ // null guard). See core/coerce.ts header + tests/core/coerce.test.ts.
1205
1290
  function detailRow(label, value) {
1206
1291
  return `<div class="meta-row"><dt>${escapeHtml(label)}</dt><dd>${value}</dd></div>`;
1207
1292
  }
@@ -1239,18 +1324,22 @@ function detailTimestampRow(label, ts) {
1239
1324
  return detailRow(label, `<span class="num">${escapeHtml(ts)}</span>`);
1240
1325
  }
1241
1326
  // Pill colour follows the declared role: active → running, terminal → done,
1242
- // holding → idle. An undeclared state (stale directory, file dropped by hand
1243
- // into a name not in `states:`) falls back to idle so the detail page still
1244
- // renders legibly.
1327
+ // holding → idle. An undeclared state falls back to idle.
1245
1328
  function buildDetailMetaRows(fm, stateClass, stateName) {
1246
1329
  const branchName = asString(fm['branch_name']);
1247
- const priority = asNumber(fm['priority']);
1248
- const createdAt = formatTimestamp(fm['created_at']);
1249
- const updatedAt = formatTimestamp(fm['updated_at']);
1330
+ // priority via asInt (the Issue-normalization coercer), so a fractional value
1331
+ // truncates here exactly as it does in issue/parse.ts:toIssue.
1332
+ const priority = asInt(fm['priority']);
1333
+ const createdAt = asTimestamp(fm['created_at']);
1334
+ const updatedAt = asTimestamp(fm['updated_at']);
1250
1335
  const showUpdated = updatedAt && updatedAt !== createdAt ? updatedAt : null;
1336
+ // NOTE(drift resolved): the Issue normalization (issue/parse.ts:toIssue)
1337
+ // lowercases labels; this detail page used to render them un-cased. Lowercase
1338
+ // here too so the detail page and the board/Issue agree. Pinned by render.test.
1339
+ const labels = asStringList(fm['labels']).map((l) => l.toLowerCase());
1251
1340
  return [
1252
1341
  detailRow('state', `<span class="pill ${stateClass}">${escapeHtml(stateName)}</span>`),
1253
- detailLabelsRow(asStringList(fm['labels'])),
1342
+ detailLabelsRow(labels),
1254
1343
  priority !== null ? detailRow('priority', `<span class="num">${escapeHtml(String(priority))}</span>`) : '',
1255
1344
  detailBlockedByRow(asStringList(fm['blocked_by'])),
1256
1345
  branchName ? detailRow('branch', `<code>${escapeHtml(branchName)}</code>`) : '',
@@ -1258,11 +1347,10 @@ function buildDetailMetaRows(fm, stateClass, stateName) {
1258
1347
  detailTimestampRow('created', createdAt),
1259
1348
  detailTimestampRow('updated', showUpdated),
1260
1349
  detailProposedByRow(asString(fm['proposed_by'])),
1261
- detailTimestampRow('proposed at', formatTimestamp(fm['proposed_at'])),
1350
+ detailTimestampRow('proposed at', asTimestamp(fm['proposed_at'])),
1262
1351
  ].join('');
1263
1352
  }
1264
- // Issue-detail CSS, hoisted out of renderIssueDetailPage so the function stays
1265
- // within the imperative-shell line budget. Pure presentation content.
1353
+ // Issue-detail CSS, hoisted out of renderIssueDetailPage. Pure presentation.
1266
1354
  const DETAIL_PAGE_CSS = `
1267
1355
  :root {
1268
1356
  color-scheme: dark;
@@ -1349,6 +1437,24 @@ main {
1349
1437
  align-self: baseline; padding-top: 0.15rem;
1350
1438
  }
1351
1439
  .issue-meta dd { margin: 0; color: var(--base); }
1440
+
1441
+ /* operator move control (issue 126): terminal issues are off the board, so the
1442
+ detail page is where they stay reachable for a move. Mirrors the board's
1443
+ .row-move select; an off-flow target is confirmed client-side before POST. */
1444
+ .detail-move { display: flex; align-items: center; gap: 0.6rem; margin: 0 0 1.4rem; }
1445
+ .detail-move-label {
1446
+ color: var(--dim); font-size: 0.85em;
1447
+ letter-spacing: 0.05em; text-transform: uppercase;
1448
+ }
1449
+ .detail-move .row-move {
1450
+ background: transparent; color: var(--muted);
1451
+ border: 1px solid var(--rule-firm); border-radius: 3px;
1452
+ padding: 0.2rem 0.55rem; font: inherit; font-size: 0.85em; cursor: pointer;
1453
+ transition: color 160ms cubic-bezier(.22,1,.36,1), border-color 160ms cubic-bezier(.22,1,.36,1);
1454
+ }
1455
+ .detail-move .row-move:hover { color: var(--strong); border-color: var(--muted); }
1456
+ .detail-move .row-move:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1457
+ .detail-move .row-move:disabled { opacity: 0.6; cursor: default; }
1352
1458
  .label-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
1353
1459
  .label-chip {
1354
1460
  display: inline-block; padding: 0.05rem 0.5rem; border-radius: 4px;
@@ -1407,15 +1513,61 @@ h2.section-title {
1407
1513
  }
1408
1514
  .empty { padding: 0.5rem 0; }
1409
1515
  `;
1410
- function renderIssueDetailPage(issue, view) {
1516
+ // Operator move control on the issue-detail page (issue 126). The board's move
1517
+ // picker only renders on board ROWS, and terminal columns are deliberately kept
1518
+ // off the board (the glance view skips finished work) — so a terminal issue
1519
+ // (Done/Cancelled) has no board picker. The detail page is where terminal issues
1520
+ // stay reachable, so the move control lives here for them, closing the gap that
1521
+ // the operator couldn't move an issue OUT of a terminal state from the dashboard.
1522
+ //
1523
+ // Scoped to terminal states on purpose: a terminal issue is never mid-flight
1524
+ // (dispatch stops at terminal states), so this control needs no running-state
1525
+ // guard — whereas a non-terminal issue might be running, which is exactly why the
1526
+ // board picker (which has the running-state info to skip mid-flight rows) owns
1527
+ // those. The endpoint is the same generalized `/move`, so an off-flow target is
1528
+ // confirmed and `off_flow` echoed identically; a re-opened active target gets the
1529
+ // same immediate-pickup nudge approve did.
1530
+ function detailMoveControlHtml(identifier, stateName, states) {
1531
+ const current = states.find((s) => s.name.toLowerCase() === stateName.toLowerCase());
1532
+ if (!current || current.role !== 'terminal')
1533
+ return '';
1534
+ const options = moveTargetOptionsHtml(current, states);
1535
+ if (!options)
1536
+ return '';
1537
+ const enc = encodeURIComponent(identifier);
1538
+ return `<div class="detail-move">
1539
+ <label class="detail-move-label" for="detail-move-select">move out of ${escapeHtml(current.name)}</label>
1540
+ <select class="row-move" id="detail-move-select"
1541
+ data-ident="${enc}" data-from="${escapeHtml(current.name)}"
1542
+ aria-label="move ${escapeHtml(identifier)} to another state">
1543
+ <option value="" selected disabled>move to…</option>
1544
+ ${options}
1545
+ </select>
1546
+ </div>`;
1547
+ }
1548
+ // Issue-detail client script: binds the single move <select> (rendered for
1549
+ // terminal issues only) to the shared __symphonyMove, reloading on success so the
1550
+ // new state pill re-renders. Self-contained — the detail page does NOT load the
1551
+ // dashboard script (which wires the board + new-issue panel that don't exist here).
1552
+ const DETAIL_PAGE_SCRIPT = `
1553
+ ${MOVE_SUBMIT_JS}
1554
+ (() => {
1555
+ const sel = document.getElementById('detail-move-select');
1556
+ if (sel instanceof HTMLSelectElement) {
1557
+ sel.addEventListener('change', () => __symphonyMove(sel, () => location.reload()));
1558
+ }
1559
+ })();
1560
+ `;
1561
+ export function renderIssueDetailPage(issue, view) {
1411
1562
  const fm = issue.frontMatter;
1412
1563
  const title = asString(fm['title']) ?? issue.identifier;
1413
1564
  const stateClass = pillClassForState(view.states, issue.state);
1414
1565
  const metaRows = buildDetailMetaRows(fm, stateClass, issue.state);
1566
+ const moveControl = detailMoveControlHtml(issue.identifier, issue.state, view.states);
1415
1567
  const bodyHtml = issue.body.length > 0
1416
1568
  ? `<div class="issue-body">${renderMarkdown(issue.body)}</div>`
1417
1569
  : `<p class="empty dim">no description</p>`;
1418
- const workflowName = path.basename(view.workflowPath || 'workflow.md');
1570
+ const workflowName = basename(view.workflowPath || 'workflow.yaml');
1419
1571
  return `<!doctype html>
1420
1572
  <html lang="en"><head>
1421
1573
  <meta charset="utf-8">
@@ -1441,436 +1593,18 @@ function renderIssueDetailPage(issue, view) {
1441
1593
  </section>
1442
1594
 
1443
1595
  <dl class="issue-meta">${metaRows}</dl>
1596
+ ${moveControl}
1444
1597
 
1445
1598
  <h2 class="section-title">description</h2>
1446
1599
  ${bodyHtml}
1447
1600
 
1448
1601
  <p class="file-path" title="path on disk">${escapeHtml(issue.filePath)}</p>
1449
1602
  </main>
1450
-
1603
+ ${moveControl ? `\n<script>${DETAIL_PAGE_SCRIPT}</script>\n` : ''}
1451
1604
  </body></html>`;
1452
1605
  }
1453
- // Resolves once the server has either bound the requested port or rejected with the bind
1454
- // error so CLI startup can surface EADDRINUSE / EACCES instead of an unhandled rejection.
1455
- // Returns the *actually bound* port (relevant for --port 0, where the kernel picks an
1456
- // ephemeral port) so callers can advertise the live address.
1457
- export async function startHttpServer(orch, opts) {
1458
- const server = createServer((req, res) => {
1459
- void handleRequest(req, res, orch, opts).catch((err) => {
1460
- log.error('http handler error', { error: err.message });
1461
- try {
1462
- jsonResponse(res, 500, {
1463
- error: { code: 'internal_error', message: err.message },
1464
- });
1465
- }
1466
- catch {
1467
- /* response already started */
1468
- }
1469
- });
1470
- });
1471
- server.on('clientError', (err, socket) => {
1472
- log.debug('http client error', { error: err.message });
1473
- try {
1474
- socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
1475
- }
1476
- catch {
1477
- /* socket may already be closed */
1478
- }
1479
- });
1480
- let boundPort = opts.port;
1481
- await new Promise((resolve, reject) => {
1482
- const onError = (err) => {
1483
- server.removeListener('listening', onListening);
1484
- reject(err);
1485
- };
1486
- const onListening = () => {
1487
- server.removeListener('error', onError);
1488
- const addr = server.address();
1489
- const port = typeof addr === 'object' && addr ? addr.port : opts.port;
1490
- boundPort = port;
1491
- log.info('http server listening', { host: opts.host, port });
1492
- resolve();
1493
- };
1494
- server.once('error', onError);
1495
- server.once('listening', onListening);
1496
- server.listen(opts.port, opts.host);
1497
- });
1498
- // After bind succeeds, install a permanent error handler so later runtime errors
1499
- // (sockets resetting, ENOTCONN, etc.) are logged rather than crashing the process.
1500
- server.on('error', (err) => {
1501
- log.warn('http server error', { error: err.message });
1502
- });
1503
- return {
1504
- port: boundPort,
1505
- close: () => new Promise((resolve) => {
1506
- server.close(() => resolve());
1507
- }),
1508
- };
1509
- }
1510
- async function handleRequest(req, res, orch, opts) {
1511
- // URL parsing inside the handler so a malformed Host header doesn't crash the listener.
1512
- let pathname;
1513
- try {
1514
- pathname = new URL(req.url ?? '/', 'http://symphony.local').pathname;
1515
- }
1516
- catch {
1517
- return badRequest(res, 'invalid request URL');
1518
- }
1519
- const method = (req.method ?? 'GET').toUpperCase();
1520
- const view = opts.getTrackerView();
1521
- const route = matchRoute(pathname);
1522
- const handler = ROUTE_HANDLERS[route.kind];
1523
- await handler({ req, res, orch, opts, view, method, route });
1524
- }
1525
- async function handleDashboard(ctx) {
1526
- if (ctx.method !== 'GET')
1527
- return methodNotAllowed(ctx.res);
1528
- const p = await gatherPartialInputs(ctx.orch, ctx.view);
1529
- ctx.res.statusCode = 200;
1530
- ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1531
- ctx.res.end(renderDashboardHtml(p));
1532
- }
1533
- // Static preview for impeccable live mode. The file under .impeccable/preview/ is a
1534
- // captured snapshot of the dashboard with polling disabled, used as a variant playground.
1535
- // Read on every request so live-wrap edits land immediately.
1536
- async function handlePreview(ctx) {
1537
- if (ctx.method !== 'GET')
1538
- return methodNotAllowed(ctx.res);
1539
- try {
1540
- const html = await readFile('.impeccable/preview/dashboard.html', 'utf8');
1541
- ctx.res.statusCode = 200;
1542
- ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1543
- ctx.res.setHeader('cache-control', 'no-store');
1544
- ctx.res.end(html);
1545
- }
1546
- catch (err) {
1547
- notFound(ctx.res, 'preview_missing', `preview not available: ${err.message}`);
1548
- }
1549
- }
1550
- // HTMX partials. Each region polls its own endpoint at 2s; this is what the dashboard
1551
- // <section hx-get=...> elements consume. They return only the inner HTML; the outer
1552
- // wrapper is in the dashboard shell.
1553
- async function handlePartial(ctx) {
1554
- if (ctx.method !== 'GET')
1555
- return methodNotAllowed(ctx.res);
1556
- const name = resolvePartialName(ctx.route.slug);
1557
- if (!name) {
1558
- return notFound(ctx.res, 'partial_not_found', `partial ${ctx.route.slug} does not exist`);
1559
- }
1560
- const p = await gatherPartialInputs(ctx.orch, ctx.view);
1561
- const body = name === 'header' ? renderHeaderPartial(p)
1562
- : name === 'attention' ? renderAttentionPartial(p)
1563
- : name === 'board' ? renderBoardPartial(p)
1564
- : renderTotalsPartial(p);
1565
- ctx.res.statusCode = 200;
1566
- ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1567
- ctx.res.setHeader('cache-control', 'no-store');
1568
- ctx.res.end(body);
1569
- }
1570
- async function handleState(ctx) {
1571
- if (ctx.method !== 'GET')
1572
- return methodNotAllowed(ctx.res);
1573
- jsonResponse(ctx.res, 200, ctx.orch.snapshot());
1574
- }
1575
- async function handleRefresh(ctx) {
1576
- if (ctx.method !== 'POST')
1577
- return methodNotAllowed(ctx.res);
1578
- const status = ctx.orch.triggerRefresh();
1579
- jsonResponse(ctx.res, 202, {
1580
- ...status,
1581
- requested_at: new Date().toISOString(),
1582
- operations: ['poll', 'reconcile'],
1583
- });
1584
- }
1585
- async function handleIssues(ctx) {
1586
- if (ctx.method === 'GET')
1587
- return handleListIssues(ctx);
1588
- if (ctx.method === 'POST')
1589
- return handleCreateIssue(ctx);
1590
- methodNotAllowed(ctx.res);
1591
- }
1592
- async function handleListIssues(ctx) {
1593
- const root = ctx.view.trackerRoot;
1594
- if (!root)
1595
- return jsonResponse(ctx.res, 200, { issues: [] });
1596
- const issues = await listIssuesFromDisk(root);
1597
- jsonResponse(ctx.res, 200, { issues });
1598
- }
1599
- async function handleCreateIssue(ctx) {
1600
- const root = ctx.view.trackerRoot;
1601
- if (!root)
1602
- return badRequest(ctx.res, 'tracker.root not configured');
1603
- let body;
1604
- try {
1605
- body = await readJsonBody(ctx.req);
1606
- }
1607
- catch (err) {
1608
- return badRequest(ctx.res, err.message);
1609
- }
1610
- const decision = decideCreateIssue(body, ctx.view.states);
1611
- if (!decision.ok) {
1612
- return jsonResponse(ctx.res, decision.status, {
1613
- error: { code: decision.code, message: decision.message },
1614
- });
1615
- }
1616
- try {
1617
- const created = await writeIssueFile({
1618
- trackerRoot: root,
1619
- identifier: decision.identifier,
1620
- title: decision.title,
1621
- state: decision.state,
1622
- description: decision.description,
1623
- priority: decision.priority,
1624
- labels: decision.labels,
1625
- blocked_by: decision.blocked_by,
1626
- });
1627
- log.info('issue created via http', { identifier: created.identifier, state: created.state });
1628
- jsonResponse(ctx.res, 201, created);
1629
- }
1630
- catch (err) {
1631
- jsonResponse(ctx.res, 409, {
1632
- error: { code: 'create_failed', message: err.message },
1633
- });
1634
- }
1635
- }
1636
- // MCP JSON-RPC endpoint: agent (inside the smolvm) POSTs JSON-RPC envelopes here. The
1637
- // URL is per-issue (the agent only knows its own /<id>/mcp), backed by a bearer token
1638
- // generated at dispatch. Both layers are belt-and-braces against the no-auth 8787 socket.
1639
- async function handleMcp(ctx) {
1640
- if (ctx.method !== 'POST')
1641
- return methodNotAllowed(ctx.res);
1642
- const mcp = ctx.opts.mcp;
1643
- if (!mcp)
1644
- return notFound(ctx.res, 'mcp_disabled', 'mcp endpoint not enabled');
1645
- const { identifier } = ctx.route;
1646
- const auth = (ctx.req.headers['authorization'] ?? ctx.req.headers['Authorization']);
1647
- const token = extractBearerToken(auth);
1648
- if (!token)
1649
- return jsonResponse(ctx.res, 401, { error: { code: 'unauthorized', message: 'bearer token required' } });
1650
- if (!mcp.isActive(identifier, token)) {
1651
- return jsonResponse(ctx.res, 404, {
1652
- error: { code: 'not_found', message: 'issue not active or token mismatch' },
1653
- });
1654
- }
1655
- let body;
1656
- try {
1657
- body = await readJsonBody(ctx.req);
1658
- }
1659
- catch (err) {
1660
- return badRequest(ctx.res, err.message);
1661
- }
1662
- const reply = await mcp.handleJsonRpc(identifier, token, body);
1663
- if (reply === null) {
1664
- // JSON-RPC notification (no id) → 204 No Content
1665
- ctx.res.statusCode = 204;
1666
- ctx.res.end();
1667
- return;
1668
- }
1669
- jsonResponse(ctx.res, 200, reply);
1670
- }
1671
- // Steering reply: the dashboard (or any operator with access) submits the human's
1672
- // response to a queued request_human_steering call. The orchestrator-side runner is
1673
- // awaiting on the registry; this POST unblocks it.
1674
- //
1675
- // Two callers:
1676
- // • Dashboard via HTMX (form-encoded body, `HX-Request: true`, same-origin). We accept
1677
- // only this combination for form bodies and reply with an HTML partial that swaps
1678
- // into #attention.
1679
- // • Direct API client (JSON body). Replies with a structured JSON acknowledgement.
1680
- //
1681
- // The form-encoded branch is gated on `HX-Request: true` and a same-origin check so a
1682
- // simple cross-site form POST cannot inject a steering reply: form-encoded is a "simple"
1683
- // CORS request that bypasses preflight, and the steering endpoint is unauthenticated.
1684
- // HTMX errors land at 200 OK with an inline `.steering-error` message because HTMX's
1685
- // default response-handling does not swap on 4xx/5xx; returning 200 keeps the operator's
1686
- // form in sync with reality (their textarea text is preserved by hx-preserve regardless).
1687
- async function handleSteeringReply(ctx) {
1688
- if (ctx.method !== 'POST')
1689
- return methodNotAllowed(ctx.res);
1690
- const mcp = ctx.opts.mcp;
1691
- if (!mcp)
1692
- return notFound(ctx.res, 'mcp_disabled', 'steering endpoint not enabled');
1693
- const { identifier } = ctx.route;
1694
- const isHtmx = ctx.req.headers['hx-request'] === 'true';
1695
- const ctype = classifyContentType(ctx.req.headers['content-type']);
1696
- const csrf = checkSteeringCsrf(ctype, isHtmx, isSameOriginRequest(ctx.req));
1697
- if (!csrf.ok) {
1698
- return jsonResponse(ctx.res, csrf.status, {
1699
- error: { code: csrf.code, message: csrf.message },
1700
- });
1701
- }
1702
- const text = await readSteeringText(ctx, ctype);
1703
- if (text === null)
1704
- return;
1705
- if (!text) {
1706
- return htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 400, 'bad_request', 'text is required and must be a non-empty string');
1707
- }
1708
- if (!mcp.submitSteeringReply(identifier, text)) {
1709
- return htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 409, 'no_pending_steering', 'no agent is awaiting steering for this issue');
1710
- }
1711
- await respondSteeringAccepted(ctx, isHtmx, identifier);
1712
- }
1713
- async function respondSteeringAccepted(ctx, isHtmx, identifier) {
1714
- if (isHtmx) {
1715
- const p = await gatherPartialInputs(ctx.orch, ctx.view);
1716
- ctx.res.statusCode = 200;
1717
- ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1718
- ctx.res.end(renderAttentionPartial(p));
1719
- return;
1720
- }
1721
- jsonResponse(ctx.res, 202, { identifier, accepted_at: new Date().toISOString() });
1722
- }
1723
- // Returns null when the body read errored and a response has already been written;
1724
- // otherwise returns the extracted text (empty string if absent/blank, caller treats
1725
- // that as the "no text supplied" case).
1726
- async function readSteeringText(ctx, ctype) {
1727
- const isHtmx = ctx.req.headers['hx-request'] === 'true';
1728
- if (ctype.isFormBody) {
1729
- try {
1730
- return extractFormText(await readTextBody(ctx.req));
1731
- }
1732
- catch (err) {
1733
- await htmxOrJsonError(ctx.res, isHtmx, ctx.orch, ctx.view, 400, 'bad_request', err.message);
1734
- return null;
1735
- }
1736
- }
1737
- try {
1738
- return extractJsonText(await readJsonBody(ctx.req));
1739
- }
1740
- catch (err) {
1741
- badRequest(ctx.res, err.message);
1742
- return null;
1743
- }
1744
- }
1745
- // Triage approve / discard. The dashboard renders these as buttons inside the triage
1746
- // section; an operator clicks one and we move the issue file out of Triage/. Approve
1747
- // sends it to the first active state (typically Todo) where the orchestrator will pick
1748
- // it up on the next poll; discard sends it to the first terminal state that looks like a
1749
- // cancellation (case-insensitive "Cancelled" match preferred) so the proposal is
1750
- // archived rather than deleted — operators can still grep for what was proposed and
1751
- // turned down.
1752
- //
1753
- // CSRF posture mirrors the steering-reply endpoint: form-encoded requires HX-Request
1754
- // + same-origin; JSON requires application/json (preflight-triggering). HTMX errors
1755
- // come back as 200 with a partial so the section doesn't go stale.
1756
- async function handleTriage(ctx) {
1757
- if (ctx.method !== 'POST')
1758
- return methodNotAllowed(ctx.res);
1759
- const tracker = ctx.opts.tracker;
1760
- if (!tracker || !tracker.moveIssueToState) {
1761
- return notFound(ctx.res, 'tracker_no_state_transitions', 'tracker does not support state transitions');
1762
- }
1763
- const root = ctx.view.trackerRoot;
1764
- if (!root)
1765
- return badRequest(ctx.res, 'tracker.root not configured');
1766
- const { identifier, action } = ctx.route;
1767
- const ctype = classifyContentType(ctx.req.headers['content-type']);
1768
- const csrf = checkTriageCsrf(ctype, ctx.req.headers['hx-request'] === 'true', isSameOriginRequest(ctx.req));
1769
- if (!csrf.ok) {
1770
- return jsonResponse(ctx.res, csrf.status, {
1771
- error: { code: csrf.code, message: csrf.message },
1772
- });
1773
- }
1774
- const transition = decideTriageTransition(action, ctx.view.states);
1775
- if (!transition.ok) {
1776
- return jsonResponse(ctx.res, transition.status, {
1777
- error: { code: transition.code, message: transition.message },
1778
- });
1779
- }
1780
- await runTriageMove(ctx, tracker, root, identifier, action, transition.toState, transition.fromState);
1781
- }
1782
- async function runTriageMove(ctx, tracker, root, identifier, action, toState, fromState) {
1783
- const isHtmx = ctx.req.headers['hx-request'] === 'true';
1784
- try {
1785
- const result = await tracker.moveIssueToState(identifier, toState, { fromRoot: root, fromState });
1786
- log.info('triage action', { identifier, action, from: result.fromState, to: result.toState });
1787
- // Nudge the orchestrator to pick the freshly approved issue up immediately instead
1788
- // of waiting for the next poll tick. Best-effort: triggerRefresh is idempotent.
1789
- if (action === 'approve') {
1790
- try {
1791
- ctx.orch.triggerRefresh();
1792
- }
1793
- catch { /* refresh request is fire-and-forget */ }
1794
- }
1795
- if (isHtmx)
1796
- return writeBoardPartial(ctx);
1797
- jsonResponse(ctx.res, 200, {
1798
- identifier, action, from_state: result.fromState, to_state: result.toState,
1799
- });
1800
- }
1801
- catch (err) {
1802
- const code = err.code ?? 'triage_failed';
1803
- const status = code === 'local_issue_not_found' ? 404 : 409;
1804
- if (isHtmx)
1805
- return writeBoardPartial(ctx);
1806
- jsonResponse(ctx.res, status, { error: { code, message: err.message } });
1807
- }
1808
- }
1809
- // Re-render the whole board after a triage move. HTMX morph keeps unchanged columns
1810
- // stable; only the row that moved (or "failed" and stayed) redraws.
1811
- async function writeBoardPartial(ctx) {
1812
- const p = await gatherPartialInputs(ctx.orch, ctx.view);
1813
- ctx.res.statusCode = 200;
1814
- ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1815
- ctx.res.setHeader('cache-control', 'no-store');
1816
- ctx.res.end(renderBoardPartial(p));
1817
- }
1818
- // Read-only HTML view of one issue. Linked from the identifier on every "on disk" and
1819
- // triage row; renders front-matter (labels, priority, blockers, provenance) plus the
1820
- // Markdown body so an operator can read the full task without leaving the browser. No
1821
- // editing surface — actions stay on the dashboard. Source of truth is the on-disk .md
1822
- // file, found by walking every state directory under tracker.root for a basename match.
1823
- async function handleDetailHtml(ctx) {
1824
- if (ctx.method !== 'GET')
1825
- return methodNotAllowed(ctx.res);
1826
- const root = ctx.view.trackerRoot;
1827
- if (!root) {
1828
- ctx.res.statusCode = 404;
1829
- ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1830
- ctx.res.end(renderIssueNotFoundPage('(tracker root not configured)', ctx.view));
1831
- return;
1832
- }
1833
- const { identifier } = ctx.route;
1834
- const issue = await readIssueFromDisk(root, identifier);
1835
- if (!issue) {
1836
- ctx.res.statusCode = 404;
1837
- ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1838
- ctx.res.end(renderIssueNotFoundPage(identifier, ctx.view));
1839
- return;
1840
- }
1841
- ctx.res.statusCode = 200;
1842
- ctx.res.setHeader('content-type', 'text/html; charset=utf-8');
1843
- ctx.res.setHeader('cache-control', 'no-store');
1844
- ctx.res.end(renderIssueDetailPage(issue, ctx.view));
1845
- }
1846
- async function handleDetailJson(ctx) {
1847
- if (ctx.method !== 'GET')
1848
- return methodNotAllowed(ctx.res);
1849
- const { identifier } = ctx.route;
1850
- const detail = ctx.orch.detailByIdentifier(identifier);
1851
- if (!detail)
1852
- return notFound(ctx.res, 'issue_not_found', `issue ${identifier} is not tracked`);
1853
- jsonResponse(ctx.res, 200, detail);
1854
- }
1855
- async function handleNotFoundRoute(ctx) {
1856
- notFound(ctx.res);
1857
- }
1858
- const ROUTE_HANDLERS = {
1859
- dashboard: handleDashboard,
1860
- preview: handlePreview,
1861
- partial: handlePartial,
1862
- state: handleState,
1863
- refresh: handleRefresh,
1864
- issues: handleIssues,
1865
- mcp: handleMcp,
1866
- steering: handleSteeringReply,
1867
- triage: handleTriage,
1868
- detail_html: handleDetailHtml,
1869
- detail_json: handleDetailJson,
1870
- not_found: handleNotFoundRoute,
1871
- };
1872
- function renderIssueNotFoundPage(identifier, view) {
1873
- const workflowName = path.basename(view.workflowPath || 'workflow.md');
1606
+ export function renderIssueNotFoundPage(identifier, view) {
1607
+ const workflowName = basename(view.workflowPath || 'workflow.yaml');
1874
1608
  return `<!doctype html>
1875
1609
  <html lang="en"><head>
1876
1610
  <meta charset="utf-8">
@@ -1898,4 +1632,4 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; co
1898
1632
  </main>
1899
1633
  </body></html>`;
1900
1634
  }
1901
- //# sourceMappingURL=http.js.map
1635
+ //# sourceMappingURL=render.js.map