@dsdenes/devboard 0.1.560 → 0.1.562

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 +709 -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
@@ -0,0 +1,1632 @@
1
+ import { fixtureRecord } from "./fixtures/adr-source";
2
+ import { afterAll, afterEach, describe, expect, test } from "bun:test";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import type { Server } from "bun";
7
+ import { createApp } from "./app";
8
+ import { StubForge } from "./fixtures/stub-forge";
9
+ import { journalKey } from "./journal";
10
+
11
+ /**
12
+ * Flow Mode's deterministic contract, driven over the app's WebSocket with the
13
+ * stub ACP agent and an injected fake forge. Every fixture, state directory,
14
+ * worktree, and bare remote lives under the harness `.tmp`, never outside the
15
+ * repository root.
16
+ */
17
+
18
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
19
+ const TEST_ROOT = join(import.meta.dir, "..", ".tmp", "flow-tests");
20
+ const SENTINEL_GOAL = "# Fixture goal\n\nThe goal the host already had.\n";
21
+ const FLOW_GOAL = "# Flow goal\n\nMake the fixture behavior observable.";
22
+
23
+ mkdirSync(TEST_ROOT, { recursive: true });
24
+
25
+ interface Fixture {
26
+ root: string;
27
+ logPath: string;
28
+ forgeLog: string;
29
+ stateDir: string;
30
+ goalFile: string;
31
+ }
32
+
33
+ const cleanup: (() => void)[] = [];
34
+ const servers: Server<unknown>[] = [];
35
+
36
+ afterEach(() => {
37
+ // Stop each test's servers and their ACP peers before the next test; the
38
+ // suite runs many peers in parallel and a leftover server starves the next
39
+ // fixture's interview on a small CI runner.
40
+ for (const server of servers.splice(0)) server.stop(true);
41
+ });
42
+
43
+ afterAll(() => {
44
+ for (const action of cleanup.reverse()) action();
45
+ });
46
+
47
+ function testDirectory(name: string): string {
48
+ const directory = join(TEST_ROOT, `${name}-${process.pid}-${Math.random().toString(16).slice(2, 8)}`);
49
+ mkdirSync(directory, { recursive: true });
50
+ return directory;
51
+ }
52
+
53
+ function git(cwd: string, args: string[]): string {
54
+ const result = Bun.spawnSync({
55
+ cmd: [
56
+ "git",
57
+ "-C",
58
+ cwd,
59
+ "-c",
60
+ "user.name=Flow Fixture",
61
+ "-c",
62
+ "user.email=fixture@example.test",
63
+ "-c",
64
+ "commit.gpgsign=false",
65
+ "-c",
66
+ "init.defaultBranch=main",
67
+ ...args,
68
+ ],
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "true" },
72
+ });
73
+ if (result.exitCode !== 0) {
74
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString()}`);
75
+ }
76
+ return result.stdout.toString();
77
+ }
78
+
79
+ const GITIGNORE = [".worktree/", "node_modules/", "state/", "origin.git/", "*.log", ""].join("\n");
80
+
81
+ /** A real host checkout on `main`, a sentinel goal, a sentinel ADR, and a bare origin. */
82
+ function createFixture(name: string): Fixture {
83
+ const root = testDirectory(name);
84
+ mkdirSync(join(root, "adrs"), { recursive: true });
85
+ mkdirSync(join(root, "static"), { recursive: true });
86
+ mkdirSync(join(root, "src"), { recursive: true });
87
+ writeFileSync(join(root, "static", "index.html"), "<!doctype html><title>Fixture</title>");
88
+ writeFileSync(join(root, "GOAL.md"), SENTINEL_GOAL);
89
+ writeFileSync(join(root, "adrs", "0001-fixture.md"), fixtureRecord("0001-fixture.md", "Fixture decision"));
90
+ writeFileSync(join(root, ".gitignore"), GITIGNORE);
91
+ writeFileSync(join(root, "src", "main.txt"), "base\n");
92
+ git(root, ["init", "-b", "main"]);
93
+ git(root, ["add", "-A"]);
94
+ git(root, ["commit", "-m", "chore: fixture base"]);
95
+ git(root, ["init", "--bare", join(root, "origin.git")]);
96
+ git(root, ["remote", "add", "origin", join(root, "origin.git")]);
97
+ git(root, ["push", "-u", "origin", "main"]);
98
+
99
+ const logPath = join(root, "agent.log");
100
+ const forgeLog = join(root, "forge.log");
101
+ const fixture: Fixture = {
102
+ root,
103
+ logPath,
104
+ forgeLog,
105
+ stateDir: join(root, "state"),
106
+ goalFile: join(root, "GOAL.md"),
107
+ };
108
+ cleanup.push(() => {
109
+ try {
110
+ rmSync(root, { recursive: true, force: true });
111
+ } catch {
112
+ // A locked fixture directory is left for the operator.
113
+ }
114
+ });
115
+ return fixture;
116
+ }
117
+
118
+ interface TestServer {
119
+ server: Server<unknown>;
120
+ fixture: Fixture;
121
+ forge: StubForge;
122
+ url: string;
123
+ trackerReads: () => number;
124
+ }
125
+
126
+ interface StartOptions {
127
+ stub?: string[];
128
+ forge?: StubForge;
129
+ watchMs?: number;
130
+ goalTimeoutMs?: number;
131
+ tracker?: () => Promise<undefined>;
132
+ stateDir?: string;
133
+ restart?: () => void;
134
+ }
135
+
136
+ function startServer(fixture: Fixture, options: StartOptions = {}): TestServer {
137
+ let reads = 0;
138
+ const forge = options.forge ?? new StubForge({ logPath: fixture.forgeLog });
139
+ const command = [`bun ${STUB_AGENT}`, `--log=${fixture.logPath}`, ...(options.stub ?? [])]
140
+ .filter((part) => part.length > 0)
141
+ .join(" ");
142
+ const server = createApp({
143
+ adrDir: join(fixture.root, "adrs"),
144
+ staticDir: join(fixture.root, "static"),
145
+ hostname: "127.0.0.1",
146
+ port: 0,
147
+ repoDir: fixture.root,
148
+ stateDir: options.stateDir ?? fixture.stateDir,
149
+ goalFile: fixture.goalFile,
150
+ agentCommand: command,
151
+ tracker:
152
+ options.tracker ??
153
+ (async () => {
154
+ reads += 1;
155
+ return undefined;
156
+ }),
157
+ trackerSettle: { attempts: 1 },
158
+ restart: options.restart,
159
+ flow: {
160
+ forge,
161
+ forgeTarget: { forge: "github", host: "forge.test", project: "acme/fixture" },
162
+ watchMs: options.watchMs ?? 40,
163
+ analystTimeoutMs: 10_000,
164
+ goalTimeoutMs: options.goalTimeoutMs ?? 10_000,
165
+ },
166
+ });
167
+ servers.push(server);
168
+ return {
169
+ server,
170
+ fixture,
171
+ forge,
172
+ url: `127.0.0.1:${server.port}`,
173
+ trackerReads: () => reads,
174
+ };
175
+ }
176
+
177
+ type Event = Record<string, unknown>;
178
+
179
+ interface TestClient {
180
+ events: Event[];
181
+ mark(): number;
182
+ send(command: unknown): void;
183
+ waitFor(
184
+ predicate: (event: Event) => boolean,
185
+ options?: { timeout?: number; from?: number },
186
+ ): Promise<Event>;
187
+ close(): void;
188
+ }
189
+
190
+ /** Waits for a condition over the events a client has already received. */
191
+ async function waitUntil(
192
+ predicate: () => boolean,
193
+ description: string,
194
+ timeout = 20_000,
195
+ ): Promise<void> {
196
+ const deadline = Date.now() + timeout;
197
+ while (Date.now() < deadline) {
198
+ if (predicate()) return;
199
+ await Bun.sleep(10);
200
+ }
201
+ throw new Error(`Timed out waiting for ${description}.`);
202
+ }
203
+
204
+ function latest(client: TestClient, type: string): Event | undefined {
205
+ return [...client.events].reverse().find((event) => event.type === type);
206
+ }
207
+
208
+ function connect(testServer: TestServer): Promise<TestClient> {
209
+ const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
210
+ const socket = new WebSocket(`ws://${testServer.url}/ws`);
211
+ const events: Event[] = [];
212
+ const waiters: {
213
+ predicate: (event: Event) => boolean;
214
+ from: number;
215
+ resolve: (event: Event) => void;
216
+ reject: (error: Error) => void;
217
+ timer: Timer;
218
+ }[] = [];
219
+
220
+ socket.onopen = () => {
221
+ resolve({
222
+ events,
223
+ mark: () => events.length,
224
+ send(command) {
225
+ socket.send(JSON.stringify(command));
226
+ },
227
+ waitFor(predicate, options = {}) {
228
+ const from = options.from ?? 0;
229
+ const timeout = options.timeout ?? 20_000;
230
+ const found = events.slice(from).find(predicate);
231
+ if (found !== undefined) return Promise.resolve(found);
232
+ const { promise: waiting, resolve: settle, reject: fail } =
233
+ Promise.withResolvers<Event>();
234
+ const timer = setTimeout(() => {
235
+ const index = waiters.findIndex((waiter) => waiter.resolve === settle);
236
+ if (index >= 0) waiters.splice(index, 1);
237
+ fail(new Error(`Timed out waiting for an event. Saw: ${JSON.stringify(events)}`));
238
+ }, timeout);
239
+ waiters.push({ predicate, from, resolve: settle, reject: fail, timer });
240
+ return waiting;
241
+ },
242
+ close() {
243
+ socket.close();
244
+ },
245
+ });
246
+ };
247
+
248
+ socket.onmessage = (message) => {
249
+ let event: Event;
250
+ try {
251
+ event = JSON.parse(String(message.data)) as Event;
252
+ } catch {
253
+ return;
254
+ }
255
+ events.push(event);
256
+ for (let index = 0; index < waiters.length; index += 1) {
257
+ const waiter = waiters[index];
258
+ if (waiter === undefined) continue;
259
+ const candidate = events.slice(waiter.from).find(waiter.predicate);
260
+ if (candidate === undefined) continue;
261
+ waiters.splice(index, 1);
262
+ index -= 1;
263
+ clearTimeout(waiter.timer);
264
+ waiter.resolve(candidate);
265
+ }
266
+ };
267
+
268
+ socket.onerror = () => reject(new Error("WebSocket failed"));
269
+ return promise;
270
+ }
271
+
272
+ /** A round trip proving the socket is open before a command is sent. */
273
+ async function connected(client: TestClient): Promise<void> {
274
+ client.send({ type: "getProject" });
275
+ await client.waitFor((event) => event.type === "project");
276
+ }
277
+
278
+ function stubLog(fixture: Fixture): string[] {
279
+ try {
280
+ return readFileSync(fixture.logPath, "utf8").split("\n").filter((line) => line.length > 0);
281
+ } catch {
282
+ return [];
283
+ }
284
+ }
285
+
286
+ function spawns(fixture: Fixture): string[] {
287
+ return stubLog(fixture).filter((line) => line.startsWith("spawn "));
288
+ }
289
+
290
+ function prompts(fixture: Fixture): string[] {
291
+ return stubLog(fixture).filter((line) => line.startsWith("prompt "));
292
+ }
293
+
294
+ /** Waits for a line the spawned stub writes, instead of guessing a duration. */
295
+ async function waitForStub(
296
+ fixture: Fixture,
297
+ predicate: (line: string) => boolean,
298
+ timeout = 10_000,
299
+ ): Promise<void> {
300
+ const deadline = Date.now() + timeout;
301
+ while (Date.now() < deadline) {
302
+ if (stubLog(fixture).some(predicate)) return;
303
+ await Bun.sleep(10);
304
+ }
305
+ throw new Error(`Timed out waiting for the stub log. Saw: ${JSON.stringify(stubLog(fixture))}`);
306
+ }
307
+
308
+ function sha256File(path: string): string {
309
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
310
+ }
311
+
312
+ function sessionIds(fixture: Fixture): string[] {
313
+ const directory = join(fixture.stateDir, "flow", journalKey(fixture.root));
314
+ try {
315
+ return readdirSync(directory, { withFileTypes: true })
316
+ .filter((entry) => entry.isDirectory())
317
+ .map((entry) => entry.name);
318
+ } catch {
319
+ return [];
320
+ }
321
+ }
322
+
323
+ function readSession(fixture: Fixture, id: string): Record<string, unknown> {
324
+ return JSON.parse(
325
+ readFileSync(join(fixture.stateDir, "flow", journalKey(fixture.root), id, "session.json"), "utf8"),
326
+ ) as Record<string, unknown>;
327
+ }
328
+
329
+ async function createGoal(client: TestClient, fixture: Fixture): Promise<void> {
330
+ client.send({ type: "createFlowGoal" });
331
+ await client.waitFor((event) => event.type === "flowQuestion");
332
+ const question = client.events.find((event) => event.type === "flowQuestion");
333
+ client.send({
334
+ type: "flowAnswer",
335
+ target: "goal",
336
+ id: question?.id,
337
+ value: "Alpha",
338
+ });
339
+ await client.waitFor((event) => event.type === "flowGoalStopped" && event.completed === true);
340
+ }
341
+
342
+ /** Waits until a Flow widget snapshot covers every changed file. */
343
+ function isWidgetSnapshot(event: Event): boolean {
344
+ return event.type === "flowWidgets" && Array.isArray(event.widgets);
345
+ }
346
+
347
+ describe("Flow Mode contract", () => {
348
+ test("a fresh connection starts with no Flow work and spawns nothing", async () => {
349
+ const fixture = createFixture("connect");
350
+ const testServer = startServer(fixture);
351
+ const client = await connect(testServer);
352
+
353
+ client.send({ type: "getGoal" });
354
+ const goal = await client.waitFor((event) => event.type === "goal");
355
+ expect(goal.text).toBe(SENTINEL_GOAL);
356
+
357
+ // A round trip after the reply proves no peer was spawned and no Flow
358
+ // event was invented for a session that does not exist.
359
+ client.send({ type: "getProject" });
360
+ await client.waitFor((event) => event.type === "project");
361
+ expect(client.events.some((event) => String(event.type).startsWith("flow"))).toBe(false);
362
+ expect(spawns(fixture)).toHaveLength(0);
363
+ expect(testServer.trackerReads()).toBe(0);
364
+ client.close();
365
+ }, 30_000);
366
+
367
+ test("the Flow goal interview spawns one peer, writes only the session goal, and never GOAL.md", async () => {
368
+ const fixture = createFixture("goal");
369
+ const testServer = startServer(fixture, { stub: ["--flow-goal"] });
370
+ const client = await connect(testServer);
371
+ await connected(client);
372
+ const before = sha256File(fixture.goalFile);
373
+
374
+ client.send({ type: "createFlowGoal" });
375
+ const started = await client.waitFor((event) => event.type === "flowGoalStarted");
376
+ expect(started.type).toBe("flowGoalStarted");
377
+ const question = await client.waitFor((event) => event.type === "flowQuestion");
378
+ expect(question.target).toBe("goal");
379
+ expect(Array.isArray(question.options)).toBe(true);
380
+ expect((question.options as unknown[]).length).toBeGreaterThan(0);
381
+
382
+ client.send({ type: "flowAnswer", target: "goal", id: question.id, value: "Alpha" });
383
+ const stopped = await client.waitFor(
384
+ (event) => event.type === "flowGoalStopped",
385
+ );
386
+ expect(stopped.completed).toBe(true);
387
+ expect(stopped.text).toBe(FLOW_GOAL);
388
+
389
+ // The goal lives in the session directory, keyed by the repository.
390
+ const ids = sessionIds(fixture);
391
+ expect(ids).toHaveLength(1);
392
+ const id = ids[0] as string;
393
+ expect(id).toMatch(/^[0-9a-f]{8}$/);
394
+ const sessionDirectory = join(fixture.stateDir, "flow", journalKey(fixture.root), id);
395
+ expect(readFileSync(join(sessionDirectory, "goal.md"), "utf8")).toBe(FLOW_GOAL);
396
+ expect(sha256File(fixture.goalFile)).toBe(before);
397
+
398
+ expect(spawns(fixture)).toHaveLength(1);
399
+ expect(prompts(fixture).filter((line) => line.startsWith("prompt /flow-goal "))).toHaveLength(1);
400
+ expect(prompts(fixture)[0]).toBe(`prompt /flow-goal ${sessionDirectory}`);
401
+ expect(testServer.trackerReads()).toBe(0);
402
+
403
+ // No Goal Mode activity or question event leaked from the interview.
404
+ expect(client.events.filter((event) => event.type === "activity")).toHaveLength(0);
405
+ expect(client.events.filter((event) => event.type === "question")).toHaveLength(0);
406
+ client.close();
407
+ }, 30_000);
408
+
409
+ test("recreating a Flow goal reuses the unmerged session and replaces only its goal", async () => {
410
+ const fixture = createFixture("goal-reuse");
411
+ const testServer = startServer(fixture, { stub: ["--flow-goal"] });
412
+ const client = await connect(testServer);
413
+ await connected(client);
414
+ await createGoal(client, fixture);
415
+ const firstId = sessionIds(fixture)[0] as string;
416
+
417
+ const count = (type: string) => client.events.filter((event) => event.type === type).length;
418
+ const questionsBefore = count("flowQuestion");
419
+ const stopsBefore = count("flowGoalStopped");
420
+ client.send({ type: "createFlowGoal" });
421
+ await waitUntil(() => count("flowQuestion") > questionsBefore, "the second goal question");
422
+ const question = [...client.events].reverse().find((event) => event.type === "flowQuestion");
423
+ client.send({ type: "flowAnswer", target: "goal", id: question?.id, value: "Beta" });
424
+ await waitUntil(
425
+ () => count("flowGoalStopped") > stopsBefore,
426
+ "the second goal creation to finish",
427
+ );
428
+ expect(sessionIds(fixture)).toEqual([firstId]);
429
+ client.close();
430
+ }, 30_000);
431
+
432
+ test("an empty Flow goal reports one error and keeps the session for another attempt", async () => {
433
+ const fixture = createFixture("goal-empty");
434
+ const testServer = startServer(fixture, { stub: ["--flow-goal", "--flow-goal-empty"] });
435
+ const client = await connect(testServer);
436
+ await connected(client);
437
+ const before = sha256File(fixture.goalFile);
438
+
439
+ client.send({ type: "createFlowGoal" });
440
+ await client.waitFor((event) => event.type === "flowQuestion");
441
+ client.send({ type: "flowAnswer", target: "goal", id: 1, value: "Alpha" });
442
+ const stopped = await client.waitFor((event) => event.type === "flowGoalStopped");
443
+ expect(stopped.completed).toBe(false);
444
+ expect("text" in stopped).toBe(false);
445
+ const errors = client.events.filter((event) => event.type === "flowError");
446
+ expect(errors).toHaveLength(1);
447
+ expect(sessionIds(fixture)).toHaveLength(1);
448
+ expect(sha256File(fixture.goalFile)).toBe(before);
449
+
450
+ // The same session accepts a developer save after the failed attempt.
451
+ client.send({ type: "saveFlowGoal", text: "# Saved by hand\n" });
452
+ await client.waitFor((event) => event.type === "flowSnapshot" && event.goal === "# Saved by hand\n");
453
+ client.close();
454
+ }, 30_000);
455
+
456
+ test("a timed-out Flow goal interview keeps the previous goal unfinished", async () => {
457
+ const fixture = createFixture("goal-timeout");
458
+ const gate = join(fixture.root, "goal-hang");
459
+ const testServer = startServer(fixture, {
460
+ stub: ["--flow-goal", `--flow-goal-hang=${gate}`],
461
+ goalTimeoutMs: 100,
462
+ });
463
+ const client = await connect(testServer);
464
+ await connected(client);
465
+ await createGoal(client, fixture);
466
+ const id = sessionIds(fixture)[0] as string;
467
+ expect(readSession(fixture, id).goal).toBe(FLOW_GOAL);
468
+
469
+ writeFileSync(gate, "");
470
+ const mark = client.mark();
471
+ client.send({ type: "createFlowGoal" });
472
+ const stopped = await client.waitFor((event) => event.type === "flowGoalStopped", {
473
+ from: mark,
474
+ timeout: 20_000,
475
+ });
476
+ expect(stopped.completed).toBe(false);
477
+ expect("text" in stopped).toBe(false);
478
+ const errors = client.events.slice(mark).filter((event) => event.type === "flowError");
479
+ expect(errors).toHaveLength(1);
480
+ expect(String(errors[0]?.message)).toContain("timed out");
481
+
482
+ // The interrupted interview wrote nothing, so the accepted goal survives.
483
+ expect(readSession(fixture, id).goal).toBe(FLOW_GOAL);
484
+ const sessionDirectory = join(fixture.stateDir, "flow", journalKey(fixture.root), id);
485
+ expect(readFileSync(join(sessionDirectory, "goal.md"), "utf8")).toBe(FLOW_GOAL);
486
+ expect(sessionIds(fixture)).toEqual([id]);
487
+ expect(stubLog(fixture)).toContain(`flow-goal-hung ${sessionDirectory}`);
488
+ client.close();
489
+ }, 30_000);
490
+
491
+ test("a timed-out interview keeps its partial draft out of the session goal", async () => {
492
+ const fixture = createFixture("goal-partial");
493
+ const gate = join(fixture.root, "goal-partial");
494
+ writeFileSync(gate, "");
495
+ const testServer = startServer(fixture, {
496
+ stub: ["--flow-goal", `--flow-goal-partial=${gate}`],
497
+ goalTimeoutMs: 2_000,
498
+ });
499
+ const client = await connect(testServer);
500
+ await connected(client);
501
+ const mark = client.mark();
502
+ client.send({ type: "createFlowGoal" });
503
+ const stopped = await client.waitFor((event) => event.type === "flowGoalStopped", {
504
+ from: mark,
505
+ timeout: 20_000,
506
+ });
507
+ expect(stopped.completed).toBe(false);
508
+ expect("text" in stopped).toBe(false);
509
+ expect(stubLog(fixture).some((line) => line.startsWith("flow-goal-partial "))).toBe(true);
510
+
511
+ const id = sessionIds(fixture)[0] as string;
512
+ expect(readSession(fixture, id).goal).toBe("");
513
+ const sessionDirectory = join(fixture.stateDir, "flow", journalKey(fixture.root), id);
514
+ expect(readFileSync(join(sessionDirectory, "goal.md"), "utf8")).toBe("");
515
+ client.close();
516
+ }, 30_000);
517
+
518
+ test("a stopped session's watcher never rewrites the goal an interview is writing", async () => {
519
+ const fixture = createFixture("goal-after-stop");
520
+ const replacement = join(fixture.root, "replacement-goal.md");
521
+ const replacementText = "# Replacement goal\n\nWritten during the second interview.\n";
522
+ const testServer = startServer(fixture, {
523
+ stub: [
524
+ "--flow-goal",
525
+ "--flow-implement",
526
+ "--flow-pre-commit-delay=5000",
527
+ "--flow-goal-settle=700",
528
+ `--flow-goal-text-file=${replacement}`,
529
+ ],
530
+ });
531
+ const client = await connect(testServer);
532
+ await connected(client);
533
+ await createGoal(client, fixture);
534
+ const id = sessionIds(fixture)[0] as string;
535
+ const sessionDirectory = join(fixture.stateDir, "flow", journalKey(fixture.root), id);
536
+ const goalPath = join(sessionDirectory, "goal.md");
537
+ expect(readSession(fixture, id).goal).toBe(FLOW_GOAL);
538
+
539
+ client.send({ type: "startFlowImplementation" });
540
+ await client.waitFor((event) => event.type === "flowImplementationStarted");
541
+ await waitForStub(fixture, (line) => line.startsWith("prompt /flow-implement "));
542
+ client.send({ type: "stopFlowImplementation" });
543
+ await client.waitFor((event) => event.type === "flowStopped");
544
+ expect(readSession(fixture, id).status).toBe("stopped");
545
+
546
+ // The second interview writes its goal immediately and keeps working for
547
+ // longer than several ticks; the watcher must leave that draft alone.
548
+ writeFileSync(replacement, replacementText);
549
+ const written = stubLog(fixture).filter((line) => line.startsWith("flow-goal-written ")).length;
550
+ const mark = client.mark();
551
+ client.send({ type: "createFlowGoal" });
552
+ const question = await client.waitFor((event) => event.type === "flowQuestion", { from: mark });
553
+ client.send({ type: "flowAnswer", target: "goal", id: question.id, value: "Alpha" });
554
+ await waitUntil(
555
+ () =>
556
+ stubLog(fixture).filter((line) => line.startsWith("flow-goal-written ")).length >
557
+ written,
558
+ "the second interview's goal write",
559
+ );
560
+ expect(readFileSync(goalPath, "utf8")).toBe(replacementText);
561
+ // Real time is the subject: the live watcher's 40 ms ticks must not touch
562
+ // the file while the stub peer is still inside its turn.
563
+ await Bun.sleep(700);
564
+ expect(readFileSync(goalPath, "utf8")).toBe(replacementText);
565
+
566
+ const stopped = await client.waitFor((event) => event.type === "flowGoalStopped", {
567
+ from: mark,
568
+ });
569
+ expect(stopped.completed).toBe(true);
570
+ expect(stopped.text).toBe(replacementText);
571
+ expect(readSession(fixture, id).goal).toBe(replacementText);
572
+ expect(readFileSync(goalPath, "utf8")).toBe(replacementText);
573
+ client.close();
574
+ }, 30_000);
575
+
576
+ test("the Flow implementation gets one peer in its own worktree and branch from origin/main", async () => {
577
+ const fixture = createFixture("implement");
578
+ const testServer = startServer(fixture, {
579
+ stub: ["--flow-goal", "--flow-implement", "--flow-checkpoints=1", "--flow-analyst"],
580
+ });
581
+ const client = await connect(testServer);
582
+ await connected(client);
583
+ await createGoal(client, fixture);
584
+
585
+ const originMain = git(fixture.root, ["--git-dir", join(fixture.root, "origin.git"), "rev-parse", "main"]).trim();
586
+ client.send({ type: "startFlowImplementation" });
587
+ const started = await client.waitFor((event) => event.type === "flowImplementationStarted");
588
+ const sessionId = started.sessionId as string;
589
+ expect(sessionId).toMatch(/^[0-9a-f]{8}$/);
590
+ const worktree = join(fixture.root, ".worktree", `flow-${sessionId}`);
591
+ expect(started.worktree).toBe(worktree);
592
+ expect(started.branch).toBe(`flow/${sessionId}`);
593
+ expect(started.baseRevision).toBe(originMain);
594
+ expect(existsSync(worktree)).toBe(true);
595
+ expect(git(worktree, ["rev-parse", "--abbrev-ref", "HEAD"]).trim()).toBe(`flow/${sessionId}`);
596
+
597
+ await waitForStub(fixture, (line) => line.startsWith("prompt /flow-implement "));
598
+ // The first checkpoint proves the implementation turn is under way, so the
599
+ // peer's own log lines exist before they are asserted.
600
+ await waitForStub(fixture, (line) => line.startsWith("flow-checkpoint 1 "));
601
+ expect(spawns(fixture)).toHaveLength(2);
602
+ const implementationPrompts = prompts(fixture).filter((line) =>
603
+ line.startsWith("prompt /flow-implement "),
604
+ );
605
+ expect(implementationPrompts).toHaveLength(1);
606
+ const sessionDirectory = join(fixture.stateDir, "flow", journalKey(fixture.root), sessionId);
607
+ expect(implementationPrompts[0]).toBe(`prompt /flow-implement ${sessionDirectory}`);
608
+
609
+ const log = stubLog(fixture);
610
+ expect(log.some((line) => line.includes(`flow-implement ${sessionDirectory} cwd=${worktree}`))).toBe(
611
+ true,
612
+ );
613
+
614
+ await client.waitFor((event) => event.type === "flowStopped");
615
+ expect(testServer.trackerReads()).toBe(0);
616
+ expect(sha256File(fixture.goalFile)).toBe(
617
+ createHash("sha256").update(SENTINEL_GOAL).digest("hex"),
618
+ );
619
+
620
+ // The worktree's first checkpoint exists on its branch and origin got it.
621
+ const commits = git(worktree, ["rev-list", "--count", `${originMain}..HEAD`]).trim();
622
+ expect(Number(commits)).toBeGreaterThanOrEqual(1);
623
+ client.close();
624
+ }, 40_000);
625
+
626
+ test("Flow events never touch the Goal Mode goal, activity, or question state", async () => {
627
+ const fixture = createFixture("goal-isolation");
628
+ const testServer = startServer(fixture, { stub: ["--flow-goal"] });
629
+ const client = await connect(testServer);
630
+ await connected(client);
631
+ await createGoal(client, fixture);
632
+
633
+ client.send({ type: "getGoal" });
634
+ await client.waitFor((event) => event.type === "goal");
635
+ const goals = client.events.filter((event) => event.type === "goal");
636
+ expect(goals.at(-1)?.text).toBe(SENTINEL_GOAL);
637
+ expect(client.events.filter((event) => event.type === "activity")).toHaveLength(0);
638
+ expect(client.events.filter((event) => event.type === "question")).toHaveLength(0);
639
+ client.close();
640
+ }, 30_000);
641
+
642
+ test("the two modes refuse each other's starts while only one holds the checkout", async () => {
643
+ const fixture = createFixture("exclusive");
644
+ const testServer = startServer(fixture, {
645
+ stub: ["--flow-goal", "--flow-implement", "--flow-checkpoints=2", "--flow-step-delay=1500", "--create-goal", "--goal=GOAL.md"],
646
+ });
647
+ const client = await connect(testServer);
648
+ await connected(client);
649
+ await createGoal(client, fixture);
650
+ client.send({ type: "startFlowImplementation" });
651
+ await client.waitFor((event) => event.type === "flowImplementationStarted");
652
+ const mark = client.mark();
653
+
654
+ client.send({ type: "startLifecycle" });
655
+ const lifecycleError = await client.waitFor(
656
+ (event) => event.type === "error" && String(event.message).includes("Flow Mode"),
657
+ { from: mark },
658
+ );
659
+ expect(String(lifecycleError.message)).toContain("Flow Mode");
660
+
661
+ for (const command of [
662
+ { type: "startRun" },
663
+ { type: "createGoal" },
664
+ { type: "reviseGoal", request: "make it bigger" },
665
+ { type: "prepareSkills" },
666
+ ]) {
667
+ const before = client.mark();
668
+ client.send(command);
669
+ const error = await client.waitFor(
670
+ (event) => event.type === "error" && String(event.message).includes("Flow Mode"),
671
+ { from: before },
672
+ );
673
+ expect(String(error.message)).toContain("Flow Mode");
674
+ }
675
+ await waitForStub(fixture, (line) => line.startsWith("prompt /flow-implement "));
676
+ const flows = spawns(fixture);
677
+ expect(flows).toHaveLength(2); // goal interview + implementation
678
+
679
+ client.send({ type: "stopFlowImplementation" });
680
+ await client.waitFor((event) => event.type === "flowStopped");
681
+ client.close();
682
+ }, 40_000);
683
+
684
+ test("a Goal Mode start refuses a Flow start and spawns no Flow peer", async () => {
685
+ const fixture = createFixture("exclusive-goal");
686
+ const testServer = startServer(fixture, {
687
+ stub: ["--create-goal", `--goal=${join("GOAL.md")}`, "--delay-ms=1500", "--flow-goal"],
688
+ });
689
+ const client = await connect(testServer);
690
+ await connected(client);
691
+
692
+ client.send({ type: "createGoal" });
693
+ await client.waitFor((event) => event.type === "goalCreationStarted");
694
+ const mark = client.mark();
695
+ client.send({ type: "createFlowGoal" });
696
+ const error = await client.waitFor((event) => event.type === "flowError", { from: mark });
697
+ expect(String(error.message)).toContain("Goal Mode");
698
+ expect(prompts(fixture).filter((line) => line.startsWith("prompt /flow-goal "))).toHaveLength(0);
699
+ client.close();
700
+ }, 30_000);
701
+
702
+ test("reset is refused while an unmerged Flow session exists", async () => {
703
+ const fixture = createFixture("reset-refusal");
704
+ const testServer = startServer(fixture, {
705
+ stub: ["--flow-goal", "--flow-implement", "--flow-checkpoints=1"],
706
+ });
707
+ const client = await connect(testServer);
708
+ await connected(client);
709
+ await createGoal(client, fixture);
710
+
711
+ client.send({ type: "resetImplementation" });
712
+ const error = await client.waitFor((event) => event.type === "error");
713
+ expect(String(error.message)).toContain("Flow session is not merged");
714
+ expect(client.events.some((event) => event.type === "implementationResetStarted")).toBe(false);
715
+ expect(git(fixture.root, ["rev-parse", "HEAD"]).trim()).not.toBe("");
716
+ client.close();
717
+ }, 30_000);
718
+ });
719
+
720
+ describe("Flow change widgets", () => {
721
+ test("snapshots cover every changed file and acceptance survives only while the signature does", async () => {
722
+ const fixture = createFixture("widgets");
723
+ const testServer = startServer(fixture, {
724
+ stub: [
725
+ "--flow-goal",
726
+ "--flow-implement",
727
+ "--flow-change",
728
+ "--flow-checkpoints=2",
729
+ "--flow-step-delay=1500",
730
+ ],
731
+ });
732
+ const client = await connect(testServer);
733
+ await connected(client);
734
+ await createGoal(client, fixture);
735
+ client.send({ type: "startFlowImplementation" });
736
+ await client.waitFor((event) => event.type === "flowImplementationStarted");
737
+
738
+ await waitUntil(
739
+ () => {
740
+ const widgets = latest(client, "flowWidgets")?.widgets;
741
+ const files = latest(client, "flowFiles")?.files;
742
+ if (!Array.isArray(widgets) || !Array.isArray(files) || files.length === 0) return false;
743
+ const covered = new Set(
744
+ widgets.flatMap((widget) =>
745
+ Array.isArray(widget.files) ? (widget.files as string[]) : [],
746
+ ),
747
+ );
748
+ return files.every((file) => covered.has(file.path));
749
+ },
750
+ "a widget snapshot covering the diff",
751
+ );
752
+
753
+ const filesEvent = latest(client, "flowFiles");
754
+ const files = filesEvent?.files as { path: string; diff: string }[];
755
+ expect(files.map((file) => file.path)).toContain("src/app.txt");
756
+ for (const file of files) expect(file.diff).toContain("diff --git");
757
+ expect(client.events.some((event) => event.type === "flowWidgets" && Array.isArray(event.widgets))).toBe(true);
758
+
759
+ const widgetsEvent = latest(client, "flowWidgets");
760
+ const widgets = widgetsEvent?.widgets as {
761
+ id: string;
762
+ category: string;
763
+ files: string[];
764
+ accepted: boolean;
765
+ }[];
766
+ const categories = widgets.map((widget) => widget.category);
767
+ for (const category of categories) {
768
+ expect([
769
+ "contract",
770
+ "entity",
771
+ "naming",
772
+ "data",
773
+ "dependency",
774
+ "irreversible",
775
+ "structure",
776
+ ]).toContain(category);
777
+ }
778
+ expect(new Set(widgets.map((widget) => widget.category)).size).toBeGreaterThan(1);
779
+ expect(new Set(widgets.map((widget) => widget.id)).size).toBe(widgets.length);
780
+
781
+ // Accept one widget while its diff is unchanged.
782
+ const accepted = widgets[0] as { id: string };
783
+ client.send({ type: "acceptFlowWidget", id: accepted.id, accepted: true });
784
+ await waitUntil(
785
+ () => {
786
+ const current = latest(client, "flowWidgets")?.widgets as { id: string; accepted: boolean }[];
787
+ return current.some((widget) => widget.id === accepted.id && widget.accepted);
788
+ },
789
+ "the accepted widget",
790
+ );
791
+ const sessionId = (latest(client, "flowImplementationStarted")?.sessionId as string) ?? "";
792
+ const sessionDirectory = join(fixture.stateDir, "flow", journalKey(fixture.root), sessionId);
793
+ const acceptedFile = JSON.parse(readFileSync(join(sessionDirectory, "accepted.json"), "utf8")) as {
794
+ signatures: string[];
795
+ };
796
+ expect(acceptedFile.signatures).toContain(accepted.id);
797
+ expect(client.events.some((event) => event.type === "flowStopped")).toBe(false);
798
+
799
+ // The next checkpoint rewrites src/app.txt: the accepted signature is gone
800
+ // and a new, unaccepted widget for that file appears.
801
+ await waitUntil(
802
+ () => {
803
+ const current = latest(client, "flowWidgets")?.widgets as { id: string; files: string[] }[];
804
+ return !current.some((widget) => widget.id === accepted.id);
805
+ },
806
+ "the old widget to disappear",
807
+ 30_000,
808
+ );
809
+ const afterCheckpoint = latest(client, "flowWidgets")?.widgets as {
810
+ id: string;
811
+ files: string[];
812
+ accepted: boolean;
813
+ }[];
814
+ const replacement = afterCheckpoint.find((widget) => widget.files.includes("src/app.txt"));
815
+ expect(replacement).toBeDefined();
816
+ expect(replacement?.accepted).toBe(false);
817
+ const acceptedAfter = JSON.parse(
818
+ readFileSync(join(sessionDirectory, "accepted.json"), "utf8"),
819
+ ) as { signatures: string[] };
820
+ expect(acceptedAfter.signatures).not.toContain(accepted.id);
821
+
822
+ await client.waitFor((event) => event.type === "flowStopped");
823
+ client.close();
824
+ }, 60_000);
825
+
826
+ test("an uncovered file gets a structure fallback, and an analyst failure covers the whole diff", async () => {
827
+ const partial = createFixture("analyst-partial");
828
+ const partialServer = startServer(partial, {
829
+ stub: ["--flow-goal", "--flow-implement", "--flow-change", "--flow-analyst-partial"],
830
+ });
831
+ const partialClient = await connect(partialServer);
832
+ await connected(partialClient);
833
+ await createGoal(partialClient, partial);
834
+ partialClient.send({ type: "startFlowImplementation" });
835
+ await waitUntil(
836
+ () => {
837
+ const widgets = latest(partialClient, "flowWidgets")?.widgets;
838
+ const files = latest(partialClient, "flowFiles")?.files;
839
+ if (!Array.isArray(widgets) || !Array.isArray(files) || files.length < 2) return false;
840
+ const covered = new Set(
841
+ widgets.flatMap((widget) => (Array.isArray(widget.files) ? (widget.files as string[]) : [])),
842
+ );
843
+ return files.every((file) => covered.has(file.path));
844
+ },
845
+ "fallback coverage",
846
+ );
847
+ const partialWidgets = latest(partialClient, "flowWidgets")?.widgets as {
848
+ category: string;
849
+ files: string[];
850
+ }[];
851
+ expect(partialWidgets.some((widget) => widget.category === "structure")).toBe(true);
852
+ await partialClient.waitFor((event) => event.type === "flowStopped");
853
+ partialClient.close();
854
+
855
+ const failing = createFixture("analyst-fail");
856
+ const failingServer = startServer(failing, {
857
+ stub: ["--flow-goal", "--flow-implement", "--flow-change", "--flow-analyst-fail"],
858
+ });
859
+ const failingClient = await connect(failingServer);
860
+ await connected(failingClient);
861
+ await createGoal(failingClient, failing);
862
+ failingClient.send({ type: "startFlowImplementation" });
863
+ const error = await failingClient.waitFor((event) => event.type === "flowError");
864
+ expect(String(error.message)).toContain("analyst");
865
+ await waitUntil(
866
+ () => {
867
+ const widgets = latest(failingClient, "flowWidgets")?.widgets;
868
+ const files = latest(failingClient, "flowFiles")?.files;
869
+ if (!Array.isArray(widgets) || !Array.isArray(files) || files.length === 0) return false;
870
+ const covered = new Set(
871
+ widgets.flatMap((widget) => (Array.isArray(widget.files) ? (widget.files as string[]) : [])),
872
+ );
873
+ return files.every((file) => covered.has(file.path));
874
+ },
875
+ "fallback coverage after a failed analyst",
876
+ );
877
+ const fallback = latest(failingClient, "flowWidgets")?.widgets as {
878
+ category: string;
879
+ files: string[];
880
+ }[];
881
+ expect(fallback.every((widget) => widget.category === "structure")).toBe(true);
882
+ failingClient.close();
883
+ }, 60_000);
884
+ });
885
+
886
+ describe("Flow steering, decisions, and conversation", () => {
887
+ test("steering is read at the next checkpoint and the decision board is read by the implementation", async () => {
888
+ const fixture = createFixture("steering");
889
+ const testServer = startServer(fixture, {
890
+ stub: [
891
+ "--flow-goal",
892
+ "--flow-implement",
893
+ "--flow-change",
894
+ "--flow-checkpoints=2",
895
+ "--flow-step-delay=2000",
896
+ ],
897
+ });
898
+ const client = await connect(testServer);
899
+ await connected(client);
900
+ await createGoal(client, fixture);
901
+ client.send({ type: "startFlowImplementation" });
902
+ await client.waitFor((event) => event.type === "flowImplementationStarted");
903
+ await waitForStub(fixture, (line) => line.startsWith("flow-decisions-read 1 "));
904
+
905
+ // A goal edit is refused while the implementation runs.
906
+ client.send({ type: "saveFlowGoal", text: "# Replaced mid-run\n" });
907
+ const goalError = await client.waitFor(
908
+ (event) => event.type === "flowError" && String(event.message).includes("Stop it"),
909
+ );
910
+ expect(String(goalError.message)).toContain("implementation");
911
+
912
+ client.send({ type: "sendFlowSteering", text: "Prefer a smaller configuration" });
913
+ const steering = await client.waitFor(
914
+ (event) =>
915
+ event.type === "flowSteering" &&
916
+ Array.isArray(event.notes) &&
917
+ (event.notes as { text: string }[]).some((note) => note.text === "Prefer a smaller configuration"),
918
+ );
919
+ const note = (steering.notes as { id: string; status: string; source: string }[]).find(
920
+ (entry) => entry.status === "new",
921
+ );
922
+ expect(note?.source).toBe("developer");
923
+ expect(client.events.some((event) => event.type === "flowStopped")).toBe(false);
924
+
925
+ client.send({ type: "saveFlowDecisions", text: "# Decisions\n\nKeep the shape small.\n" });
926
+ await waitUntil(
927
+ () => {
928
+ const decisions = latest(client, "flowDecisions")?.text;
929
+ return decisions === "# Decisions\n\nKeep the shape small.\n";
930
+ },
931
+ "the decision board save",
932
+ );
933
+
934
+ await waitForStub(
935
+ fixture,
936
+ (line) => line.startsWith("flow-decisions-read 2 ") && line.length > "flow-decisions-read 2 5".length,
937
+ 30_000,
938
+ );
939
+ await waitForStub(fixture, (line) => line === `flow-ack ${note?.id ?? ""}`, 30_000);
940
+ await waitUntil(
941
+ () => {
942
+ const notes = latest(client, "flowSteering")?.notes as { id: string; status: string }[];
943
+ return notes?.some((entry) => entry.id === note?.id && entry.status === "read") ?? false;
944
+ },
945
+ "the steering note to be marked read",
946
+ );
947
+ await waitUntil(
948
+ () => {
949
+ const files = latest(client, "flowFiles")?.files as { path: string; diff: string }[];
950
+ return files?.some(
951
+ (file) => file.path === "steering-applied.txt" && file.diff.includes("Prefer a smaller configuration"),
952
+ ) ?? false;
953
+ },
954
+ "the steering to reach the next code change",
955
+ 30_000,
956
+ );
957
+ await client.waitFor((event) => event.type === "flowStopped");
958
+ client.close();
959
+ }, 60_000);
960
+
961
+ test("a widget conversation is read-only, queues a message, and its conclusion becomes steering", async () => {
962
+ const fixture = createFixture("conversation");
963
+ const testServer = startServer(fixture, {
964
+ stub: ["--flow-goal", "--flow-implement", "--flow-change", "--flow-widget"],
965
+ });
966
+ const client = await connect(testServer);
967
+ await connected(client);
968
+ await createGoal(client, fixture);
969
+ client.send({ type: "startFlowImplementation" });
970
+ await waitUntil(
971
+ () => {
972
+ const widgets = latest(client, "flowWidgets")?.widgets as { id: string }[] | undefined;
973
+ return Array.isArray(widgets) && widgets.length > 0;
974
+ },
975
+ "the first widget snapshot",
976
+ );
977
+ await client.waitFor((event) => event.type === "flowStopped");
978
+ const sessionId = sessionIds(fixture)[0] as string;
979
+ const sessionDirectory = join(fixture.stateDir, "flow", journalKey(fixture.root), sessionId);
980
+ const session = readSession(fixture, sessionId);
981
+ const worktree = session.worktree as string;
982
+ const base = session.baseRevision as string;
983
+ const diffBefore = createHash("sha256").update(git(worktree, ["diff", base, "--"])).digest("hex");
984
+
985
+ const widget = (latest(client, "flowWidgets")?.widgets as { id: string }[])[0] as { id: string };
986
+ client.send({ type: "openFlowWidget", id: widget.id });
987
+ const started = await client.waitFor((event) => event.type === "flowConversationStarted");
988
+ expect(started.widgetId).toBe(widget.id);
989
+ const question = await client.waitFor((event) => event.type === "flowConversationQuestion");
990
+ client.send({
991
+ type: "flowAnswer",
992
+ target: "conversation",
993
+ id: question.id,
994
+ value: "Alpha",
995
+ });
996
+ await client.waitFor(
997
+ (event) => event.type === "flowConversationActivity" && String(event.text).includes("Answer"),
998
+ );
999
+
1000
+ client.send({ type: "sendFlowConversation", text: "Explain the contract change" });
1001
+ client.send({ type: "sendFlowConversation", text: "And the naming choice" });
1002
+ await waitForStub(fixture, (line) => line === "flow-conversation Explain the contract change");
1003
+ await waitForStub(fixture, (line) => line === "flow-conversation And the naming choice");
1004
+
1005
+ client.send({ type: "sendFlowConclusion", text: "Prefer the smaller contract shape." });
1006
+ await waitUntil(
1007
+ () => {
1008
+ const decisions = latest(client, "flowDecisions")?.text;
1009
+ return typeof decisions === "string" && decisions.includes("Prefer the smaller contract shape.");
1010
+ },
1011
+ "the conclusion to reach the decision board",
1012
+ );
1013
+ await waitUntil(
1014
+ () => {
1015
+ const notes = latest(client, "flowSteering")?.notes as { source: string }[];
1016
+ return notes?.some((note) => note.source === `widget-chat:${widget.id}`) ?? false;
1017
+ },
1018
+ "the conclusion to become steering",
1019
+ );
1020
+ const decisions = latest(client, "flowDecisions")?.text as string;
1021
+ expect(decisions).toMatch(/## .*Widget conversation/);
1022
+ expect(readFileSync(join(sessionDirectory, "decisions.md"), "utf8")).toBe(decisions);
1023
+
1024
+ const diffAfter = createHash("sha256").update(git(worktree, ["diff", base, "--"])).digest("hex");
1025
+ expect(diffAfter).toBe(diffBefore);
1026
+
1027
+ client.send({ type: "closeFlowWidget" });
1028
+ await client.waitFor((event) => event.type === "flowConversationStopped");
1029
+ expect(client.events.some((event) => event.type === "flowStopped" && false)).toBe(false);
1030
+ client.close();
1031
+ }, 60_000);
1032
+
1033
+ test("a developer save and an agent append preserve both on the decision board", async () => {
1034
+ const fixture = createFixture("decisions-race");
1035
+ const testServer = startServer(fixture, {
1036
+ stub: ["--flow-goal", "--flow-implement", "--flow-change", "--flow-widget"],
1037
+ });
1038
+ const client = await connect(testServer);
1039
+ await connected(client);
1040
+ await createGoal(client, fixture);
1041
+ client.send({ type: "startFlowImplementation" });
1042
+ await waitUntil(
1043
+ () => {
1044
+ const widgets = latest(client, "flowWidgets")?.widgets as { id: string }[] | undefined;
1045
+ return Array.isArray(widgets) && widgets.length > 0;
1046
+ },
1047
+ "the first widget snapshot",
1048
+ );
1049
+ await client.waitFor((event) => event.type === "flowStopped");
1050
+
1051
+ const widget = (latest(client, "flowWidgets")?.widgets as { id: string }[])[0] as { id: string };
1052
+ client.send({ type: "openFlowWidget", id: widget.id });
1053
+ await client.waitFor((event) => event.type === "flowConversationStarted");
1054
+ client.send({ type: "saveFlowDecisions", text: "# Board\n\nDeveloper text.\n" });
1055
+ client.send({ type: "sendFlowConclusion", text: "Conclusion text." });
1056
+ await waitUntil(
1057
+ () => {
1058
+ const text = latest(client, "flowDecisions")?.text;
1059
+ return (
1060
+ typeof text === "string" && text.includes("Developer text.") && text.includes("Conclusion text.")
1061
+ );
1062
+ },
1063
+ "both decision-board writes",
1064
+ );
1065
+ const text = latest(client, "flowDecisions")?.text as string;
1066
+ expect(text.startsWith("# Board\n\nDeveloper text.")).toBe(true);
1067
+ expect(text).toMatch(/## .*Widget conversation/);
1068
+ client.close();
1069
+ }, 60_000);
1070
+ });
1071
+
1072
+ describe("Flow checkpoints, verification, questions, and blockers", () => {
1073
+ test("a commit without a checkpoint entry is shown from its subject and verification badges persist", async () => {
1074
+ const fixture = createFixture("checkpoints");
1075
+ const testServer = startServer(fixture, {
1076
+ stub: [
1077
+ "--flow-goal",
1078
+ "--flow-implement",
1079
+ "--flow-change",
1080
+ "--flow-silent-checkpoints",
1081
+ "--flow-no-verification",
1082
+ ],
1083
+ });
1084
+ const client = await connect(testServer);
1085
+ await connected(client);
1086
+ await createGoal(client, fixture);
1087
+ client.send({ type: "startFlowImplementation" });
1088
+ const checkpoints = await client.waitFor(
1089
+ (event) => event.type === "flowCheckpoints" && (event.checkpoints as unknown[]).length > 0,
1090
+ );
1091
+ const entry = (checkpoints.checkpoints as Record<string, unknown>[])[0] as Record<string, unknown>;
1092
+ expect(entry.summary).toBe("checkpoint 1: fixture change");
1093
+ expect(entry.commit).toMatch(/^[0-9a-f]{7}$/);
1094
+ expect(entry.verification).toBe("not-run");
1095
+ expect(entry.files).toBeGreaterThan(0);
1096
+
1097
+ const failing = createFixture("verification-failed");
1098
+ const failingServer = startServer(failing, {
1099
+ stub: [
1100
+ "--flow-goal",
1101
+ "--flow-implement",
1102
+ "--flow-change",
1103
+ "--flow-verification=failed",
1104
+ ],
1105
+ });
1106
+ const failingClient = await connect(failingServer);
1107
+ await connected(failingClient);
1108
+ await createGoal(failingClient, failing);
1109
+ failingClient.send({ type: "startFlowImplementation" });
1110
+ const verification = await failingClient.waitFor((event) => event.type === "flowVerification");
1111
+ expect(verification.verification).toMatchObject({
1112
+ command: "bun test",
1113
+ status: "failed",
1114
+ });
1115
+ const failedCheckpoints = await failingClient.waitFor(
1116
+ (event) =>
1117
+ event.type === "flowCheckpoints" &&
1118
+ (event.checkpoints as { verification: string }[]).some(
1119
+ (checkpoint) => checkpoint.verification === "failed",
1120
+ ),
1121
+ );
1122
+ expect(Array.isArray(failedCheckpoints.checkpoints)).toBe(true);
1123
+ failingClient.close();
1124
+ }, 60_000);
1125
+
1126
+ test("an implementation question is answerable and a blocker can be dismissed", async () => {
1127
+ const fixture = createFixture("question-blocker");
1128
+ const testServer = startServer(fixture, {
1129
+ stub: [
1130
+ "--flow-goal",
1131
+ "--flow-implement",
1132
+ "--flow-change",
1133
+ "--flow-question",
1134
+ "--flow-blocked",
1135
+ ],
1136
+ });
1137
+ const client = await connect(testServer);
1138
+ await connected(client);
1139
+ await createGoal(client, fixture);
1140
+ client.send({ type: "startFlowImplementation" });
1141
+ const question = await client.waitFor(
1142
+ (event) => event.type === "flowQuestion" && event.target === "implementation",
1143
+ );
1144
+ client.send({ type: "flowAnswer", target: "implementation", id: question.id, value: "Beta" });
1145
+ await waitForStub(fixture, (line) => line === "answer Beta", 30_000);
1146
+
1147
+ const blockers = await client.waitFor(
1148
+ (event) => event.type === "flowBlockers" && (event.blockers as unknown[]).length > 0,
1149
+ );
1150
+ const blocker = (blockers.blockers as { id: string; text: string; dismissed: boolean }[])[0] as {
1151
+ id: string;
1152
+ text: string;
1153
+ dismissed: boolean;
1154
+ };
1155
+ expect(blocker.text).toContain("fixture cannot continue");
1156
+ expect(blocker.dismissed).toBe(false);
1157
+ client.send({ type: "dismissFlowBlocker", id: blocker.id });
1158
+ await waitUntil(
1159
+ () => {
1160
+ const current = latest(client, "flowBlockers")?.blockers as { id: string; dismissed: boolean }[];
1161
+ return current?.some((entry) => entry.id === blocker.id && entry.dismissed) ?? false;
1162
+ },
1163
+ "the blocker dismissal",
1164
+ );
1165
+ const session = readSession(fixture, sessionIds(fixture)[0] as string);
1166
+ expect((session.blockers as { dismissed: boolean }[]).some((entry) => entry.dismissed)).toBe(true);
1167
+ client.close();
1168
+ }, 60_000);
1169
+ });
1170
+
1171
+ describe("Flow session lifecycle", () => {
1172
+ test("stopping keeps the worktree, files, and review state and starts no replacement", async () => {
1173
+ const fixture = createFixture("stop");
1174
+ const testServer = startServer(fixture, {
1175
+ stub: [
1176
+ "--flow-goal",
1177
+ "--flow-implement",
1178
+ "--flow-change",
1179
+ "--flow-checkpoints=2",
1180
+ "--flow-step-delay=5000",
1181
+ ],
1182
+ });
1183
+ const client = await connect(testServer);
1184
+ await connected(client);
1185
+ await createGoal(client, fixture);
1186
+ client.send({ type: "startFlowImplementation" });
1187
+ const started = await client.waitFor((event) => event.type === "flowImplementationStarted");
1188
+ await waitUntil(
1189
+ () => Array.isArray(latest(client, "flowFiles")?.files) && (latest(client, "flowFiles")?.files as unknown[]).length > 0,
1190
+ "the first checkpoint diff",
1191
+ );
1192
+
1193
+ client.send({ type: "stopFlowImplementation" });
1194
+ await client.waitFor((event) => event.type === "flowStopped");
1195
+ const sessionId = started.sessionId as string;
1196
+ const session = readSession(fixture, sessionId);
1197
+ expect(session.status).toBe("stopped");
1198
+ expect(existsSync(session.worktree as string)).toBe(true);
1199
+ const directory = join(fixture.stateDir, "flow", journalKey(fixture.root), sessionId);
1200
+ for (const name of ["session.json", "goal.md", "decisions.md", "changes.json", "accepted.json"]) {
1201
+ expect(existsSync(join(directory, name))).toBe(true);
1202
+ }
1203
+ const spawnsAfterStop = spawns(fixture).length;
1204
+ client.send({ type: "getProject" });
1205
+ await client.waitFor((event) => event.type === "project");
1206
+ expect(spawns(fixture)).toHaveLength(spawnsAfterStop);
1207
+
1208
+ // Starting again reuses the same worktree and session.
1209
+ client.send({ type: "startFlowImplementation" });
1210
+ const resumed = await client.waitFor(
1211
+ (event) => event.type === "flowImplementationStarted" && event.sessionId === sessionId,
1212
+ );
1213
+ expect(resumed.worktree).toBe(started.worktree);
1214
+ await waitForStub(
1215
+ fixture,
1216
+ (line) => line === `prompt /flow-implement ${directory}` && promptCount(fixture, directory) > 1,
1217
+ );
1218
+ client.send({ type: "stopFlowImplementation" });
1219
+ await client.waitFor((event) => event.type === "flowStopped");
1220
+ client.close();
1221
+ }, 60_000);
1222
+
1223
+ test("restartServer whenIdle waits for active Flow work", async () => {
1224
+ const fixture = createFixture("restart-idle");
1225
+ let restarts = 0;
1226
+ const testServer = startServer(fixture, {
1227
+ restart: () => {
1228
+ restarts += 1;
1229
+ },
1230
+ stub: [
1231
+ "--flow-goal",
1232
+ "--flow-implement",
1233
+ "--flow-change",
1234
+ "--flow-checkpoints=2",
1235
+ "--flow-step-delay=8000",
1236
+ ],
1237
+ });
1238
+ const client = await connect(testServer);
1239
+ await connected(client);
1240
+ await createGoal(client, fixture);
1241
+ client.send({ type: "startFlowImplementation" });
1242
+ await client.waitFor((event) => event.type === "flowImplementationStarted");
1243
+
1244
+ client.send({ type: "restartServer", whenIdle: true });
1245
+ await client.waitFor((event) => event.type === "upgradePending");
1246
+ expect(restarts).toBe(0);
1247
+
1248
+ client.send({ type: "stopFlowImplementation" });
1249
+ await client.waitFor((event) => event.type === "flowStopped");
1250
+ await waitUntil(() => restarts === 1, "the deferred restart", 10_000);
1251
+ client.close();
1252
+ }, 60_000);
1253
+
1254
+ test("reverting the worktree diff removes its widgets and accepted state", async () => {
1255
+ const fixture = createFixture("revert");
1256
+ const testServer = startServer(fixture, {
1257
+ stub: ["--flow-goal", "--flow-implement", "--flow-change"],
1258
+ });
1259
+ const client = await connect(testServer);
1260
+ await connected(client);
1261
+ await createGoal(client, fixture);
1262
+ client.send({ type: "startFlowImplementation" });
1263
+ await waitUntil(
1264
+ () => {
1265
+ const widgets = latest(client, "flowWidgets")?.widgets as { id: string }[] | undefined;
1266
+ return Array.isArray(widgets) && widgets.length > 0;
1267
+ },
1268
+ "the first widget snapshot",
1269
+ );
1270
+ await client.waitFor((event) => event.type === "flowStopped");
1271
+ const sessionId = sessionIds(fixture)[0] as string;
1272
+ const session = readSession(fixture, sessionId);
1273
+ const widget = (latest(client, "flowWidgets")?.widgets as { id: string }[])[0] as { id: string };
1274
+ client.send({ type: "acceptFlowWidget", id: widget.id, accepted: true });
1275
+ await waitUntil(
1276
+ () => {
1277
+ const ids = (latest(client, "flowWidgets")?.widgets as { id: string; accepted: boolean }[]) ?? [];
1278
+ return ids.some((entry) => entry.id === widget.id && entry.accepted);
1279
+ },
1280
+ "the accepted widget",
1281
+ );
1282
+
1283
+ git(session.worktree as string, ["reset", "--hard", session.baseRevision as string]);
1284
+ await waitUntil(
1285
+ () => Array.isArray(latest(client, "flowFiles")?.files) && (latest(client, "flowFiles")?.files as unknown[]).length === 0,
1286
+ "the reverted diff",
1287
+ );
1288
+ await waitUntil(
1289
+ () => Array.isArray(latest(client, "flowWidgets")?.widgets) && (latest(client, "flowWidgets")?.widgets as unknown[]).length === 0,
1290
+ "the widgets to disappear",
1291
+ );
1292
+ const directory = join(fixture.stateDir, "flow", journalKey(fixture.root), sessionId);
1293
+ const accepted = JSON.parse(readFileSync(join(directory, "accepted.json"), "utf8")) as {
1294
+ signatures: string[];
1295
+ };
1296
+ expect(accepted.signatures).toEqual([]);
1297
+ client.close();
1298
+ }, 60_000);
1299
+ });
1300
+
1301
+ describe("Flow persistence and isolation", () => {
1302
+ test("a replacement server restores the session, stops the stale run, and spawns nothing", async () => {
1303
+ const fixture = createFixture("restart");
1304
+ const first = startServer(fixture, {
1305
+ stub: [
1306
+ "--flow-goal",
1307
+ "--flow-implement",
1308
+ "--flow-change",
1309
+ "--flow-checkpoints=2",
1310
+ "--flow-step-delay=5000",
1311
+ ],
1312
+ });
1313
+ const client = await connect(first);
1314
+ await connected(client);
1315
+ await createGoal(client, fixture);
1316
+ client.send({ type: "saveFlowDecisions", text: "# Board\n\nRun one.\n" });
1317
+ await client.waitFor(
1318
+ (event) => event.type === "flowDecisions" && String(event.text).includes("Run one."),
1319
+ );
1320
+ client.send({ type: "startFlowImplementation" });
1321
+ await client.waitFor((event) => event.type === "flowImplementationStarted");
1322
+ await waitUntil(
1323
+ () => {
1324
+ const checkpoints = latest(client, "flowCheckpoints")?.checkpoints as unknown[] | undefined;
1325
+ return Array.isArray(checkpoints) && checkpoints.length > 0;
1326
+ },
1327
+ "the first checkpoint",
1328
+ );
1329
+ const sessionId = sessionIds(fixture)[0] as string;
1330
+ const before = readSession(fixture, sessionId);
1331
+ const spawnsBefore = spawns(fixture).length;
1332
+ client.close();
1333
+ first.server.stop(true);
1334
+
1335
+ const second = startServer(fixture, {
1336
+ stub: ["--flow-goal", "--flow-implement", "--flow-change"],
1337
+ });
1338
+ const restored = await connect(second);
1339
+ const snapshot = await restored.waitFor(
1340
+ (event) => event.type === "flowSnapshot" && event.sessionId === sessionId,
1341
+ );
1342
+ expect(snapshot.status).toBe("stopped");
1343
+ expect(snapshot.goal).toBe(before.goal as string);
1344
+ expect(snapshot.decisions).toContain("Run one.");
1345
+ expect((snapshot.worktree as string)).toBe(before.worktree as string);
1346
+ expect((snapshot.checkpoints as unknown[]).length).toBeGreaterThan(0);
1347
+ expect((snapshot.widgets as unknown[]).length).toBeGreaterThan(0);
1348
+ expect((snapshot.files as unknown[]).length).toBeGreaterThan(0);
1349
+ expect(String(snapshot.notice)).toContain("restarted");
1350
+ expect(spawns(fixture)).toHaveLength(spawnsBefore);
1351
+
1352
+ restored.send({ type: "getProject" });
1353
+ await restored.waitFor((event) => event.type === "project");
1354
+ expect(spawns(fixture)).toHaveLength(spawnsBefore);
1355
+
1356
+ restored.send({ type: "startFlowImplementation" });
1357
+ await restored.waitFor(
1358
+ (event) => event.type === "flowImplementationStarted" && event.sessionId === sessionId,
1359
+ );
1360
+ const resumedPrompt = `prompt /flow-implement ${join(
1361
+ fixture.stateDir,
1362
+ "flow",
1363
+ journalKey(fixture.root),
1364
+ sessionId,
1365
+ )}`;
1366
+ const promptsBefore = prompts(fixture).filter((line) => line === resumedPrompt).length;
1367
+ await waitUntil(
1368
+ () => prompts(fixture).filter((line) => line === resumedPrompt).length > promptsBefore,
1369
+ "the resumed implementation prompt",
1370
+ );
1371
+ expect(spawns(fixture).length).toBeGreaterThan(spawnsBefore);
1372
+ restored.send({ type: "stopFlowImplementation" });
1373
+ await restored.waitFor((event) => event.type === "flowStopped");
1374
+ restored.close();
1375
+ }, 90_000);
1376
+
1377
+ test("two repositories under one state directory never share a Flow session", async () => {
1378
+ const first = createFixture("isolation-a");
1379
+ const second = createFixture("isolation-b");
1380
+ const shared = join(TEST_ROOT, `shared-${process.pid}-${Date.now()}`);
1381
+ mkdirSync(shared, { recursive: true });
1382
+ cleanup.push(() => rmSync(shared, { recursive: true, force: true }));
1383
+ expect(journalKey(first.root)).not.toBe(journalKey(second.root));
1384
+
1385
+ const firstServer = startServer(first, { stub: ["--flow-goal"], stateDir: shared });
1386
+ const secondServer = startServer(second, { stub: ["--flow-goal"], stateDir: shared });
1387
+ const firstClient = await connect(firstServer);
1388
+ const secondClient = await connect(secondServer);
1389
+ await connected(firstClient);
1390
+ await connected(secondClient);
1391
+ await createGoal(firstClient, first);
1392
+ await createGoal(secondClient, second);
1393
+
1394
+ const firstIds = readdirSync(join(shared, "flow", journalKey(first.root)));
1395
+ const secondIds = readdirSync(join(shared, "flow", journalKey(second.root)));
1396
+ expect(firstIds).toHaveLength(1);
1397
+ expect(secondIds).toHaveLength(1);
1398
+ expect(firstIds[0]).not.toBe(secondIds[0]);
1399
+ const firstGoal = readFileSync(
1400
+ join(shared, "flow", journalKey(first.root), firstIds[0] as string, "goal.md"),
1401
+ "utf8",
1402
+ );
1403
+ expect(firstGoal).toBe(FLOW_GOAL);
1404
+ const secondSession = JSON.parse(
1405
+ readFileSync(
1406
+ join(shared, "flow", journalKey(second.root), secondIds[0] as string, "session.json"),
1407
+ "utf8",
1408
+ ),
1409
+ ) as Record<string, unknown>;
1410
+ expect(secondSession.goal).toBe(FLOW_GOAL);
1411
+
1412
+ // A fresh connection to the second repository sees only its own session.
1413
+ secondClient.send({ type: "getProject" });
1414
+ await secondClient.waitFor((event) => event.type === "project");
1415
+ const secondSnapshot = await connect(secondServer);
1416
+ const snapshot = await secondSnapshot.waitFor((event) => event.type === "flowSnapshot");
1417
+ expect(snapshot.sessionId).toBe(secondIds[0] as string);
1418
+ expect(snapshot.worktree).toContain(second.root);
1419
+ firstClient.close();
1420
+ secondClient.close();
1421
+ secondSnapshot.close();
1422
+ }, 60_000);
1423
+ });
1424
+
1425
+ /** Counts implementation prompts for a session directory in the stub log. */
1426
+ function promptCount(fixture: Fixture, directory: string): number {
1427
+ return prompts(fixture).filter((line) => line === `prompt /flow-implement ${directory}`).length;
1428
+ }
1429
+
1430
+ describe("Flow replay, base resolution, and validation edges", () => {
1431
+ test("a client connecting during the goal interview gets the session, marker, activity, and question first", async () => {
1432
+ const fixture = createFixture("replay-goal");
1433
+ const testServer = startServer(fixture, { stub: ["--flow-goal"] });
1434
+ const first = await connect(testServer);
1435
+ await connected(first);
1436
+ first.send({ type: "createFlowGoal" });
1437
+ await first.waitFor((event) => event.type === "flowQuestion");
1438
+
1439
+ const late = await connect(testServer);
1440
+ const sessionId = sessionIds(fixture)[0] as string;
1441
+ late.send({ type: "getGoal" });
1442
+ await late.waitFor((event) => event.type === "goal");
1443
+ const types = late.events.map((event) => event.type);
1444
+ const snapshot = late.events.find(
1445
+ (event) => event.type === "flowSnapshot" && event.sessionId === sessionId,
1446
+ );
1447
+ expect(snapshot).toBeDefined();
1448
+ expect(types.indexOf("flowGoalStarted")).toBeGreaterThanOrEqual(0);
1449
+ expect(types.indexOf("flowActivity")).toBeGreaterThan(types.indexOf("flowGoalStarted"));
1450
+ expect(types.indexOf("flowQuestion")).toBeGreaterThan(types.indexOf("flowActivity"));
1451
+ expect(types.indexOf("goal")).toBeGreaterThan(types.indexOf("flowQuestion"));
1452
+ const question = late.events.find((event) => event.type === "flowQuestion");
1453
+ expect(question?.target).toBe("goal");
1454
+ first.send({ type: "flowAnswer", target: "goal", id: question?.id, value: "Alpha" });
1455
+ await first.waitFor((event) => event.type === "flowGoalStopped");
1456
+ first.close();
1457
+ late.close();
1458
+ }, 40_000);
1459
+
1460
+ test("the base falls back to local main when origin has no main, and refuses when neither exists", async () => {
1461
+ const fixture = createFixture("base-fallback");
1462
+ git(fixture.root, ["remote", "remove", "origin"]);
1463
+ const testServer = startServer(fixture, {
1464
+ stub: ["--flow-goal", "--flow-implement", "--flow-change"],
1465
+ });
1466
+ const client = await connect(testServer);
1467
+ await connected(client);
1468
+ await createGoal(client, fixture);
1469
+ client.send({ type: "startFlowImplementation" });
1470
+ const started = await client.waitFor((event) => event.type === "flowImplementationStarted");
1471
+ expect(started.baseRevision).toBe(git(fixture.root, ["rev-parse", "main"]).trim());
1472
+ await client.waitFor((event) => event.type === "flowStopped");
1473
+ client.close();
1474
+
1475
+ const root = testDirectory("no-main");
1476
+ mkdirSync(join(root, "adrs"), { recursive: true });
1477
+ mkdirSync(join(root, "static"), { recursive: true });
1478
+ writeFileSync(join(root, "static", "index.html"), "<!doctype html>");
1479
+ writeFileSync(join(root, "GOAL.md"), "# Host\n");
1480
+ writeFileSync(join(root, "file.txt"), "x\n");
1481
+ git(root, ["init", "-b", "trunk"]);
1482
+ git(root, ["add", "-A"]);
1483
+ git(root, ["commit", "-m", "base"]);
1484
+ const noMain: Fixture = {
1485
+ root,
1486
+ logPath: join(root, "agent.log"),
1487
+ forgeLog: join(root, "forge.log"),
1488
+ stateDir: join(root, "state"),
1489
+ goalFile: join(root, "GOAL.md"),
1490
+ };
1491
+ cleanup.push(() => rmSync(root, { recursive: true, force: true }));
1492
+ const failing = startServer(noMain, {
1493
+ stub: ["--flow-goal", "--flow-implement", "--flow-change"],
1494
+ });
1495
+ const failingClient = await connect(failing);
1496
+ await connected(failingClient);
1497
+ await createGoal(failingClient, noMain);
1498
+ failingClient.send({ type: "startFlowImplementation" });
1499
+ const error = await failingClient.waitFor((event) => event.type === "flowError");
1500
+ expect(String(error.message)).toContain("neither origin/main nor main");
1501
+ expect(existsSync(join(root, ".worktree"))).toBe(false);
1502
+ expect(prompts(noMain).filter((line) => line.startsWith("prompt /flow-implement "))).toHaveLength(0);
1503
+ failingClient.close();
1504
+ }, 60_000);
1505
+
1506
+ test("an analyst that names a file outside the diff is rejected and covered instead", async () => {
1507
+ const fixture = createFixture("analyst-foreign");
1508
+ const testServer = startServer(fixture, {
1509
+ stub: [
1510
+ "--flow-goal",
1511
+ "--flow-implement",
1512
+ "--flow-change",
1513
+ "--flow-analyst-foreign",
1514
+ ],
1515
+ });
1516
+ const client = await connect(testServer);
1517
+ await connected(client);
1518
+ await createGoal(client, fixture);
1519
+ client.send({ type: "startFlowImplementation" });
1520
+ const error = await client.waitFor((event) => event.type === "flowError");
1521
+ expect(String(error.message)).toContain("valid widget list");
1522
+ await waitUntil(
1523
+ () => {
1524
+ const widgets = latest(client, "flowWidgets")?.widgets as
1525
+ | { files: string[]; category: string }[]
1526
+ | undefined;
1527
+ const files = latest(client, "flowFiles")?.files as { path: string }[] | undefined;
1528
+ if (!Array.isArray(widgets) || !Array.isArray(files) || files.length === 0) return false;
1529
+ const covered = new Set(widgets.flatMap((widget) => widget.files));
1530
+ return files.every((file) => covered.has(file.path));
1531
+ },
1532
+ "fallback coverage after an invalid analyst result",
1533
+ );
1534
+ const widgets = latest(client, "flowWidgets")?.widgets as { files: string[]; category: string }[];
1535
+ for (const widget of widgets) {
1536
+ for (const path of widget.files) expect(path).not.toBe("src/not-in-the-diff.txt");
1537
+ expect(widget.category).toBe("structure");
1538
+ }
1539
+ client.close();
1540
+ }, 60_000);
1541
+
1542
+ test("a reconnecting client receives the open conversation, its messages, and its pending question", async () => {
1543
+ const fixture = createFixture("replay-conversation");
1544
+ const testServer = startServer(fixture, {
1545
+ stub: ["--flow-goal", "--flow-implement", "--flow-change", "--flow-widget"],
1546
+ });
1547
+ const first = await connect(testServer);
1548
+ await connected(first);
1549
+ await createGoal(first, fixture);
1550
+ first.send({ type: "startFlowImplementation" });
1551
+ await waitUntil(
1552
+ () => {
1553
+ const widgets = latest(first, "flowWidgets")?.widgets as unknown[] | undefined;
1554
+ return Array.isArray(widgets) && widgets.length > 0;
1555
+ },
1556
+ "the first widget snapshot",
1557
+ );
1558
+ await first.waitFor((event) => event.type === "flowStopped");
1559
+ const widget = (latest(first, "flowWidgets")?.widgets as { id: string }[])[0] as { id: string };
1560
+ first.send({ type: "openFlowWidget", id: widget.id });
1561
+ await first.waitFor((event) => event.type === "flowConversationQuestion");
1562
+ first.send({ type: "sendFlowConversation", text: "Recorded developer message" });
1563
+ await waitUntil(
1564
+ () => {
1565
+ const message = latest(first, "flowConversationMessage");
1566
+ return message?.text === "Recorded developer message";
1567
+ },
1568
+ "the recorded conversation message",
1569
+ );
1570
+
1571
+ const late = await connect(testServer);
1572
+ late.send({ type: "getGoal" });
1573
+ await late.waitFor((event) => event.type === "goal");
1574
+ const types = late.events.map((event) => event.type);
1575
+ expect(types).toContain("flowConversationStarted");
1576
+ expect(types).toContain("flowConversationQuestion");
1577
+ expect(types).toContain("flowConversationMessage");
1578
+ expect(types.indexOf("flowConversationStarted")).toBeLessThan(types.indexOf("goal"));
1579
+ const message = late.events.find((event) => event.type === "flowConversationMessage");
1580
+ expect(message?.text).toBe("Recorded developer message");
1581
+ first.send({ type: "closeFlowWidget" });
1582
+ first.close();
1583
+ late.close();
1584
+ }, 60_000);
1585
+
1586
+ test("the widget identity ignores concept text and the seven categories have distinct colors", async () => {
1587
+ const here = import.meta.dir;
1588
+ const flowSource = readFileSync(join(here, "flow.ts"), "utf8");
1589
+ expect(flowSource).toContain("export function widgetSignature");
1590
+ // The signature takes only category, paths, and diffs, never concept text.
1591
+ const signatureCall = /widgetSignature\(([^)]*)\)/.exec(
1592
+ flowSource.slice(flowSource.indexOf("function widgetSignature")),
1593
+ );
1594
+ expect(signatureCall?.[1]).toContain("category");
1595
+
1596
+ const css = readFileSync(join(here, "..", "client", "app.css"), "utf8");
1597
+ const categories = [
1598
+ "contract",
1599
+ "entity",
1600
+ "naming",
1601
+ "data",
1602
+ "dependency",
1603
+ "irreversible",
1604
+ "structure",
1605
+ ];
1606
+ const lightRoot = css.slice(0, css.indexOf("@media (prefers-color-scheme: dark)"));
1607
+ const darkRoot = css.slice(css.indexOf("@media (prefers-color-scheme: dark)"));
1608
+ const lightBorders = categories.map((category) => {
1609
+ const match = new RegExp(`--flow-${category}-border:\\s*([^;]+);`).exec(lightRoot);
1610
+ return match?.[1]?.trim();
1611
+ });
1612
+ const darkBorders = categories.map((category) => {
1613
+ const match = new RegExp(`--flow-${category}-border:\\s*([^;]+);`).exec(darkRoot);
1614
+ return match?.[1]?.trim();
1615
+ });
1616
+ const lightSurfaces = categories.map((category) => {
1617
+ const match = new RegExp(`--flow-${category}-surface:\\s*([^;]+);`).exec(lightRoot);
1618
+ return match?.[1]?.trim();
1619
+ });
1620
+ expect(lightBorders.every((value) => value !== undefined)).toBe(true);
1621
+ expect(darkBorders.every((value) => value !== undefined)).toBe(true);
1622
+ expect(lightSurfaces.every((value) => value !== undefined)).toBe(true);
1623
+ expect(new Set(lightBorders).size).toBe(categories.length);
1624
+ expect(new Set(darkBorders).size).toBe(categories.length);
1625
+ expect(new Set(lightSurfaces).size).toBe(categories.length);
1626
+
1627
+ const view = readFileSync(join(here, "..", "client", "FlowMode.svelte"), "utf8");
1628
+ expect(view).toContain('from "diff2html"');
1629
+ expect(view).toContain('data-category={widget.category}');
1630
+ expect(view).toContain('data-testid="flow-widget"');
1631
+ });
1632
+ });