@dsdenes/devboard 0.1.560 → 0.1.563

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 (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +729 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
package/server/flow.ts ADDED
@@ -0,0 +1,2655 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ appendFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { join, relative, resolve } from "node:path";
12
+ import type { AcpActivity, AcpQuestion, AcpQuestionOption } from "./acp";
13
+ import { AcpConnection } from "./acp";
14
+ import {
15
+ CommandFlowForge,
16
+ type FlowPrAdapter,
17
+ type ForgePrState,
18
+ type ForgeTarget,
19
+ } from "./forge-pr";
20
+ import type { Exec, Forge } from "../lib/forge-auth.ts";
21
+ import { giteaBaseUrl, parseForgeRemote } from "../lib/forge";
22
+ import { journalKey, type Journal } from "./journal";
23
+
24
+ /**
25
+ * Flow Mode: one implementation agent in an isolated worktree, reviewable as
26
+ * color-coded change widgets, steered by an append-only feed and one editable
27
+ * decision board.
28
+ *
29
+ * The controller owns the session, its files under the harness state
30
+ * directory, the worktree, the ACP peers, and the diff watcher. It never
31
+ * writes `GOAL.md` or `adrs/`, never creates an issue or an epic, and never
32
+ * applies an edit a read-only peer produced. Goal Mode's behavior is untouched:
33
+ * this module only reports whether it has active work.
34
+ */
35
+
36
+ export const FLOW_CATEGORIES = [
37
+ "contract",
38
+ "entity",
39
+ "naming",
40
+ "data",
41
+ "dependency",
42
+ "irreversible",
43
+ "structure",
44
+ ] as const;
45
+
46
+ export type FlowCategory = (typeof FLOW_CATEGORIES)[number];
47
+
48
+ const CATEGORY_SET = new Set<string>(FLOW_CATEGORIES);
49
+
50
+ export interface FlowFile {
51
+ path: string;
52
+ status: "added" | "modified" | "deleted" | "renamed";
53
+ insertions: number;
54
+ deletions: number;
55
+ diff: string;
56
+ }
57
+
58
+ export interface FlowWidget {
59
+ /** Stable signature over the category, its file set, and their diff. */
60
+ id: string;
61
+ category: FlowCategory;
62
+ concept: string;
63
+ summary: string;
64
+ rationale: string;
65
+ files: string[];
66
+ accepted: boolean;
67
+ }
68
+
69
+ export interface FlowSteeringNote {
70
+ id: string;
71
+ timestamp: string;
72
+ source: string;
73
+ text: string;
74
+ status: "new" | "read";
75
+ }
76
+
77
+ export interface FlowCheckpoint {
78
+ id: string;
79
+ timestamp: string;
80
+ summary: string;
81
+ files: number;
82
+ commit: string;
83
+ verification: "passed" | "failed" | "not-run";
84
+ }
85
+
86
+ export interface FlowVerification {
87
+ command: string;
88
+ status: "passed" | "failed" | "not-run";
89
+ timestamp: string;
90
+ output: string;
91
+ }
92
+
93
+ export interface FlowBlocker {
94
+ id: string;
95
+ timestamp: string;
96
+ text: string;
97
+ dismissed: boolean;
98
+ }
99
+
100
+ export interface FlowPr {
101
+ forge: Forge;
102
+ number: number;
103
+ url: string;
104
+ state: "none" | "draft" | "live" | "merged" | "closed" | "error";
105
+ }
106
+
107
+ export interface FlowQuestion {
108
+ id: number | string;
109
+ message: string;
110
+ options: AcpQuestionOption[];
111
+ }
112
+
113
+ export interface FlowConversationMessage {
114
+ role: "developer" | "agent";
115
+ text: string;
116
+ }
117
+
118
+ export interface FlowHistoryEntry {
119
+ sessionId: string;
120
+ goal: string;
121
+ pr: FlowPr | null;
122
+ }
123
+
124
+ export interface FlowSnapshot {
125
+ sessionId: string | null;
126
+ status: "none" | "idle" | "running" | "stopped" | "merged";
127
+ goal: string;
128
+ decisions: string;
129
+ worktree: string | null;
130
+ branch: string | null;
131
+ baseRevision: string | null;
132
+ files: FlowFile[];
133
+ widgets: FlowWidget[];
134
+ steering: FlowSteeringNote[];
135
+ checkpoints: FlowCheckpoint[];
136
+ verification: FlowVerification | null;
137
+ blockers: FlowBlocker[];
138
+ pr: FlowPr | null;
139
+ prError: string | null;
140
+ leftover: string | null;
141
+ question: FlowQuestion | null;
142
+ goalCreation: { activity: string[]; question: FlowQuestion | null } | null;
143
+ conversation: {
144
+ widgetId: string;
145
+ messages: FlowConversationMessage[];
146
+ activity: string[];
147
+ question: FlowQuestion | null;
148
+ } | null;
149
+ activity: string[];
150
+ notice: string | null;
151
+ history: FlowHistoryEntry[];
152
+ }
153
+
154
+ export interface FlowOptions {
155
+ repoDir: string;
156
+ stateDir: string;
157
+ agentCommand: string;
158
+ broadcast: (event: unknown) => void;
159
+ journal?: Pick<Journal, "write">;
160
+ /** Injected fake forge, or a real one built from `exec`. */
161
+ forge?: FlowPrAdapter;
162
+ /** Classified origin remote for the forge adapter. */
163
+ forgeTarget?: ForgeTarget;
164
+ exec?: Exec;
165
+ now?: () => number;
166
+ /** Poll interval of the diff watcher. */
167
+ watchMs?: number;
168
+ /** Bound on one analyst turn. */
169
+ analystTimeoutMs?: number;
170
+ /** Bound on one goal interview turn. */
171
+ goalTimeoutMs?: number;
172
+ /** Describes active Goal Mode work, if any. */
173
+ goalWorkActive?: () => string | undefined;
174
+ }
175
+
176
+ interface FlowSession {
177
+ id: string;
178
+ baseRevision: string | null;
179
+ worktree: string;
180
+ branch: string;
181
+ status: "idle" | "running" | "stopped" | "merged";
182
+ goal: string;
183
+ decisions: string;
184
+ steering: FlowSteeringNote[];
185
+ accepted: string[];
186
+ widgets: FlowWidget[];
187
+ files: FlowFile[];
188
+ checkpoints: FlowCheckpoint[];
189
+ verification: FlowVerification | null;
190
+ blockers: FlowBlocker[];
191
+ pr: FlowPr | null;
192
+ prError: string | null;
193
+ leftover: string | null;
194
+ question: FlowQuestion | null;
195
+ pushedTip: string | null;
196
+ prFailedTip: string | null;
197
+ lastDiffHash: string | null;
198
+ createdAt: string;
199
+ updatedAt: string;
200
+ }
201
+
202
+ interface RawWidget {
203
+ category: string;
204
+ concept: string;
205
+ summary: string;
206
+ rationale: string;
207
+ files: string[];
208
+ }
209
+
210
+ interface Conversation {
211
+ widgetId: string;
212
+ agent: AcpConnection | undefined;
213
+ messages: FlowConversationMessage[];
214
+ activity: string[];
215
+ question: FlowQuestion | null;
216
+ busy: boolean;
217
+ queue: string[];
218
+ }
219
+
220
+ interface CommandResult {
221
+ stdout: string;
222
+ stderr: string;
223
+ code: number;
224
+ }
225
+
226
+ const STALE_VERIFICATION: FlowVerification["status"] = "not-run";
227
+ const DEFAULT_WATCH_MS = 400;
228
+ const DEFAULT_ANALYST_TIMEOUT_MS = 60_000;
229
+ const DEFAULT_GOAL_TIMEOUT_MS = 120_000;
230
+ const OUTPUT_TAIL_MAX = 4000;
231
+ const BLOCKER_MARKER = "DEVBOARD-BLOCKED:";
232
+ const GIT_ENV: Record<string, string> = {
233
+ GIT_TERMINAL_PROMPT: "0",
234
+ GIT_ASKPASS: "true",
235
+ NO_COLOR: "1",
236
+ };
237
+
238
+ function nowIso(now: () => number): string {
239
+ return new Date(now()).toISOString();
240
+ }
241
+
242
+ function sha256(text: string): string {
243
+ return createHash("sha256").update(text).digest("hex");
244
+ }
245
+
246
+ /** Lines of a JSON-lines file; a malformed or absent file reads as none. */
247
+ function readJsonLines<T>(path: string): T[] {
248
+ let source: string;
249
+ try {
250
+ source = readFileSync(path, "utf8");
251
+ } catch {
252
+ return [];
253
+ }
254
+ const entries: T[] = [];
255
+ for (const line of source.split(/\r?\n/)) {
256
+ const trimmed = line.trim();
257
+ if (trimmed.length === 0) continue;
258
+ try {
259
+ entries.push(JSON.parse(trimmed) as T);
260
+ } catch {
261
+ // One damaged line costs at most that entry.
262
+ }
263
+ }
264
+ return entries;
265
+ }
266
+
267
+ function readText(path: string): string | undefined {
268
+ try {
269
+ return readFileSync(path, "utf8");
270
+ } catch {
271
+ return undefined;
272
+ }
273
+ }
274
+
275
+ function writeText(path: string, text: string): void {
276
+ mkdirSync(join(path, ".."), { recursive: true });
277
+ writeFileSync(path, text);
278
+ }
279
+
280
+ function writeJson(path: string, value: unknown): void {
281
+ writeText(path, `${JSON.stringify(value, null, 2)}\n`);
282
+ }
283
+
284
+ function readJson<T>(path: string): T | undefined {
285
+ const text = readText(path);
286
+ if (text === undefined) return undefined;
287
+ try {
288
+ return JSON.parse(text) as T;
289
+ } catch {
290
+ return undefined;
291
+ }
292
+ }
293
+
294
+ function appendLine(path: string, value: unknown): void {
295
+ mkdirSync(join(path, ".."), { recursive: true });
296
+ appendFileSync(path, `${JSON.stringify(value)}\n`);
297
+ }
298
+
299
+ function firstLine(text: string): string {
300
+ return (
301
+ text
302
+ .split(/\r?\n/)
303
+ .map((line) => line.trim().replace(/^#+\s*/, ""))
304
+ .find((line) => line.length > 0) ?? "Flow session"
305
+ );
306
+ }
307
+
308
+ function boundedTail(text: string): string {
309
+ return text.length <= OUTPUT_TAIL_MAX ? text : text.slice(text.length - OUTPUT_TAIL_MAX);
310
+ }
311
+
312
+ /** A diff without the volatile index line and trailing spaces. */
313
+ export function normalizeDiff(diff: string): string {
314
+ return diff
315
+ .replace(/\r\n/g, "\n")
316
+ .split("\n")
317
+ .filter((line) => !line.startsWith("index "))
318
+ .map((line) => line.replace(/[ \t]+$/, ""))
319
+ .join("\n");
320
+ }
321
+
322
+ /** Parses a unified diff into the files it touches, with per-file counts. */
323
+ /** Resolves after `ms`; races bounded ACP turns. */
324
+ function timeout(ms: number): Promise<void> {
325
+ const { promise, resolve } = Promise.withResolvers<void>();
326
+ setTimeout(resolve, ms);
327
+ return promise;
328
+ }
329
+
330
+ function countDiffLines(chunk: string): { insertions: number; deletions: number } {
331
+ let insertions = 0;
332
+ let deletions = 0;
333
+ for (const line of chunk.split("\n")) {
334
+ if (line.startsWith("+") && !line.startsWith("+++")) insertions += 1;
335
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
336
+ }
337
+ return { insertions, deletions };
338
+ }
339
+
340
+ /**
341
+ * Decodes one path token of a `diff --git` header. Git quotes a token with a
342
+ * C-style escape when the path holds a quote, a backslash, a control byte, or
343
+ * (with `core.quotePath`, the default) a non-ASCII byte.
344
+ */
345
+ function unquoteGitPath(token: string): string {
346
+ if (token.length < 2 || !token.startsWith('"') || !token.endsWith('"')) return token;
347
+ const end = token.length - 1;
348
+ const bytes: number[] = [];
349
+ const escapes: Record<string, number> = {
350
+ a: 7,
351
+ b: 8,
352
+ f: 12,
353
+ n: 10,
354
+ r: 13,
355
+ t: 9,
356
+ v: 11,
357
+ '"': 34,
358
+ "\\": 92,
359
+ };
360
+ let index = 1;
361
+ while (index < end) {
362
+ const char = token[index] ?? "";
363
+ if (char !== "\\") {
364
+ bytes.push(...Buffer.from(char, "utf8"));
365
+ index += 1;
366
+ continue;
367
+ }
368
+ const next = token[index + 1];
369
+ if (next === undefined) {
370
+ bytes.push(92);
371
+ break;
372
+ }
373
+ const simple = escapes[next];
374
+ if (simple !== undefined) {
375
+ bytes.push(simple);
376
+ index += 2;
377
+ continue;
378
+ }
379
+ if (next >= "0" && next <= "7") {
380
+ const octal = /^[0-7]{1,3}/.exec(token.slice(index + 1))?.[0] ?? "";
381
+ bytes.push(Number.parseInt(octal, 8));
382
+ index += 1 + octal.length;
383
+ continue;
384
+ }
385
+ bytes.push(92);
386
+ index += 1;
387
+ }
388
+ return Buffer.from(bytes).toString("utf8");
389
+ }
390
+
391
+ /**
392
+ * The two paths of a `diff --git` header, without the `a/` and `b/` prefixes.
393
+ * Quoted headers are decoded; a plain header keeps the usual greedy split.
394
+ */
395
+ function diffHeaderPaths(rest: string): [string, string] | undefined {
396
+ let first: string;
397
+ let second: string;
398
+ if (rest.startsWith('"')) {
399
+ let index = 1;
400
+ while (index < rest.length && rest[index] !== '"') {
401
+ index += rest[index] === "\\" ? 2 : 1;
402
+ }
403
+ const closing = rest[index];
404
+ if (closing !== '"' || rest[index + 1] !== " ") return undefined;
405
+ first = unquoteGitPath(rest.slice(0, index + 1));
406
+ second = unquoteGitPath(rest.slice(index + 2));
407
+ } else {
408
+ const plain = /^a\/(.+) b\/(.+)$/.exec(rest);
409
+ if (plain === null) return undefined;
410
+ first = plain[1] ?? "";
411
+ second = plain[2] ?? "";
412
+ if (first.length === 0 || second.length === 0) return undefined;
413
+ return [first, second];
414
+ }
415
+ if (!first.startsWith("a/") || !second.startsWith("b/")) return undefined;
416
+ return [first.slice(2), second.slice(2)];
417
+ }
418
+
419
+ /** Splits one `git diff` result into per-path chunks. */
420
+ export function splitDiffFiles(text: string): Map<string, string> {
421
+ const files = new Map<string, string>();
422
+ let currentPath: string | undefined;
423
+ let lines: string[] = [];
424
+ const flush = () => {
425
+ // Dropping the separator newline keeps a chunk identical whether or not
426
+ // another file follows it, so diffHash ignores file order and neighbours.
427
+ if (currentPath !== undefined) files.set(currentPath, lines.join("\n").replace(/\n+$/, ""));
428
+ currentPath = undefined;
429
+ lines = [];
430
+ };
431
+ for (const line of text.split("\n")) {
432
+ if (!line.startsWith("diff --git ")) {
433
+ if (currentPath !== undefined) lines.push(line);
434
+ continue;
435
+ }
436
+ // A file head always ends the previous chunk, so an unreadable header can
437
+ // never fold its body into another file's diff.
438
+ flush();
439
+ const paths = diffHeaderPaths(line.slice("diff --git ".length));
440
+ if (paths === undefined) continue;
441
+ currentPath = paths[1];
442
+ lines = [line];
443
+ }
444
+ flush();
445
+ return files;
446
+ }
447
+
448
+ /** The tracked files one `git diff` result touches, with per-file counts. */
449
+ export function diffFiles(text: string): FlowFile[] {
450
+ return [...splitDiffFiles(text)].map(([path, chunk]) => {
451
+ const counts = countDiffLines(chunk);
452
+ return {
453
+ path,
454
+ status: fileStatus(chunk),
455
+ insertions: counts.insertions,
456
+ deletions: counts.deletions,
457
+ diff: chunk,
458
+ };
459
+ });
460
+ }
461
+
462
+ function fileStatus(chunk: string): FlowFile["status"] {
463
+ if (/^new file mode /m.test(chunk)) return "added";
464
+ if (/^deleted file mode /m.test(chunk)) return "deleted";
465
+ if (/^rename from /m.test(chunk)) return "renamed";
466
+ return "modified";
467
+ }
468
+
469
+ /** The whole-diff hash the watcher compares to decide whether to analyze. */
470
+ export function diffHash(files: FlowFile[]): string {
471
+ return sha256(
472
+ files
473
+ .map((file) => `${file.path}\0${normalizeDiff(file.diff)}`)
474
+ .sort()
475
+ .join("\0"),
476
+ );
477
+ }
478
+
479
+ /**
480
+ * A widget's identity: its category, its sorted file set, and the normalized
481
+ * diff of those files. Concept text is display metadata and never part of it.
482
+ */
483
+ export function widgetSignature(
484
+ category: string,
485
+ paths: string[],
486
+ diffs: Map<string, string>,
487
+ ): string {
488
+ const body = [
489
+ category,
490
+ [...paths].sort().join("\0"),
491
+ [...paths]
492
+ .sort()
493
+ .map((path) => normalizeDiff(diffs.get(path) ?? ""))
494
+ .join("\0"),
495
+ ].join("\0");
496
+ return sha256(body).slice(0, 16);
497
+ }
498
+
499
+ /** One new file's unified diff, built rather than shelled out to git. */
500
+ function untrackedDiff(path: string, content: string): string {
501
+ const header = ["diff --git a/" + path + " b/" + path, "new file mode 100644", "--- /dev/null", "+++ b/" + path];
502
+ if (content.length === 0) return header.join("\n");
503
+ const lines = content.split("\n");
504
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
505
+ const body = [`@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`)];
506
+ return [...header, ...body].join("\n");
507
+ }
508
+
509
+ export class FlowController {
510
+ readonly repoDir: string;
511
+ readonly stateDir: string;
512
+ readonly #agentCommand: string;
513
+ readonly #broadcast: (event: unknown) => void;
514
+ readonly #journal: Pick<Journal, "write"> | undefined;
515
+ readonly #forge: FlowPrAdapter;
516
+ readonly #forgeTarget: ForgeTarget | undefined;
517
+ readonly #exec: Exec;
518
+ readonly #now: () => number;
519
+ readonly #watchMs: number;
520
+ readonly #analystTimeoutMs: number;
521
+ readonly #goalTimeoutMs: number;
522
+ readonly #goalWorkActive: () => string | undefined;
523
+
524
+ #session: FlowSession | undefined;
525
+ #history: FlowHistoryEntry[] = [];
526
+ /** The goal text already on disk, or null when goal.md may not match it. */
527
+ #persistedGoal: string | null = null;
528
+ #implementation:
529
+ | { agent: AcpConnection | undefined; activity: string[]; finished: boolean }
530
+ | undefined;
531
+ #goalCreation:
532
+ | { agent: AcpConnection | undefined; activity: string[]; question: FlowQuestion | null }
533
+ | undefined;
534
+ #analyst: AcpConnection | undefined;
535
+ #analystPending = false;
536
+ #conversation: Conversation | undefined;
537
+ #timer: Timer | undefined;
538
+ #ticking = false;
539
+ #tickQueued = false;
540
+ #disposed = false;
541
+ #restoring = false;
542
+ #replayWaiters: { send(data: string): unknown }[] = [];
543
+ /** True until Start: a restored session never spawns an analyst by itself. */
544
+ #quiet = false;
545
+ #prBusy = false;
546
+ /** The newest checkpoint tip a busy PR sync must pick up when it finishes. */
547
+ #prPendingTip: string | null = null;
548
+ #decisionsChain: Promise<void> = Promise.resolve();
549
+ #seenCommits = new Set<string>();
550
+ #startedAt: string;
551
+ #restartNotice: string | null = null;
552
+
553
+ constructor(options: FlowOptions) {
554
+ this.repoDir = resolve(options.repoDir);
555
+ this.stateDir = resolve(options.stateDir);
556
+ this.#agentCommand = options.agentCommand;
557
+ this.#broadcast = options.broadcast;
558
+ this.#journal = options.journal;
559
+ this.#forgeTarget = options.forgeTarget;
560
+ this.#exec =
561
+ options.exec ??
562
+ (async (command, args, execOptions) => {
563
+ try {
564
+ const child = Bun.spawn({
565
+ cmd: [command, ...args],
566
+ cwd: execOptions.cwd,
567
+ env: execOptions.env,
568
+ stdin: "ignore",
569
+ stdout: "pipe",
570
+ stderr: "pipe",
571
+ });
572
+ const [stdout, stderr, code] = await Promise.all([
573
+ new Response(child.stdout).text(),
574
+ new Response(child.stderr).text(),
575
+ child.exited,
576
+ ]);
577
+ return { stdout, stderr, code };
578
+ } catch (error) {
579
+ return {
580
+ stdout: "",
581
+ stderr: error instanceof Error ? error.message : String(error),
582
+ code: -1,
583
+ };
584
+ }
585
+ });
586
+ this.#forge = options.forge ?? new CommandFlowForge(this.#exec);
587
+ this.#now = options.now ?? Date.now;
588
+ this.#watchMs = Math.max(10, options.watchMs ?? DEFAULT_WATCH_MS);
589
+ this.#analystTimeoutMs = Math.max(10, options.analystTimeoutMs ?? DEFAULT_ANALYST_TIMEOUT_MS);
590
+ this.#goalTimeoutMs = Math.max(10, options.goalTimeoutMs ?? DEFAULT_GOAL_TIMEOUT_MS);
591
+ this.#goalWorkActive = options.goalWorkActive ?? (() => undefined);
592
+ this.#startedAt = nowIso(this.#now);
593
+ // Restore after the host has finished wiring its own state: a restored
594
+ // session may broadcast before the caller's declarations are initialized.
595
+ queueMicrotask(() => {
596
+ void this.#restore();
597
+ });
598
+ }
599
+
600
+ // ---------------------------------------------------------------- paths
601
+
602
+ /** `<stateDir>/flow/<24-hex repository key>/<session-id>`. */
603
+ get flowRoot(): string {
604
+ return join(this.stateDir, "flow", journalKey(this.repoDir));
605
+ }
606
+
607
+ /** The session directory, or undefined before a session exists. */
608
+ get sessionDir(): string | undefined {
609
+ return this.#session === undefined ? undefined : join(this.flowRoot, this.#session.id);
610
+ }
611
+
612
+ #path(name: string): string {
613
+ const directory = this.sessionDir;
614
+ if (directory === undefined) throw new Error("No Flow session exists.");
615
+ return join(directory, name);
616
+ }
617
+
618
+ #worktreePath(id: string): string {
619
+ return join(this.repoDir, ".worktree", `flow-${id}`);
620
+ }
621
+
622
+ // ------------------------------------------------------------ lifecycle
623
+
624
+ /** True while any Flow work is live, for Goal Mode's own guards. */
625
+ hasActiveWork(): boolean {
626
+ return (
627
+ this.#goalCreation !== undefined ||
628
+ this.#implementation !== undefined ||
629
+ this.#analyst !== undefined ||
630
+ this.#conversation !== undefined ||
631
+ this.#prBusy
632
+ );
633
+ }
634
+
635
+ /** True while a session exists whose worktree or PR is not merged. */
636
+ hasUnmergedSession(): boolean {
637
+ return this.#session !== undefined && this.#session.status !== "merged";
638
+ }
639
+
640
+ #goalWorkLabel(): string | undefined {
641
+ return this.#goalWorkActive();
642
+ }
643
+
644
+ snapshot(): FlowSnapshot {
645
+ const session = this.#session;
646
+ const conversation = this.#conversation;
647
+ const creation = this.#goalCreation;
648
+ return {
649
+ sessionId: session?.id ?? null,
650
+ status: session?.status ?? "none",
651
+ goal: session?.goal ?? "",
652
+ decisions: session?.decisions ?? "",
653
+ worktree: session?.worktree ?? null,
654
+ branch: session?.branch ?? null,
655
+ baseRevision: session?.baseRevision ?? null,
656
+ files: session?.files ?? [],
657
+ widgets: session?.widgets ?? [],
658
+ steering: session?.steering ?? [],
659
+ checkpoints: session?.checkpoints ?? [],
660
+ verification: session?.verification ?? null,
661
+ blockers: session?.blockers ?? [],
662
+ pr: session?.pr ?? null,
663
+ prError: session?.prError ?? null,
664
+ leftover: session?.leftover ?? null,
665
+ question: session?.question ?? null,
666
+ goalCreation:
667
+ creation === undefined ? null : { activity: [...creation.activity], question: creation.question },
668
+ conversation:
669
+ conversation === undefined
670
+ ? null
671
+ : {
672
+ widgetId: conversation.widgetId,
673
+ messages: [...conversation.messages],
674
+ activity: [...conversation.activity],
675
+ question: conversation.question,
676
+ },
677
+ activity: this.#implementation === undefined ? [] : [...this.#implementation.activity],
678
+ notice: this.#restartNotice,
679
+ history: [...this.#history],
680
+ };
681
+ }
682
+
683
+ /**
684
+ * Sends the complete current Flow state, then the markers and streamed lines
685
+ * a connecting client needs: the session first, then the started marker,
686
+ * every recorded activity line, and the pending question, before the client's
687
+ * own replies. The client resets the matching activity list on each marker.
688
+ */
689
+ replay(target?: { send(data: string): unknown }): void {
690
+ if (this.#restoring) {
691
+ if (target !== undefined) this.#replayWaiters.push(target);
692
+ return;
693
+ }
694
+ // A server with no Flow work sends nothing: the view already starts empty.
695
+ if (
696
+ this.#session === undefined &&
697
+ this.#goalCreation === undefined &&
698
+ this.#conversation === undefined &&
699
+ this.#implementation === undefined
700
+ ) {
701
+ return;
702
+ }
703
+ const snapshot = this.snapshot();
704
+ const deliver = (event: unknown) => {
705
+ if (target === undefined) {
706
+ this.#broadcast(event);
707
+ return;
708
+ }
709
+ try {
710
+ target.send(JSON.stringify(event));
711
+ } catch {
712
+ // The socket closed before it received the event.
713
+ }
714
+ };
715
+
716
+ deliver({ type: "flowSnapshot", ...snapshot });
717
+ const session = this.#session;
718
+ if (session !== undefined) {
719
+ if (session.status === "running") {
720
+ deliver({
721
+ type: "flowImplementationStarted",
722
+ sessionId: session.id,
723
+ worktree: session.worktree,
724
+ branch: session.branch,
725
+ baseRevision: session.baseRevision,
726
+ });
727
+ }
728
+ deliver({ type: "flowFiles", files: session.files });
729
+ deliver({ type: "flowWidgets", widgets: session.widgets });
730
+ deliver({ type: "flowSteering", notes: session.steering });
731
+ deliver({ type: "flowDecisions", text: session.decisions });
732
+ deliver({ type: "flowCheckpoints", checkpoints: session.checkpoints });
733
+ deliver({ type: "flowVerification", verification: session.verification });
734
+ deliver({ type: "flowBlockers", blockers: session.blockers });
735
+ deliver({ type: "flowPr", pr: session.pr });
736
+ }
737
+
738
+ const creation = this.#goalCreation;
739
+ if (creation !== undefined) {
740
+ deliver({ type: "flowGoalStarted" });
741
+ for (const text of creation.activity) deliver({ type: "flowActivity", text });
742
+ if (creation.question !== null) {
743
+ deliver({
744
+ type: "flowQuestion",
745
+ target: "goal",
746
+ id: creation.question.id,
747
+ message: creation.question.message,
748
+ options: creation.question.options,
749
+ });
750
+ }
751
+ }
752
+
753
+ const implementation = this.#implementation;
754
+ if (implementation !== undefined) {
755
+ for (const text of implementation.activity) deliver({ type: "flowActivity", text });
756
+ if (session?.question != null) {
757
+ deliver({
758
+ type: "flowQuestion",
759
+ target: "implementation",
760
+ id: session.question.id,
761
+ message: session.question.message,
762
+ options: session.question.options,
763
+ });
764
+ }
765
+ }
766
+
767
+ const conversation = this.#conversation;
768
+ if (conversation !== undefined) {
769
+ deliver({ type: "flowConversationStarted", widgetId: conversation.widgetId });
770
+ for (const message of conversation.messages) {
771
+ deliver({ type: "flowConversationMessage", role: message.role, text: message.text });
772
+ }
773
+ for (const text of conversation.activity) {
774
+ deliver({ type: "flowConversationActivity", text });
775
+ }
776
+ if (conversation.question !== null) {
777
+ deliver({
778
+ type: "flowConversationQuestion",
779
+ id: conversation.question.id,
780
+ message: conversation.question.message,
781
+ options: conversation.question.options,
782
+ });
783
+ }
784
+ }
785
+ }
786
+
787
+ #publish(): void {
788
+ this.#broadcast({ type: "flowSnapshot", ...this.snapshot() });
789
+ }
790
+
791
+ #error(message: string): void {
792
+ this.#broadcast({ type: "flowError", message });
793
+ }
794
+
795
+ #notice(message: string): void {
796
+ this.#broadcast({ type: "flowNotice", message });
797
+ }
798
+
799
+ // ------------------------------------------------------- persistence
800
+
801
+ #sessionFile(session: FlowSession): Record<string, unknown> {
802
+ return { version: 1, ...session };
803
+ }
804
+
805
+ #persist(): void {
806
+ const session = this.#session;
807
+ if (session === undefined) return;
808
+ session.updatedAt = nowIso(this.#now);
809
+ try {
810
+ writeJson(this.#path("session.json"), this.#sessionFile(session));
811
+ } catch (error) {
812
+ this.#journal?.write(
813
+ "server-error",
814
+ `flow: could not write session state: ${error instanceof Error ? error.message : String(error)}`,
815
+ );
816
+ }
817
+ }
818
+
819
+ /** Writes the reviewable artifacts beside the session's machine state. */
820
+ #persistArtifacts(): void {
821
+ const session = this.#session;
822
+ if (session === undefined) return;
823
+ try {
824
+ // goal.md is the goal interview's own output channel: while a turn is
825
+ // live, writing the in-memory goal here would overwrite its draft with
826
+ // the previous goal. Outside an interview, write only on a real change.
827
+ if (this.#goalCreation === undefined && this.#persistedGoal !== session.goal) {
828
+ writeText(this.#path("goal.md"), session.goal);
829
+ this.#persistedGoal = session.goal;
830
+ }
831
+ writeText(this.#path("decisions.md"), session.decisions);
832
+ writeJson(this.#path("accepted.json"), { signatures: session.accepted });
833
+ writeJson(this.#path("changes.json"), session.files);
834
+ } catch (error) {
835
+ this.#journal?.write(
836
+ "server-error",
837
+ `flow: could not write session files: ${error instanceof Error ? error.message : String(error)}`,
838
+ );
839
+ }
840
+ }
841
+
842
+ #appendSteering(note: FlowSteeringNote): void {
843
+ appendLine(this.#path("steering.jsonl"), note);
844
+ this.#journal?.write(
845
+ "activity",
846
+ `flow steering [${note.source}] ${note.text.slice(0, 200)}`,
847
+ );
848
+ }
849
+
850
+ #registerHistory(): void {
851
+ const session = this.#session;
852
+ if (session === undefined || session.status !== "merged") return;
853
+ this.#history = [
854
+ { sessionId: session.id, goal: session.goal, pr: session.pr },
855
+ ...this.#history.filter((entry) => entry.sessionId !== session.id),
856
+ ];
857
+ }
858
+
859
+ async #restore(): Promise<void> {
860
+ this.#restoring = true;
861
+ try {
862
+ let entries: string[];
863
+ try {
864
+ entries = readdirSync(this.flowRoot, { withFileTypes: true })
865
+ .filter((entry) => entry.isDirectory())
866
+ .map((entry) => entry.name);
867
+ } catch {
868
+ return;
869
+ }
870
+
871
+ const sessions: { session: FlowSession; directory: string }[] = [];
872
+ for (const id of entries) {
873
+ const directory = join(this.flowRoot, id);
874
+ const stored = readJson<Record<string, unknown>>(join(directory, "session.json"));
875
+ if (stored === undefined || stored.id !== id) continue;
876
+ const session = this.#parseSession(stored, id, directory);
877
+ if (session === undefined) continue;
878
+ sessions.push({ session, directory });
879
+ }
880
+ if (sessions.length === 0) return;
881
+
882
+ sessions.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt));
883
+ const merged = sessions.filter((entry) => entry.session.status === "merged");
884
+ this.#history = merged.map((entry) => ({
885
+ sessionId: entry.session.id,
886
+ goal: entry.session.goal,
887
+ pr: entry.session.pr,
888
+ }));
889
+
890
+ const current = sessions.find((entry) => entry.session.status !== "merged");
891
+ if (current === undefined) return;
892
+
893
+ const session = current.session;
894
+ // The files beside the machine state are authoritative for the text a
895
+ // developer or agent may have appended while the process was down.
896
+ const storedGoal = readText(join(current.directory, "goal.md"));
897
+ if (storedGoal !== undefined) session.goal = storedGoal;
898
+ this.#persistedGoal = storedGoal ?? null;
899
+ session.decisions = readText(join(current.directory, "decisions.md")) ?? session.decisions;
900
+ const acks = new Set(
901
+ readJsonLines<{ id?: unknown } | string>(join(current.directory, "steering-ack.jsonl")).map(
902
+ (entry) => (typeof entry === "string" ? entry : String(entry.id ?? "")),
903
+ ),
904
+ );
905
+ session.steering = session.steering.map((note) =>
906
+ acks.has(note.id) ? { ...note, status: "read" } : note,
907
+ );
908
+ const verification = readJson<FlowVerification>(join(current.directory, "verification.json"));
909
+ if (verification !== undefined && typeof verification.status === "string") {
910
+ session.verification = verification;
911
+ }
912
+ const storedCheckpoints = readJsonLines<FlowCheckpoint>(
913
+ join(current.directory, "checkpoints.jsonl"),
914
+ );
915
+ for (const entry of storedCheckpoints) {
916
+ if (typeof entry.commit !== "string") continue;
917
+ if (!session.checkpoints.some((candidate) => candidate.commit === entry.commit)) {
918
+ session.checkpoints.push(entry);
919
+ }
920
+ }
921
+
922
+ const wasRunning = session.status === "running";
923
+ session.status = wasRunning ? "stopped" : session.status === "idle" ? "idle" : session.status;
924
+ session.question = null;
925
+ this.#session = session;
926
+ for (const checkpoint of session.checkpoints) this.#seenCommits.add(checkpoint.commit);
927
+ this.#persist();
928
+
929
+ if (wasRunning) {
930
+ this.#restartNotice =
931
+ "The server restarted, so the Flow implementation stopped. Start it again to resume.";
932
+ this.#broadcast({ type: "flowNotice", message: this.#restartNotice });
933
+ }
934
+
935
+ // Widgets are recomputed from the worktree without spawning a peer.
936
+ if (existsSync(session.worktree)) {
937
+ const files = await this.#computeDiff(session);
938
+ if (files !== undefined) {
939
+ session.files = files;
940
+ const raw = readJson<{ widgets?: RawWidget[] }>(join(current.directory, "widgets.json"));
941
+ session.widgets = this.#reconcile(
942
+ raw !== null && typeof raw === "object" && Array.isArray(raw.widgets)
943
+ ? raw.widgets
944
+ : this.#widgetsAsRaw(session.widgets),
945
+ files,
946
+ session,
947
+ );
948
+ session.lastDiffHash = diffHash(files);
949
+ this.#persist();
950
+ this.#persistArtifacts();
951
+ }
952
+ }
953
+ this.#publish();
954
+ this.#emitSections({ files: true, widgets: true });
955
+ this.#quiet = true;
956
+ if (session.status !== "merged") this.#startWatchdog();
957
+ } finally {
958
+ this.#restoring = false;
959
+ const waiters = this.#replayWaiters;
960
+ this.#replayWaiters = [];
961
+ for (const waiter of waiters) this.replay(waiter);
962
+ }
963
+ }
964
+
965
+ #parseSession(
966
+ stored: Record<string, unknown>,
967
+ id: string,
968
+ directory: string,
969
+ ): FlowSession | undefined {
970
+ if (typeof stored.worktree !== "string" || typeof stored.branch !== "string") return undefined;
971
+ const status = stored.status;
972
+ if (
973
+ status !== "idle" &&
974
+ status !== "running" &&
975
+ status !== "stopped" &&
976
+ status !== "merged"
977
+ ) {
978
+ return undefined;
979
+ }
980
+ const array = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
981
+ const pre = (): FlowSession => ({
982
+ id,
983
+ baseRevision: typeof stored.baseRevision === "string" ? stored.baseRevision : null,
984
+ worktree: stored.worktree as string,
985
+ branch: stored.branch as string,
986
+ status,
987
+ goal: typeof stored.goal === "string" ? stored.goal : "",
988
+ decisions: typeof stored.decisions === "string" ? stored.decisions : "",
989
+ steering: array<FlowSteeringNote>(stored.steering),
990
+ accepted: array<string>(stored.accepted),
991
+ widgets: array<FlowWidget>(stored.widgets),
992
+ files: array<FlowFile>(stored.files),
993
+ checkpoints: array<FlowCheckpoint>(stored.checkpoints),
994
+ verification:
995
+ typeof stored.verification === "object" && stored.verification !== null
996
+ ? (stored.verification as FlowVerification)
997
+ : null,
998
+ blockers: array<FlowBlocker>(stored.blockers),
999
+ pr:
1000
+ typeof stored.pr === "object" && stored.pr !== null ? (stored.pr as FlowPr) : null,
1001
+ prError: typeof stored.prError === "string" ? stored.prError : null,
1002
+ leftover: typeof stored.leftover === "string" ? stored.leftover : null,
1003
+ question: null,
1004
+ pushedTip: typeof stored.pushedTip === "string" ? stored.pushedTip : null,
1005
+ prFailedTip: typeof stored.prFailedTip === "string" ? stored.prFailedTip : null,
1006
+ lastDiffHash: typeof stored.lastDiffHash === "string" ? stored.lastDiffHash : null,
1007
+ createdAt:
1008
+ typeof stored.createdAt === "string"
1009
+ ? stored.createdAt
1010
+ : (readText(join(directory, "session.json")) === undefined ? this.#startedAt : this.#startedAt),
1011
+ updatedAt: typeof stored.updatedAt === "string" ? stored.updatedAt : this.#startedAt,
1012
+ });
1013
+ return pre();
1014
+ }
1015
+
1016
+ #widgetsAsRaw(widgets: FlowWidget[]): RawWidget[] {
1017
+ return widgets.map((widget) => ({
1018
+ category: widget.category,
1019
+ concept: widget.concept,
1020
+ summary: widget.summary,
1021
+ rationale: widget.rationale,
1022
+ files: widget.files,
1023
+ }));
1024
+ }
1025
+
1026
+ dispose(): void {
1027
+ this.#disposed = true;
1028
+ if (this.#timer !== undefined) {
1029
+ clearInterval(this.#timer);
1030
+ this.#timer = undefined;
1031
+ }
1032
+ this.#implementation?.agent?.close();
1033
+ this.#implementation = undefined;
1034
+ this.#goalCreation?.agent?.close();
1035
+ this.#goalCreation = undefined;
1036
+ this.#analyst?.close();
1037
+ this.#analyst = undefined;
1038
+ this.#conversation?.agent?.close();
1039
+ this.#conversation = undefined;
1040
+ }
1041
+
1042
+ // -------------------------------------------------------------- commands
1043
+
1044
+ async createGoal(): Promise<void> {
1045
+ const goalWork = this.#goalWorkLabel();
1046
+ if (goalWork !== undefined) {
1047
+ this.#error(`Goal Mode is active (${goalWork}). Stop it before starting Flow work.`);
1048
+ return;
1049
+ }
1050
+ if (this.#implementation !== undefined) {
1051
+ this.#error("The Flow implementation is running. Stop it before creating a new goal.");
1052
+ return;
1053
+ }
1054
+ if (this.#goalCreation !== undefined) {
1055
+ this.#error("A Flow goal creation is already active.");
1056
+ return;
1057
+ }
1058
+
1059
+ if (this.#session === undefined || this.#session.status === "merged") {
1060
+ await this.#newSession();
1061
+ if (this.#session === undefined) return;
1062
+ }
1063
+
1064
+ const creation = { agent: undefined, activity: [] as string[], question: null };
1065
+ this.#goalCreation = creation;
1066
+ this.#broadcast({ type: "flowGoalStarted" });
1067
+ this.#publish();
1068
+ await this.#spawnGoalCreation(creation);
1069
+ }
1070
+
1071
+ async #newSession(): Promise<void> {
1072
+ const root = this.flowRoot;
1073
+ mkdirSync(root, { recursive: true });
1074
+ let id = randomUUID().replace(/-/g, "").slice(0, 8);
1075
+ while (existsSync(join(root, id))) id = randomUUID().replace(/-/g, "").slice(0, 8);
1076
+ const directory = join(root, id);
1077
+ mkdirSync(directory, { recursive: true });
1078
+ const session: FlowSession = {
1079
+ id,
1080
+ baseRevision: null,
1081
+ worktree: this.#worktreePath(id),
1082
+ branch: `flow/${id}`,
1083
+ status: "idle",
1084
+ goal: "",
1085
+ decisions: "",
1086
+ steering: [],
1087
+ accepted: [],
1088
+ widgets: [],
1089
+ files: [],
1090
+ checkpoints: [],
1091
+ verification: null,
1092
+ blockers: [],
1093
+ pr: null,
1094
+ prError: null,
1095
+ leftover: null,
1096
+ question: null,
1097
+ pushedTip: null,
1098
+ prFailedTip: null,
1099
+ lastDiffHash: null,
1100
+ createdAt: nowIso(this.#now),
1101
+ updatedAt: nowIso(this.#now),
1102
+ };
1103
+ this.#session = session;
1104
+ this.#persistedGoal = null;
1105
+ this.#persist();
1106
+ this.#persistArtifacts();
1107
+ }
1108
+
1109
+ async #spawnGoalCreation(creation: {
1110
+ agent: AcpConnection | undefined;
1111
+ activity: string[];
1112
+ question: FlowQuestion | null;
1113
+ }): Promise<void> {
1114
+ const directory = this.sessionDir;
1115
+ if (directory === undefined) return;
1116
+ // Drop the previous interview's goal first: only a file this turn writes
1117
+ // may be accepted, never a draft an interrupted peer or an earlier
1118
+ // interview left behind.
1119
+ const goalPath = join(directory, "goal.md");
1120
+ try {
1121
+ unlinkSync(goalPath);
1122
+ } catch {
1123
+ // No goal file exists yet.
1124
+ }
1125
+ this.#persistedGoal = null;
1126
+ try {
1127
+ const agent = new AcpConnection({
1128
+ command: this.#agentCommand,
1129
+ cwd: this.repoDir,
1130
+ onActivity: (activity: AcpActivity) => {
1131
+ if (this.#goalCreation !== creation) return;
1132
+ const append = activity.append && creation.activity.length > 0;
1133
+ if (append) creation.activity[creation.activity.length - 1] += activity.text;
1134
+ else creation.activity.push(activity.text);
1135
+ this.#broadcast({ type: "flowActivity", text: activity.text, append });
1136
+ },
1137
+ onQuestion: (question: AcpQuestion) => {
1138
+ if (this.#goalCreation !== creation) return;
1139
+ creation.question = {
1140
+ id: question.id,
1141
+ message: question.message,
1142
+ options: question.options,
1143
+ };
1144
+ this.#journal?.write("question", question.message, "flow:goal");
1145
+ this.#broadcast({
1146
+ type: "flowQuestion",
1147
+ target: "goal",
1148
+ id: question.id,
1149
+ message: question.message,
1150
+ options: question.options,
1151
+ });
1152
+ },
1153
+ onStderr: (text) => this.#journal?.write("acp-stderr", text),
1154
+ });
1155
+ creation.agent = agent;
1156
+ // A timeout is its own outcome: an interrupted interview is never an
1157
+ // accepted goal, and its peer is closed before anything is read.
1158
+ const turn = agent
1159
+ .run(`/flow-goal ${directory}`)
1160
+ .then(
1161
+ () => ({ ok: true as const }),
1162
+ (error: unknown) => ({ ok: false as const, error }),
1163
+ );
1164
+ const outcome = await Promise.race([
1165
+ turn,
1166
+ timeout(this.#goalTimeoutMs).then(() => ({ timedOut: true as const })),
1167
+ ]);
1168
+ if (this.#goalCreation !== creation) return;
1169
+ if ("timedOut" in outcome) {
1170
+ this.#error(`The Flow goal interview timed out after ${this.#goalTimeoutMs / 1000} s.`);
1171
+ this.#finishGoalCreation(false);
1172
+ return;
1173
+ }
1174
+ if (!outcome.ok) throw outcome.error;
1175
+ this.#finishGoalCreation(true);
1176
+ } catch (error) {
1177
+ if (this.#goalCreation !== creation) return;
1178
+ this.#error(error instanceof Error ? error.message : String(error));
1179
+ this.#finishGoalCreation(false);
1180
+ }
1181
+ }
1182
+
1183
+ #finishGoalCreation(readFile: boolean): void {
1184
+ const creation = this.#goalCreation;
1185
+ const session = this.#session;
1186
+ if (creation === undefined || session === undefined) return;
1187
+ this.#goalCreation = undefined;
1188
+ creation.question = null;
1189
+ creation.agent?.close();
1190
+
1191
+ const goalPath = this.#path("goal.md");
1192
+ const text = readFile ? readText(goalPath) : undefined;
1193
+ if (text === undefined || text.trim().length === 0) {
1194
+ if (readFile) this.#error("The Flow goal interview wrote no goal.");
1195
+ // A failed or interrupted interview must not leave its truncated draft
1196
+ // where the session's accepted goal belongs.
1197
+ this.#persistArtifacts();
1198
+ this.#broadcast({ type: "flowGoalStopped", completed: false });
1199
+ this.#publish();
1200
+ return;
1201
+ }
1202
+ session.goal = text;
1203
+ this.#persist();
1204
+ this.#persistArtifacts();
1205
+ this.#broadcast({ type: "flowGoalStopped", completed: true, text });
1206
+ this.#publish();
1207
+ }
1208
+
1209
+ saveGoal(text: unknown): void {
1210
+ if (typeof text !== "string") {
1211
+ this.#error("saveFlowGoal needs the goal text.");
1212
+ return;
1213
+ }
1214
+ if (this.#implementation !== undefined) {
1215
+ this.#error("The Flow implementation is running. Stop it before editing the goal.");
1216
+ return;
1217
+ }
1218
+ const session = this.#session;
1219
+ if (session === undefined) {
1220
+ this.#error("No Flow session exists.");
1221
+ return;
1222
+ }
1223
+ session.goal = text;
1224
+ this.#persist();
1225
+ this.#persistArtifacts();
1226
+ this.#publish();
1227
+ }
1228
+
1229
+ async startImplementation(): Promise<void> {
1230
+ const goalWork = this.#goalWorkLabel();
1231
+ if (goalWork !== undefined) {
1232
+ this.#error(`Goal Mode is active (${goalWork}). Stop it before starting Flow work.`);
1233
+ return;
1234
+ }
1235
+ const session = this.#session;
1236
+ if (session === undefined) {
1237
+ this.#error("Create a Flow goal before starting the implementation.");
1238
+ return;
1239
+ }
1240
+ if (session.status === "merged") {
1241
+ this.#error("This Flow session is merged. Create a new Flow goal to start again.");
1242
+ return;
1243
+ }
1244
+ if (this.#goalCreation !== undefined) {
1245
+ this.#error("The Flow goal interview is active.");
1246
+ return;
1247
+ }
1248
+ if (session.goal.trim().length === 0) {
1249
+ this.#error("The Flow goal is empty.");
1250
+ return;
1251
+ }
1252
+ if (this.#implementation !== undefined) {
1253
+ this.#error("The Flow implementation is already running.");
1254
+ return;
1255
+ }
1256
+
1257
+ const base = await this.#resolveBase();
1258
+ if (base === undefined) {
1259
+ this.#error("Could not start the Flow implementation: neither origin/main nor main exists.");
1260
+ return;
1261
+ }
1262
+ if (session.baseRevision === null) session.baseRevision = base;
1263
+
1264
+ const created = await this.#ensureWorktree(session, base);
1265
+ if (!created) return;
1266
+
1267
+ const activity: string[] = [];
1268
+ const implementation = { agent: undefined as AcpConnection | undefined, activity, finished: false };
1269
+ this.#implementation = implementation;
1270
+ this.#quiet = false;
1271
+ session.status = "running";
1272
+ this.#restartNotice = null;
1273
+ this.#persist();
1274
+ this.#broadcast({
1275
+ type: "flowImplementationStarted",
1276
+ sessionId: session.id,
1277
+ worktree: session.worktree,
1278
+ branch: session.branch,
1279
+ baseRevision: session.baseRevision,
1280
+ });
1281
+ this.#publish();
1282
+ this.#startWatchdog();
1283
+ void this.#tick();
1284
+
1285
+ const directory = this.sessionDir;
1286
+ if (directory === undefined) return;
1287
+ try {
1288
+ const agent = new AcpConnection({
1289
+ command: this.#agentCommand,
1290
+ cwd: session.worktree,
1291
+ onActivity: (update: AcpActivity) => {
1292
+ if (this.#implementation !== implementation) return;
1293
+ const append = update.append && activity.length > 0;
1294
+ if (append) activity[activity.length - 1] += update.text;
1295
+ else activity.push(update.text);
1296
+ this.#broadcast({ type: "flowActivity", text: update.text, append });
1297
+ if (update.text.includes(BLOCKER_MARKER)) {
1298
+ for (const line of activity) {
1299
+ const marker = line.indexOf(BLOCKER_MARKER);
1300
+ if (marker >= 0) this.#addBlocker(line.slice(marker + BLOCKER_MARKER.length).trim());
1301
+ }
1302
+ }
1303
+ },
1304
+ onQuestion: (question: AcpQuestion) => {
1305
+ if (this.#implementation !== implementation) return;
1306
+ session.question = {
1307
+ id: question.id,
1308
+ message: question.message,
1309
+ options: question.options,
1310
+ };
1311
+ this.#persist();
1312
+ this.#broadcast({
1313
+ type: "flowQuestion",
1314
+ target: "implementation",
1315
+ id: question.id,
1316
+ message: question.message,
1317
+ options: question.options,
1318
+ });
1319
+ },
1320
+ onStderr: (text) => this.#journal?.write("acp-stderr", text),
1321
+ });
1322
+ implementation.agent = agent;
1323
+ await agent.run(`/flow-implement ${directory}`);
1324
+ if (this.#implementation !== implementation) return;
1325
+ implementation.finished = true;
1326
+ this.#finishImplementation();
1327
+ } catch (error) {
1328
+ if (this.#implementation !== implementation) return;
1329
+ this.#error(error instanceof Error ? error.message : String(error));
1330
+ this.#finishImplementation();
1331
+ }
1332
+ }
1333
+
1334
+ #finishImplementation(): void {
1335
+ const implementation = this.#implementation;
1336
+ const session = this.#session;
1337
+ if (implementation === undefined) return;
1338
+ this.#implementation = undefined;
1339
+ implementation.agent?.close();
1340
+ implementation.agent = undefined;
1341
+ if (session !== undefined && session.status === "running") {
1342
+ session.status = "stopped";
1343
+ session.question = null;
1344
+ this.#persist();
1345
+ }
1346
+ this.#broadcast({ type: "flowStopped" });
1347
+ this.#publish();
1348
+ // One last look at what the turn left behind, then the watcher idles.
1349
+ void this.#tick();
1350
+ }
1351
+
1352
+ stopImplementation(): void {
1353
+ const implementation = this.#implementation;
1354
+ if (implementation === undefined) {
1355
+ this.#error("The Flow implementation is not running.");
1356
+ return;
1357
+ }
1358
+ this.#finishImplementation();
1359
+ }
1360
+
1361
+ // ------------------------------------------------------------- worktree
1362
+
1363
+ #git(cwd: string, args: string[]): Promise<CommandResult> {
1364
+ return this.#exec("git", args, {
1365
+ cwd,
1366
+ env: { ...(process.env as Record<string, string>), ...GIT_ENV },
1367
+ }).then(
1368
+ (result) => result,
1369
+ (error) => ({
1370
+ stdout: "",
1371
+ stderr: error instanceof Error ? error.message : String(error),
1372
+ code: -1,
1373
+ }),
1374
+ );
1375
+ }
1376
+
1377
+ async #resolveBase(): Promise<string | undefined> {
1378
+ await this.#git(this.repoDir, ["fetch", "origin", "main"]);
1379
+ for (const ref of ["origin/main", "main"]) {
1380
+ const result = await this.#git(this.repoDir, ["rev-parse", "--verify", `${ref}^{commit}`]);
1381
+ const revision = result.stdout.trim();
1382
+ if (result.code === 0 && revision.length > 0) return revision;
1383
+ }
1384
+ return undefined;
1385
+ }
1386
+
1387
+ async #ensureWorktree(session: FlowSession, base: string): Promise<boolean> {
1388
+ if (existsSync(session.worktree)) {
1389
+ const inside = await this.#git(session.worktree, ["rev-parse", "--show-toplevel"]);
1390
+ if (inside.code === 0) return true;
1391
+ }
1392
+ mkdirSync(join(this.repoDir, ".worktree"), { recursive: true });
1393
+ const branch = await this.#git(this.repoDir, [
1394
+ "rev-parse",
1395
+ "--verify",
1396
+ `refs/heads/${session.branch}`,
1397
+ ]);
1398
+ const added =
1399
+ branch.code === 0
1400
+ ? await this.#git(this.repoDir, ["worktree", "add", session.worktree, session.branch])
1401
+ : await this.#git(this.repoDir, [
1402
+ "worktree",
1403
+ "add",
1404
+ "-b",
1405
+ session.branch,
1406
+ session.worktree,
1407
+ base,
1408
+ ]);
1409
+ if (added.code !== 0) {
1410
+ this.#error(
1411
+ `Could not create the Flow worktree: ${added.stderr.trim() || `exit code ${added.code}`}`,
1412
+ );
1413
+ return false;
1414
+ }
1415
+ return true;
1416
+ }
1417
+
1418
+ async #isClean(worktree: string): Promise<boolean> {
1419
+ const result = await this.#git(worktree, ["status", "--porcelain"]);
1420
+ if (result.code !== 0) return false;
1421
+ return result.stdout.trim().length === 0;
1422
+ }
1423
+
1424
+ // ------------------------------------------------------------ watcher
1425
+
1426
+ #startWatchdog(): void {
1427
+ if (this.#timer !== undefined || this.#disposed) return;
1428
+ this.#timer = setInterval(() => {
1429
+ void this.#tick();
1430
+ }, this.#watchMs);
1431
+ }
1432
+
1433
+ #stopWatchdog(): void {
1434
+ if (this.#timer === undefined) return;
1435
+ clearInterval(this.#timer);
1436
+ this.#timer = undefined;
1437
+ }
1438
+
1439
+ async #tick(): Promise<void> {
1440
+ if (this.#disposed) return;
1441
+ if (this.#ticking) {
1442
+ this.#tickQueued = true;
1443
+ return;
1444
+ }
1445
+ this.#ticking = true;
1446
+ try {
1447
+ const session = this.#session;
1448
+ if (session === undefined || session.status === "merged") {
1449
+ this.#stopWatchdog();
1450
+ return;
1451
+ }
1452
+ if (!existsSync(session.worktree)) return;
1453
+
1454
+ await this.#syncCheckpoints(session);
1455
+ const files = await this.#computeDiff(session);
1456
+ if (files === undefined) return;
1457
+ const hash = diffHash(files);
1458
+ const changed = hash !== session.lastDiffHash;
1459
+ session.files = files;
1460
+ if (changed && files.length === 0) {
1461
+ session.widgets = [];
1462
+ session.accepted = [];
1463
+ this.#broadcast({ type: "flowWidgets", widgets: [] });
1464
+ }
1465
+ if (changed) {
1466
+ session.lastDiffHash = hash;
1467
+ this.#persist();
1468
+ this.#broadcast({ type: "flowFiles", files });
1469
+ }
1470
+ this.#persistArtifacts();
1471
+ if (changed && session.files.length > 0) {
1472
+ if (this.#quiet) {
1473
+ // A restored session recomputes its review list without spawning a
1474
+ // peer; the analyst runs again only after the implementation starts.
1475
+ session.widgets = this.#reconcile([], session.files, session);
1476
+ session.accepted = session.accepted.filter((signature) =>
1477
+ session.widgets.some((widget) => widget.id === signature),
1478
+ );
1479
+ this.#persist();
1480
+ this.#persistArtifacts();
1481
+ this.#broadcast({ type: "flowWidgets", widgets: session.widgets });
1482
+ } else {
1483
+ this.#scheduleAnalysis();
1484
+ }
1485
+ }
1486
+ this.#syncSteeringAcks(session);
1487
+ this.#syncVerification(session);
1488
+ this.#syncBlockers(session);
1489
+ if (changed) this.#publish();
1490
+ } finally {
1491
+ this.#ticking = false;
1492
+ if (this.#tickQueued) {
1493
+ this.#tickQueued = false;
1494
+ void this.#tick();
1495
+ }
1496
+ }
1497
+ }
1498
+
1499
+ async #computeDiff(session: FlowSession): Promise<FlowFile[] | undefined> {
1500
+ if (session.baseRevision === null) return undefined;
1501
+ const base = session.baseRevision;
1502
+ const tracked = await this.#git(session.worktree, ["diff", "--no-color", "--no-ext-diff", base, "--"]);
1503
+ if (tracked.code !== 0) return undefined;
1504
+ const status = await this.#git(session.worktree, [
1505
+ "status",
1506
+ "--porcelain=v1",
1507
+ "-z",
1508
+ "--untracked-files=all",
1509
+ ]);
1510
+ if (status.code !== 0) return undefined;
1511
+
1512
+ const files = new Map<string, FlowFile>();
1513
+ for (const file of diffFiles(tracked.stdout)) files.set(file.path, file);
1514
+
1515
+ for (const entry of parseStatusZ(status.stdout)) {
1516
+ if (entry.status === "untracked") {
1517
+ if (files.has(entry.path)) continue;
1518
+ let content = "";
1519
+ try {
1520
+ content = readFileSync(join(session.worktree, entry.path), "utf8");
1521
+ } catch {
1522
+ continue;
1523
+ }
1524
+ const diff = untrackedDiff(entry.path, content);
1525
+ const counts = countDiffLines(diff);
1526
+ files.set(entry.path, {
1527
+ path: entry.path,
1528
+ status: "added",
1529
+ insertions: counts.insertions,
1530
+ deletions: counts.deletions,
1531
+ diff,
1532
+ });
1533
+ continue;
1534
+ }
1535
+ const existing = files.get(entry.path);
1536
+ if (existing === undefined) continue;
1537
+ if (entry.status === "added" || entry.status === "deleted" || entry.status === "renamed") {
1538
+ existing.status = entry.status;
1539
+ }
1540
+ }
1541
+
1542
+ return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
1543
+ }
1544
+
1545
+ #scheduleAnalysis(): void {
1546
+ if (this.#analystPending) return;
1547
+ if (this.#analyst !== undefined) {
1548
+ this.#analystPending = true;
1549
+ return;
1550
+ }
1551
+ void this.#runAnalyst();
1552
+ }
1553
+
1554
+ async #runAnalyst(): Promise<void> {
1555
+ const session = this.#session;
1556
+ const directory = this.sessionDir;
1557
+ if (session === undefined || directory === undefined) return;
1558
+ const files = session.files;
1559
+ if (files.length === 0) return;
1560
+
1561
+ const request = {
1562
+ goal: session.goal,
1563
+ decisions: session.decisions,
1564
+ files: files.map((file) => ({
1565
+ path: file.path,
1566
+ status: file.status,
1567
+ insertions: file.insertions,
1568
+ deletions: file.deletions,
1569
+ diff: file.diff,
1570
+ })),
1571
+ previous: session.widgets,
1572
+ };
1573
+ writeJson(this.#path("request.json"), request);
1574
+ // A stale result must never be mistaken for this run's answer.
1575
+ try {
1576
+ writeFileSync(this.#path("widgets.json"), "");
1577
+ } catch {
1578
+ // The directory is gone; the run below reports it.
1579
+ }
1580
+
1581
+ const context = [
1582
+ "Session goal:",
1583
+ session.goal,
1584
+ "",
1585
+ "Decision board:",
1586
+ session.decisions,
1587
+ "",
1588
+ "Previous widget list:",
1589
+ JSON.stringify(session.widgets),
1590
+ "",
1591
+ "Current diff:",
1592
+ ...files.map((file) =>
1593
+ [
1594
+ `--- ${file.path} (${file.status}, +${file.insertions} -${file.deletions}) ---`,
1595
+ file.diff,
1596
+ ].join("\n"),
1597
+ ),
1598
+ "",
1599
+ "The same data is in request.json in the session directory.",
1600
+ "Write the complete widget list to widgets.json in the session directory.",
1601
+ ].join("\n");
1602
+
1603
+ const started = Date.now();
1604
+ const agent = new AcpConnection({
1605
+ command: this.#agentCommand,
1606
+ cwd: this.repoDir,
1607
+ onActivity: () => {},
1608
+ onQuestion: (question) => {
1609
+ agent.answer(question.id, question.options[0]?.label ?? "Continue");
1610
+ },
1611
+ onStderr: (text) => this.#journal?.write("acp-stderr", text),
1612
+ });
1613
+ this.#analyst = agent;
1614
+ try {
1615
+ await Promise.race([
1616
+ agent.run(`/flow-change ${directory}\n\n${context}`),
1617
+ timeout(this.#analystTimeoutMs),
1618
+ ]);
1619
+ } catch (error) {
1620
+ this.#journal?.write(
1621
+ "server-error",
1622
+ `flow analyst failed: ${error instanceof Error ? error.message : String(error)}`,
1623
+ );
1624
+ } finally {
1625
+ agent.close();
1626
+ this.#analyst = undefined;
1627
+ }
1628
+
1629
+ const elapsed = Date.now() - started;
1630
+ const timedOut = elapsed >= this.#analystTimeoutMs - 5;
1631
+ const answer = readJson<{ widgets?: unknown }>(this.#path("widgets.json"));
1632
+ const raw =
1633
+ answer !== null && typeof answer === "object" && Array.isArray(answer.widgets)
1634
+ ? (answer.widgets as RawWidget[])
1635
+ : undefined;
1636
+ const validated = raw === undefined ? undefined : this.#validateWidgets(raw, session.files);
1637
+ if (validated === undefined) {
1638
+ this.#error(
1639
+ timedOut
1640
+ ? "The Flow analyst timed out; every changed file is shown as a structure change."
1641
+ : "The Flow analyst returned no valid widget list; every changed file is shown as a structure change.",
1642
+ );
1643
+ session.widgets = this.#reconcile([], session.files, session);
1644
+ session.accepted = session.accepted.filter((signature) =>
1645
+ session.widgets.some((widget) => widget.id === signature),
1646
+ );
1647
+ writeJson(this.#path("widgets.json"), { widgets: this.#widgetsAsRaw(session.widgets) });
1648
+ this.#persist();
1649
+ this.#persistArtifacts();
1650
+ this.#broadcast({ type: "flowWidgets", widgets: session.widgets });
1651
+ this.#publish();
1652
+ } else {
1653
+ session.widgets = this.#reconcile(validated, session.files, session);
1654
+ session.accepted = session.accepted.filter((signature) =>
1655
+ session.widgets.some((widget) => widget.id === signature),
1656
+ );
1657
+ this.#persist();
1658
+ this.#persistArtifacts();
1659
+ this.#broadcast({ type: "flowWidgets", widgets: session.widgets });
1660
+ this.#publish();
1661
+ }
1662
+
1663
+ if (this.#analystPending) {
1664
+ this.#analystPending = false;
1665
+ void this.#runAnalyst();
1666
+ }
1667
+ }
1668
+
1669
+ #validateWidgets(raw: RawWidget[], files: FlowFile[]): RawWidget[] | undefined {
1670
+ const paths = new Set(files.map((file) => file.path));
1671
+ const widgets: RawWidget[] = [];
1672
+ for (const entry of raw) {
1673
+ if (typeof entry !== "object" || entry === null) return undefined;
1674
+ const category = entry.category;
1675
+ const concept = entry.concept;
1676
+ const summary = entry.summary;
1677
+ const rationale = entry.rationale;
1678
+ const listed = entry.files;
1679
+ if (typeof category !== "string" || !CATEGORY_SET.has(category)) return undefined;
1680
+ if (typeof concept !== "string" || concept.trim().length === 0) return undefined;
1681
+ if (typeof summary !== "string" || summary.trim().length === 0) return undefined;
1682
+ if (typeof rationale !== "string" || rationale.trim().length === 0) return undefined;
1683
+ if (!Array.isArray(listed) || listed.length === 0) return undefined;
1684
+ if (!listed.every((path) => typeof path === "string")) return undefined;
1685
+ if (!listed.every((path) => paths.has(path))) return undefined;
1686
+ widgets.push({
1687
+ category,
1688
+ concept,
1689
+ summary,
1690
+ rationale,
1691
+ files: [...new Set(listed as string[])],
1692
+ });
1693
+ }
1694
+ return widgets;
1695
+ }
1696
+
1697
+ #reconcile(raw: RawWidget[], files: FlowFile[], session: FlowSession): FlowWidget[] {
1698
+ const diffs = new Map(files.map((file) => [file.path, file.diff]));
1699
+ const byPath = new Map(files.map((file) => [file.path, file]));
1700
+ const widgets: FlowWidget[] = [];
1701
+ const covered = new Set<string>();
1702
+ const accepted = new Set(session.accepted);
1703
+
1704
+ for (const entry of raw) {
1705
+ if (typeof entry.category !== "string" || !CATEGORY_SET.has(entry.category)) continue;
1706
+ const paths = entry.files.filter((path) => byPath.has(path));
1707
+ if (paths.length === 0) continue;
1708
+ const signature = widgetSignature(entry.category, paths, diffs);
1709
+ for (const path of paths) covered.add(path);
1710
+ if (widgets.some((widget) => widget.id === signature)) continue;
1711
+ widgets.push({
1712
+ id: signature,
1713
+ category: entry.category as FlowCategory,
1714
+ concept: entry.concept,
1715
+ summary: entry.summary,
1716
+ rationale: entry.rationale,
1717
+ files: [...new Set(paths)].sort(),
1718
+ accepted: accepted.has(signature),
1719
+ });
1720
+ }
1721
+
1722
+ for (const file of files) {
1723
+ if (covered.has(file.path)) continue;
1724
+ const signature = widgetSignature("structure", [file.path], diffs);
1725
+ if (widgets.some((widget) => widget.id === signature)) continue;
1726
+ widgets.push({
1727
+ id: signature,
1728
+ category: "structure",
1729
+ concept: file.path,
1730
+ summary: `Changed file ${file.path}`,
1731
+ rationale: "No change widget named this file, so it is shown as a structure change.",
1732
+ files: [file.path],
1733
+ accepted: accepted.has(signature),
1734
+ });
1735
+ }
1736
+
1737
+ return widgets;
1738
+ }
1739
+
1740
+ // -------------------------------------------------------- checkpoints
1741
+
1742
+ async #syncCheckpoints(session: FlowSession): Promise<void> {
1743
+ if (session.baseRevision === null) return;
1744
+ const log = await this.#git(session.worktree, [
1745
+ "log",
1746
+ "--reverse",
1747
+ "--format=%H%x1f%s%x1f%cI",
1748
+ `${session.baseRevision}..HEAD`,
1749
+ ]);
1750
+ if (log.code !== 0) return;
1751
+ const commits = log.stdout
1752
+ .split("\n")
1753
+ .map((line) => line.trim())
1754
+ .filter((line) => line.length > 0)
1755
+ .map((line) => {
1756
+ const [hash, subject, timestamp] = line.split("\x1f");
1757
+ return { hash: hash ?? "", subject: subject ?? "", timestamp: timestamp ?? "" };
1758
+ })
1759
+ .filter((entry) => entry.hash.length > 0);
1760
+ if (commits.length === 0) return;
1761
+
1762
+ const recorded = readJsonLines<Partial<FlowCheckpoint>>(this.#path("checkpoints.jsonl"));
1763
+ let added = false;
1764
+ for (const commit of commits) {
1765
+ const short = commit.hash.slice(0, 7);
1766
+ if (session.checkpoints.some((entry) => entry.commit === short)) continue;
1767
+ const match = recorded.find(
1768
+ (entry) => typeof entry.commit === "string" && commit.hash.startsWith(entry.commit),
1769
+ );
1770
+ if (match !== undefined) {
1771
+ const entry: FlowCheckpoint = {
1772
+ id: typeof match.id === "string" ? match.id : `checkpoint-${short}`,
1773
+ timestamp: typeof match.timestamp === "string" ? match.timestamp : commit.timestamp,
1774
+ summary: typeof match.summary === "string" ? match.summary : commit.subject,
1775
+ files: typeof match.files === "number" ? match.files : 0,
1776
+ commit: short,
1777
+ verification: verificationStatus(match.verification),
1778
+ };
1779
+ session.checkpoints.push(entry);
1780
+ } else {
1781
+ const changed = await this.#git(session.worktree, [
1782
+ "show",
1783
+ "--name-only",
1784
+ "--format=",
1785
+ commit.hash,
1786
+ ]);
1787
+ const fileCount = changed.stdout.split("\n").filter((line) => line.trim().length > 0).length;
1788
+ session.checkpoints.push({
1789
+ id: `checkpoint-${short}`,
1790
+ timestamp: commit.timestamp,
1791
+ summary: commit.subject,
1792
+ files: fileCount,
1793
+ commit: short,
1794
+ verification: session.verification?.status ?? STALE_VERIFICATION,
1795
+ });
1796
+ }
1797
+ this.#seenCommits.add(short);
1798
+ added = true;
1799
+ }
1800
+
1801
+ if (added) {
1802
+ this.#persist();
1803
+ this.#broadcast({ type: "flowCheckpoints", checkpoints: session.checkpoints });
1804
+ }
1805
+
1806
+ const tip = commits[commits.length - 1]?.hash ?? null;
1807
+ if (tip !== null && tip !== session.pushedTip && session.prFailedTip !== tip) {
1808
+ await this.#syncPullRequest(session, tip);
1809
+ }
1810
+ }
1811
+
1812
+ // --------------------------------------------------------------- forge
1813
+
1814
+ async #syncPullRequest(session: FlowSession, tip: string): Promise<void> {
1815
+ if (this.#prBusy) {
1816
+ // A checkpoint that lands during a push is not dropped: the running sync
1817
+ // picks up the newest tip when it finishes.
1818
+ this.#prPendingTip = tip;
1819
+ return;
1820
+ }
1821
+ this.#prBusy = true;
1822
+ try {
1823
+ const pushed = await this.#git(session.worktree, ["push", "origin", session.branch]);
1824
+ if (pushed.code !== 0) {
1825
+ session.prFailedTip = tip;
1826
+ session.prError = pushed.stderr.trim() || `exit code ${pushed.code}`;
1827
+ this.#persist();
1828
+ this.#error(`Could not push ${session.branch}: ${session.prError}`);
1829
+ this.#publish();
1830
+ return;
1831
+ }
1832
+
1833
+ const target = await this.#target();
1834
+ if (target === undefined) {
1835
+ session.prFailedTip = tip;
1836
+ session.prError = "The origin remote does not name a supported repository.";
1837
+ this.#persist();
1838
+ this.#error(session.prError);
1839
+ this.#publish();
1840
+ return;
1841
+ }
1842
+
1843
+ const input = {
1844
+ cwd: this.repoDir,
1845
+ target,
1846
+ head: session.branch,
1847
+ base: "main",
1848
+ title: prTitle(session.goal),
1849
+ body: prBody(session.goal, session.decisions),
1850
+ };
1851
+
1852
+ if (session.pr === null || session.pr.state === "error") {
1853
+ const created = await this.#forge.createDraft(input);
1854
+ session.pr = {
1855
+ forge: target.forge,
1856
+ number: created.number,
1857
+ url: created.url,
1858
+ state: "draft",
1859
+ };
1860
+ this.#broadcast({ type: "flowPr", pr: session.pr });
1861
+ } else {
1862
+ await this.#forge.update({ ...input, number: session.pr.number });
1863
+ if (session.pr.state === "draft") {
1864
+ session.pr = { ...session.pr, url: session.pr.url };
1865
+ }
1866
+ }
1867
+ session.prError = null;
1868
+ session.prFailedTip = null;
1869
+ session.pushedTip = tip;
1870
+ this.#persist();
1871
+ this.#broadcast({ type: "flowPr", pr: session.pr });
1872
+ this.#publish();
1873
+ } catch (error) {
1874
+ session.prFailedTip = tip;
1875
+ session.prError = error instanceof Error ? error.message : String(error);
1876
+ this.#persist();
1877
+ this.#error(session.prError);
1878
+ this.#publish();
1879
+ } finally {
1880
+ this.#prBusy = false;
1881
+ const pending = this.#prPendingTip;
1882
+ this.#prPendingTip = null;
1883
+ if (pending !== null && pending !== session.pushedTip) {
1884
+ void this.#syncPullRequest(session, pending);
1885
+ }
1886
+ }
1887
+ }
1888
+
1889
+ async #target(): Promise<ForgeTarget | undefined> {
1890
+ if (this.#forgeTarget !== undefined) return this.#forgeTarget;
1891
+ const remote = await this.#git(this.repoDir, ["remote", "get-url", "origin"]);
1892
+ if (remote.code !== 0) return undefined;
1893
+ return classifyOrigin(remote.stdout);
1894
+ }
1895
+
1896
+ async retryPr(): Promise<void> {
1897
+ const session = this.#session;
1898
+ if (session === undefined) {
1899
+ this.#error("No Flow session exists.");
1900
+ return;
1901
+ }
1902
+ if (session.prFailedTip !== null) {
1903
+ const tip = session.prFailedTip;
1904
+ session.prFailedTip = null;
1905
+ await this.#syncPullRequest(session, tip);
1906
+ return;
1907
+ }
1908
+ this.#error("There is no failed pull request to retry.");
1909
+ }
1910
+
1911
+ async markPrReady(): Promise<void> {
1912
+ const session = this.#session;
1913
+ if (session === undefined || session.pr === null) {
1914
+ this.#error("No Flow pull request exists.");
1915
+ return;
1916
+ }
1917
+ if (this.#implementation !== undefined) {
1918
+ this.#error("Stop the Flow implementation before marking the pull request ready.");
1919
+ return;
1920
+ }
1921
+ if (session.pr.state !== "draft") {
1922
+ this.#error("Only a draft pull request can be marked ready.");
1923
+ return;
1924
+ }
1925
+ if (!(await this.#isClean(session.worktree))) {
1926
+ this.#error("Commit or discard the Flow worktree changes before marking the pull request ready.");
1927
+ return;
1928
+ }
1929
+ const target = await this.#target();
1930
+ if (target === undefined) {
1931
+ this.#error("The origin remote does not name a supported repository.");
1932
+ return;
1933
+ }
1934
+ try {
1935
+ await this.#forge.markReady(this.repoDir, target, session.pr.number);
1936
+ const state = await this.#forge.view(this.repoDir, target, session.pr.number);
1937
+ session.pr = {
1938
+ ...session.pr,
1939
+ url: state.url,
1940
+ state: state.state === "merged" ? "merged" : state.draft ? "draft" : "live",
1941
+ };
1942
+ this.#persist();
1943
+ this.#broadcast({ type: "flowPr", pr: session.pr });
1944
+ this.#publish();
1945
+ } catch (error) {
1946
+ this.#error(error instanceof Error ? error.message : String(error));
1947
+ }
1948
+ }
1949
+
1950
+ async mergePr(): Promise<void> {
1951
+ const session = this.#session;
1952
+ if (session === undefined || session.pr === null) {
1953
+ this.#error("No Flow pull request exists.");
1954
+ return;
1955
+ }
1956
+ if (this.#implementation !== undefined) {
1957
+ this.#error("Stop the Flow implementation before merging the pull request.");
1958
+ return;
1959
+ }
1960
+ const goalWork = this.#goalWorkLabel();
1961
+ if (goalWork !== undefined) {
1962
+ this.#error(`Goal Mode is active (${goalWork}). Stop it before merging the Flow pull request.`);
1963
+ return;
1964
+ }
1965
+ if (session.pr.state !== "live") {
1966
+ this.#error("Only a live pull request can be merged.");
1967
+ return;
1968
+ }
1969
+ if (!(await this.#isClean(session.worktree))) {
1970
+ this.#error("Commit or discard the Flow worktree changes before merging.");
1971
+ return;
1972
+ }
1973
+ const target = await this.#target();
1974
+ if (target === undefined) {
1975
+ this.#error("The origin remote does not name a supported repository.");
1976
+ return;
1977
+ }
1978
+
1979
+ try {
1980
+ // The forge is the authority on the request's current state: a request
1981
+ // closed or reopened elsewhere is never merged on stale local state.
1982
+ const current = await this.#forge.view(this.repoDir, target, session.pr.number);
1983
+ if (current.state !== "open" || current.draft) {
1984
+ this.#error("The pull request is not an open, live pull request.");
1985
+ return;
1986
+ }
1987
+ await this.#forge.merge(this.repoDir, target, session.pr.number);
1988
+ const state = await this.#forge.view(this.repoDir, target, session.pr.number);
1989
+ if (state.state !== "merged") {
1990
+ throw new Error("The forge did not report the pull request as merged.");
1991
+ }
1992
+ session.pr = { ...session.pr, url: state.url, state: "merged" };
1993
+ this.#persist();
1994
+ this.#broadcast({ type: "flowPr", pr: session.pr });
1995
+ } catch (error) {
1996
+ this.#error(error instanceof Error ? error.message : String(error));
1997
+ return;
1998
+ }
1999
+
2000
+ const leftover = await this.#cleanupAfterMerge(session);
2001
+ session.status = "merged";
2002
+ session.leftover = leftover;
2003
+ this.#persist();
2004
+ this.#registerHistory();
2005
+ this.#stopWatchdog();
2006
+ if (leftover !== null) {
2007
+ this.#error(`The merge completed, but cleanup left ${leftover}.`);
2008
+ }
2009
+ this.#publish();
2010
+ }
2011
+
2012
+ /** Removes the merged work, reporting what it could not clean up. */
2013
+ async #cleanupAfterMerge(session: FlowSession): Promise<string | null> {
2014
+ const leftovers: string[] = [];
2015
+
2016
+ await this.#git(this.repoDir, ["fetch", "origin"]);
2017
+ const head = await this.#git(this.repoDir, ["symbolic-ref", "--short", "HEAD"]);
2018
+ if (head.code === 0 && head.stdout.trim() === "main") {
2019
+ const status = await this.#git(this.repoDir, ["status", "--porcelain"]);
2020
+ if (status.code === 0 && status.stdout.trim().length === 0) {
2021
+ const fastForward = await this.#git(this.repoDir, ["merge", "--ff-only", "origin/main"]);
2022
+ if (fastForward.code !== 0) {
2023
+ leftovers.push(`the host checkout could not fast-forward to origin/main`);
2024
+ }
2025
+ }
2026
+ }
2027
+
2028
+ const tip = await this.#git(session.worktree, ["rev-parse", "HEAD"]);
2029
+ const tipRevision = tip.code === 0 ? tip.stdout.trim() : "";
2030
+ const removed = await this.#git(this.repoDir, ["worktree", "remove", session.worktree]);
2031
+ if (removed.code !== 0) {
2032
+ leftovers.push(`the worktree at ${session.worktree} (${session.branch})`);
2033
+ } else {
2034
+ const ancestor =
2035
+ tipRevision.length === 0
2036
+ ? { code: 1 }
2037
+ : await this.#git(this.repoDir, [
2038
+ "merge-base",
2039
+ "--is-ancestor",
2040
+ tipRevision,
2041
+ "origin/main",
2042
+ ]);
2043
+ if (ancestor.code !== 0) {
2044
+ leftovers.push(`the branch ${session.branch} (its tip is not an ancestor of origin/main)`);
2045
+ } else {
2046
+ const local = await this.#git(this.repoDir, ["branch", "-d", session.branch]);
2047
+ if (local.code !== 0) leftovers.push(`the local branch ${session.branch}`);
2048
+ try {
2049
+ await this.#forge.deleteRemoteBranch(this.repoDir, session.branch);
2050
+ } catch {
2051
+ leftovers.push(`the remote branch ${session.branch}`);
2052
+ }
2053
+ }
2054
+ }
2055
+
2056
+ return leftovers.length === 0 ? null : leftovers.join("; ");
2057
+ }
2058
+
2059
+ // ------------------------------------------------- steering / decisions
2060
+
2061
+ sendSteering(text: unknown, source = "developer"): boolean {
2062
+ const session = this.#session;
2063
+ if (session === undefined) {
2064
+ this.#error("No Flow session exists.");
2065
+ return false;
2066
+ }
2067
+ if (typeof text !== "string" || text.trim().length === 0) {
2068
+ this.#error("A steering note needs non-empty text.");
2069
+ return false;
2070
+ }
2071
+ const note: FlowSteeringNote = {
2072
+ id: `steer-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
2073
+ timestamp: nowIso(this.#now),
2074
+ source,
2075
+ text,
2076
+ status: "new",
2077
+ };
2078
+ session.steering.push(note);
2079
+ try {
2080
+ this.#appendSteering(note);
2081
+ } catch (error) {
2082
+ this.#error(
2083
+ `Could not record the steering note: ${error instanceof Error ? error.message : String(error)}`,
2084
+ );
2085
+ }
2086
+ this.#persist();
2087
+ this.#broadcast({ type: "flowSteering", notes: session.steering });
2088
+ this.#publish();
2089
+ return true;
2090
+ }
2091
+
2092
+ #syncSteeringAcks(session: FlowSession): void {
2093
+ const directory = this.sessionDir;
2094
+ if (directory === undefined) return;
2095
+ const acks = new Set(
2096
+ readJsonLines<{ id?: unknown } | string>(join(directory, "steering-ack.jsonl")).map((entry) =>
2097
+ typeof entry === "string" ? entry : String(entry.id ?? ""),
2098
+ ),
2099
+ );
2100
+ let changed = false;
2101
+ session.steering = session.steering.map((note) => {
2102
+ if (note.status === "new" && acks.has(note.id)) {
2103
+ changed = true;
2104
+ return { ...note, status: "read" };
2105
+ }
2106
+ return note;
2107
+ });
2108
+ if (changed) {
2109
+ this.#persist();
2110
+ this.#broadcast({ type: "flowSteering", notes: session.steering });
2111
+ }
2112
+ }
2113
+
2114
+ saveDecisions(text: unknown): void {
2115
+ if (typeof text !== "string") {
2116
+ this.#error("saveFlowDecisions needs the decision board text.");
2117
+ return;
2118
+ }
2119
+ const session = this.#session;
2120
+ if (session === undefined) {
2121
+ this.#error("No Flow session exists.");
2122
+ return;
2123
+ }
2124
+ session.decisions = text;
2125
+ this.#persist();
2126
+ try {
2127
+ writeText(this.#path("decisions.md"), text);
2128
+ } catch (error) {
2129
+ this.#error(
2130
+ `Could not write the decision board: ${error instanceof Error ? error.message : String(error)}`,
2131
+ );
2132
+ }
2133
+ this.#broadcast({ type: "flowDecisions", text });
2134
+ this.#publish();
2135
+ }
2136
+
2137
+ /** Appends a timestamped, source-named section without touching developer text. */
2138
+ #appendDecisions(source: string, text: string): Promise<void> {
2139
+ this.#decisionsChain = this.#decisionsChain.then(async () => {
2140
+ const session = this.#session;
2141
+ if (session === undefined) return;
2142
+ const section = `\n\n## ${nowIso(this.#now)} — ${source}\n\n${text.replace(/\s+$/, "")}\n`;
2143
+ session.decisions = `${session.decisions.replace(/\s+$/, "")}${section}`;
2144
+ try {
2145
+ writeText(this.#path("decisions.md"), session.decisions);
2146
+ } catch (error) {
2147
+ this.#error(
2148
+ `Could not write the decision board: ${error instanceof Error ? error.message : String(error)}`,
2149
+ );
2150
+ }
2151
+ this.#persist();
2152
+ this.#broadcast({ type: "flowDecisions", text: session.decisions });
2153
+ this.#publish();
2154
+ });
2155
+ return this.#decisionsChain;
2156
+ }
2157
+
2158
+ // ------------------------------------------------------ diff / widgets
2159
+
2160
+ acceptWidget(id: unknown, accepted: unknown): void {
2161
+ const session = this.#session;
2162
+ if (session === undefined) {
2163
+ this.#error("No Flow session exists.");
2164
+ return;
2165
+ }
2166
+ if (typeof id !== "string") {
2167
+ this.#error("A widget acceptance needs the widget id.");
2168
+ return;
2169
+ }
2170
+ const widget = session.widgets.find((entry) => entry.id === id);
2171
+ if (widget === undefined) {
2172
+ this.#error("That Flow widget is no longer current.");
2173
+ return;
2174
+ }
2175
+ const shouldAccept = accepted !== false;
2176
+ session.accepted = shouldAccept
2177
+ ? [...new Set([...session.accepted, id])]
2178
+ : session.accepted.filter((signature) => signature !== id);
2179
+ session.widgets = session.widgets.map((entry) =>
2180
+ entry.id === id ? { ...entry, accepted: shouldAccept } : entry,
2181
+ );
2182
+ this.#persist();
2183
+ try {
2184
+ writeJson(this.#path("accepted.json"), { signatures: session.accepted });
2185
+ } catch {
2186
+ // The machine state above is the source of truth.
2187
+ }
2188
+ this.#broadcast({ type: "flowWidgets", widgets: session.widgets });
2189
+ this.#publish();
2190
+ }
2191
+
2192
+ refuseWidget(id: unknown, reason: unknown): void {
2193
+ const session = this.#session;
2194
+ if (session === undefined) {
2195
+ this.#error("No Flow session exists.");
2196
+ return;
2197
+ }
2198
+ if (typeof id !== "string" || !session.widgets.some((widget) => widget.id === id)) {
2199
+ this.#error("That Flow widget is no longer current.");
2200
+ return;
2201
+ }
2202
+ const detail = typeof reason === "string" ? reason.trim() : "";
2203
+ const text =
2204
+ detail.length > 0
2205
+ ? `Refuse widget ${id}: ${detail} Abandon this change and implement another solution.`
2206
+ : `Refuse widget ${id}: abandon this change and implement another solution.`;
2207
+ this.sendSteering(text, `widget:${id}`);
2208
+ }
2209
+
2210
+ // ------------------------------------------------------- conversation
2211
+
2212
+ openConversation(widgetId: unknown): void {
2213
+ const session = this.#session;
2214
+ if (session === undefined) {
2215
+ this.#error("No Flow session exists.");
2216
+ return;
2217
+ }
2218
+ if (typeof widgetId !== "string") {
2219
+ this.#error("A widget conversation needs the widget id.");
2220
+ return;
2221
+ }
2222
+ const widget = session.widgets.find((entry) => entry.id === widgetId);
2223
+ if (widget === undefined) {
2224
+ this.#error("That Flow widget is no longer current.");
2225
+ return;
2226
+ }
2227
+ this.closeConversation(true);
2228
+
2229
+ const conversation: Conversation = {
2230
+ widgetId,
2231
+ agent: undefined,
2232
+ messages: [],
2233
+ activity: [],
2234
+ question: null,
2235
+ busy: false,
2236
+ queue: [],
2237
+ };
2238
+ this.#conversation = conversation;
2239
+ this.#broadcast({ type: "flowConversationStarted", widgetId });
2240
+ this.#publish();
2241
+
2242
+ const directory = this.sessionDir;
2243
+ if (directory === undefined) return;
2244
+ const files = widget.files
2245
+ .map((path) => session.files.find((file) => file.path === path))
2246
+ .filter((file): file is FlowFile => file !== undefined);
2247
+ const context = [
2248
+ `Widget category: ${widget.category}`,
2249
+ `Widget concept: ${widget.concept}`,
2250
+ `Widget summary: ${widget.summary}`,
2251
+ `Widget rationale: ${widget.rationale}`,
2252
+ "",
2253
+ "Session goal:",
2254
+ session.goal,
2255
+ "",
2256
+ "Decision board:",
2257
+ session.decisions,
2258
+ "",
2259
+ "Files and diffs:",
2260
+ ...files.map((file) => [`--- ${file.path} (${file.status}) ---`, file.diff].join("\n")),
2261
+ "",
2262
+ "This conversation is read-only. Never edit code or the worktree.",
2263
+ ].join("\n");
2264
+
2265
+ try {
2266
+ const agent = new AcpConnection({
2267
+ command: this.#agentCommand,
2268
+ cwd: this.repoDir,
2269
+ onActivity: (activity: AcpActivity) => {
2270
+ if (this.#conversation !== conversation) return;
2271
+ const append = activity.append && conversation.activity.length > 0;
2272
+ if (append) conversation.activity[conversation.activity.length - 1] += activity.text;
2273
+ else conversation.activity.push(activity.text);
2274
+ this.#broadcast({
2275
+ type: "flowConversationActivity",
2276
+ text: activity.text,
2277
+ append,
2278
+ });
2279
+ },
2280
+ onQuestion: (question: AcpQuestion) => {
2281
+ if (this.#conversation !== conversation) return;
2282
+ conversation.question = {
2283
+ id: question.id,
2284
+ message: question.message,
2285
+ options: question.options,
2286
+ };
2287
+ this.#broadcast({
2288
+ type: "flowConversationQuestion",
2289
+ id: question.id,
2290
+ message: question.message,
2291
+ options: question.options,
2292
+ });
2293
+ this.#publish();
2294
+ },
2295
+ onStderr: (text) => this.#journal?.write("acp-stderr", text),
2296
+ });
2297
+ conversation.agent = agent;
2298
+ void this.#pumpConversation(conversation, `/flow-widget ${directory} ${widgetId}\n\n${context}`);
2299
+ } catch (error) {
2300
+ this.#error(error instanceof Error ? error.message : String(error));
2301
+ this.closeConversation();
2302
+ }
2303
+ }
2304
+
2305
+ /** One prompt at a time; a message sent while the peer answers waits its turn. */
2306
+ async #pumpConversation(conversation: Conversation, prompt: string): Promise<void> {
2307
+ conversation.busy = true;
2308
+ try {
2309
+ await conversation.agent?.run(prompt);
2310
+ } catch (error) {
2311
+ if (this.#conversation !== conversation) return;
2312
+ this.#error(error instanceof Error ? error.message : String(error));
2313
+ } finally {
2314
+ conversation.busy = false;
2315
+ const next = conversation.queue.shift();
2316
+ if (next !== undefined && this.#conversation === conversation) {
2317
+ void this.#pumpConversation(conversation, next);
2318
+ }
2319
+ }
2320
+ }
2321
+
2322
+ sendConversationMessage(text: unknown): void {
2323
+ const conversation = this.#conversation;
2324
+ if (conversation === undefined) {
2325
+ this.#error("No widget conversation is open.");
2326
+ return;
2327
+ }
2328
+ if (typeof text !== "string" || text.trim().length === 0) {
2329
+ this.#error("A conversation message needs non-empty text.");
2330
+ return;
2331
+ }
2332
+ conversation.messages.push({ role: "developer", text });
2333
+ this.#broadcast({ type: "flowConversationMessage", role: "developer", text });
2334
+ if (conversation.busy) {
2335
+ conversation.queue.push(text);
2336
+ } else if (conversation.agent !== undefined) {
2337
+ void this.#promptConversation(conversation, text);
2338
+ }
2339
+ this.#publish();
2340
+ }
2341
+
2342
+ async #promptConversation(conversation: Conversation, text: string): Promise<void> {
2343
+ if (conversation.agent === undefined) return;
2344
+ conversation.busy = true;
2345
+ try {
2346
+ await conversation.agent.prompt(text);
2347
+ } catch (error) {
2348
+ if (this.#conversation !== conversation) return;
2349
+ this.#error(error instanceof Error ? error.message : String(error));
2350
+ } finally {
2351
+ conversation.busy = false;
2352
+ const next = conversation.queue.shift();
2353
+ if (next !== undefined && this.#conversation === conversation) {
2354
+ void this.#promptConversation(conversation, next);
2355
+ }
2356
+ }
2357
+ }
2358
+
2359
+ closeConversation(silent = false): void {
2360
+ const conversation = this.#conversation;
2361
+ if (conversation === undefined) {
2362
+ if (!silent) this.#error("No widget conversation is open.");
2363
+ return;
2364
+ }
2365
+ this.#conversation = undefined;
2366
+ conversation.question = null;
2367
+ conversation.agent?.close();
2368
+ conversation.agent = undefined;
2369
+ this.#broadcast({ type: "flowConversationStopped", widgetId: conversation.widgetId });
2370
+ if (!silent) this.#publish();
2371
+ }
2372
+
2373
+ async sendConversationConclusion(text: unknown): Promise<void> {
2374
+ const conversation = this.#conversation;
2375
+ const session = this.#session;
2376
+ if (conversation === undefined || session === undefined) {
2377
+ this.#error("No widget conversation is open.");
2378
+ return;
2379
+ }
2380
+ if (typeof text !== "string" || text.trim().length === 0) {
2381
+ this.#error("A conclusion needs non-empty text.");
2382
+ return;
2383
+ }
2384
+ const widget = session.widgets.find((entry) => entry.id === conversation.widgetId);
2385
+ await this.#appendDecisions(
2386
+ `Widget conversation ${conversation.widgetId}`,
2387
+ text,
2388
+ );
2389
+ this.sendSteering(
2390
+ `Conclusion for widget ${conversation.widgetId}${widget === undefined ? "" : ` (${widget.concept})`}: ${text}`,
2391
+ `widget-chat:${conversation.widgetId}`,
2392
+ );
2393
+ }
2394
+
2395
+ // ----------------------------------------------------- verification
2396
+
2397
+ #syncVerification(session: FlowSession): void {
2398
+ const path = this.#path("verification.json");
2399
+ const stored = readJson<Record<string, unknown>>(path);
2400
+ if (stored === null || typeof stored !== "object") return;
2401
+ const status =
2402
+ stored.status === "passed" || stored.status === "failed" || stored.status === "not-run"
2403
+ ? stored.status
2404
+ : undefined;
2405
+ if (status === undefined) return;
2406
+ const verification: FlowVerification = {
2407
+ command: typeof stored.command === "string" ? stored.command : "",
2408
+ status,
2409
+ timestamp: typeof stored.timestamp === "string" ? stored.timestamp : nowIso(this.#now),
2410
+ output: typeof stored.output === "string" ? boundedTail(stored.output) : "",
2411
+ };
2412
+ if (
2413
+ session.verification !== null &&
2414
+ session.verification.command === verification.command &&
2415
+ session.verification.status === verification.status &&
2416
+ session.verification.timestamp === verification.timestamp
2417
+ ) {
2418
+ return;
2419
+ }
2420
+ session.verification = verification;
2421
+ this.#persist();
2422
+ this.#broadcast({ type: "flowVerification", verification });
2423
+ }
2424
+
2425
+ // ---------------------------------------------------------- blockers
2426
+
2427
+ #addBlocker(text: string): void {
2428
+ const session = this.#session;
2429
+ const trimmed = text.trim();
2430
+ if (session === undefined || trimmed.length === 0) return;
2431
+ if (session.blockers.some((blocker) => !blocker.dismissed && blocker.text === trimmed)) return;
2432
+ const blocker: FlowBlocker = {
2433
+ id: `blocker-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
2434
+ timestamp: nowIso(this.#now),
2435
+ text: trimmed,
2436
+ dismissed: false,
2437
+ };
2438
+ session.blockers.push(blocker);
2439
+ try {
2440
+ appendLine(this.#path("blockers.jsonl"), {
2441
+ id: blocker.id,
2442
+ timestamp: blocker.timestamp,
2443
+ text: blocker.text,
2444
+ });
2445
+ } catch {
2446
+ // The session state still carries it.
2447
+ }
2448
+ this.#persist();
2449
+ this.#broadcast({ type: "flowBlockers", blockers: session.blockers });
2450
+ this.#publish();
2451
+ }
2452
+
2453
+ #syncBlockers(session: FlowSession): void {
2454
+ const directory = this.sessionDir;
2455
+ if (directory === undefined) return;
2456
+ const recorded = readJsonLines<{ id?: string; timestamp?: string; text?: string }>(
2457
+ join(directory, "blockers.jsonl"),
2458
+ );
2459
+ let changed = false;
2460
+ for (const entry of recorded) {
2461
+ if (typeof entry.text !== "string" || entry.text.trim().length === 0) continue;
2462
+ if (session.blockers.some((blocker) => blocker.text === entry.text)) continue;
2463
+ session.blockers.push({
2464
+ id: typeof entry.id === "string" ? entry.id : `blocker-${randomUUID().slice(0, 8)}`,
2465
+ timestamp: typeof entry.timestamp === "string" ? entry.timestamp : nowIso(this.#now),
2466
+ text: entry.text,
2467
+ dismissed: false,
2468
+ });
2469
+ changed = true;
2470
+ }
2471
+ if (changed) {
2472
+ this.#persist();
2473
+ this.#broadcast({ type: "flowBlockers", blockers: session.blockers });
2474
+ }
2475
+ }
2476
+
2477
+ dismissBlocker(id: unknown): void {
2478
+ const session = this.#session;
2479
+ if (session === undefined) {
2480
+ this.#error("No Flow session exists.");
2481
+ return;
2482
+ }
2483
+ if (typeof id !== "string") {
2484
+ this.#error("A blocker dismissal needs the blocker id.");
2485
+ return;
2486
+ }
2487
+ const blocker = session.blockers.find((entry) => entry.id === id);
2488
+ if (blocker === undefined) {
2489
+ this.#error("That Flow blocker does not exist.");
2490
+ return;
2491
+ }
2492
+ session.blockers = session.blockers.map((entry) =>
2493
+ entry.id === id ? { ...entry, dismissed: true } : entry,
2494
+ );
2495
+ this.#persist();
2496
+ this.#broadcast({ type: "flowBlockers", blockers: session.blockers });
2497
+ this.#publish();
2498
+ }
2499
+
2500
+ // -------------------------------------------------------------- answer
2501
+
2502
+ answer(id: unknown, value: unknown, target: unknown): void {
2503
+ if (
2504
+ id === undefined ||
2505
+ (typeof id !== "string" && typeof id !== "number") ||
2506
+ typeof value !== "string" ||
2507
+ value.trim().length === 0
2508
+ ) {
2509
+ this.#error("An answer needs the question id and a non-empty value.");
2510
+ return;
2511
+ }
2512
+ if (target === "conversation") {
2513
+ const conversation = this.#conversation;
2514
+ if (
2515
+ conversation === undefined ||
2516
+ conversation.question === null ||
2517
+ String(conversation.question.id) !== String(id) ||
2518
+ conversation.agent === undefined ||
2519
+ !conversation.agent.answer(conversation.question.id, value)
2520
+ ) {
2521
+ this.#error("No pending conversation question with that id.");
2522
+ return;
2523
+ }
2524
+ conversation.question = null;
2525
+ this.#publish();
2526
+ return;
2527
+ }
2528
+ if (target === "goal") {
2529
+ const creation = this.#goalCreation;
2530
+ if (
2531
+ creation === undefined ||
2532
+ creation.question === null ||
2533
+ String(creation.question.id) !== String(id) ||
2534
+ creation.agent === undefined ||
2535
+ !creation.agent.answer(creation.question.id, value)
2536
+ ) {
2537
+ this.#error("No pending Flow goal question with that id.");
2538
+ return;
2539
+ }
2540
+ creation.question = null;
2541
+ return;
2542
+ }
2543
+
2544
+ const session = this.#session;
2545
+ const implementation = this.#implementation;
2546
+ if (
2547
+ session === undefined ||
2548
+ implementation === undefined ||
2549
+ implementation.agent === undefined ||
2550
+ session.question === null ||
2551
+ String(session.question.id) !== String(id) ||
2552
+ !implementation.agent.answer(session.question.id, value)
2553
+ ) {
2554
+ this.#error("No pending Flow question with that id.");
2555
+ return;
2556
+ }
2557
+ session.question = null;
2558
+ this.#persist();
2559
+ this.#publish();
2560
+ }
2561
+
2562
+ // --------------------------------------------------------- replay bits
2563
+
2564
+ #emitSections(options: { files?: boolean; widgets?: boolean }): void {
2565
+ const session = this.#session;
2566
+ if (session === undefined) return;
2567
+ if (options.files !== false) this.#broadcast({ type: "flowFiles", files: session.files });
2568
+ if (options.widgets !== false) this.#broadcast({ type: "flowWidgets", widgets: session.widgets });
2569
+ this.#broadcast({ type: "flowSteering", notes: session.steering });
2570
+ this.#broadcast({ type: "flowDecisions", text: session.decisions });
2571
+ this.#broadcast({ type: "flowCheckpoints", checkpoints: session.checkpoints });
2572
+ this.#broadcast({ type: "flowVerification", verification: session.verification });
2573
+ this.#broadcast({ type: "flowBlockers", blockers: session.blockers });
2574
+ this.#broadcast({ type: "flowPr", pr: session.pr });
2575
+ }
2576
+ }
2577
+
2578
+ function verificationStatus(value: unknown): "passed" | "failed" | "not-run" {
2579
+ return value === "passed" || value === "failed" ? value : "not-run";
2580
+ }
2581
+
2582
+ /** Parses `git status --porcelain=v1 -z`. */
2583
+ export function parseStatusZ(
2584
+ text: string,
2585
+ ): { status: "added" | "modified" | "deleted" | "renamed" | "untracked"; path: string }[] {
2586
+ const parts = text.split("\0");
2587
+ const entries: { status: "added" | "modified" | "deleted" | "renamed" | "untracked"; path: string }[] =
2588
+ [];
2589
+ for (let index = 0; index < parts.length; index += 1) {
2590
+ const part = parts[index];
2591
+ if (part === undefined || part.length < 4) continue;
2592
+ const code = part.slice(0, 2);
2593
+ const path = part.slice(3);
2594
+ if (code === "??") {
2595
+ entries.push({ status: "untracked", path });
2596
+ continue;
2597
+ }
2598
+ if (code.startsWith("R") || code.startsWith("C")) {
2599
+ // The source path follows as its own NUL-terminated field.
2600
+ index += 1;
2601
+ entries.push({ status: "renamed", path });
2602
+ continue;
2603
+ }
2604
+ if (code.includes("D")) {
2605
+ entries.push({ status: "deleted", path });
2606
+ continue;
2607
+ }
2608
+ if (code.includes("A")) {
2609
+ entries.push({ status: "added", path });
2610
+ continue;
2611
+ }
2612
+ entries.push({ status: "modified", path });
2613
+ }
2614
+ return entries;
2615
+ }
2616
+
2617
+ /** The forge named by an origin URL, reusing the tracker's classification. */
2618
+ export function classifyOrigin(
2619
+ remote: string,
2620
+ environment: Record<string, string | undefined> = process.env,
2621
+ ): ForgeTarget | undefined {
2622
+ const parsed = parseForgeRemote(remote, environment);
2623
+ if (parsed === undefined) return undefined;
2624
+ return {
2625
+ forge: parsed.forge,
2626
+ host: parsed.host,
2627
+ project: parsed.project,
2628
+ ...(parsed.forge === "gitea"
2629
+ ? { baseUrl: giteaBaseUrl(parsed, environment) }
2630
+ : {}),
2631
+ };
2632
+ }
2633
+
2634
+ export function prTitle(goal: string): string {
2635
+ const line = firstLine(goal);
2636
+ return line.length > 120 ? `${line.slice(0, 117)}...` : line;
2637
+ }
2638
+
2639
+ export function prBody(goal: string, decisions: string): string {
2640
+ return [
2641
+ "## Goal",
2642
+ "",
2643
+ goal.trim(),
2644
+ "",
2645
+ "## Decisions",
2646
+ "",
2647
+ decisions.trim().length === 0 ? "No decisions recorded." : decisions.trim(),
2648
+ "",
2649
+ ].join("\n");
2650
+ }
2651
+
2652
+ /** Keeps the exported worktree name reachable for the client's summary. */
2653
+ export function worktreeLabel(repoDir: string, sessionId: string): string {
2654
+ return relative(repoDir, join(repoDir, ".worktree", `flow-${sessionId}`));
2655
+ }