@dsdenes/devboard 0.1.559 → 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,957 @@
1
+ import { fixtureRecord, pendingRecord } from "./fixtures/adr-source";
2
+ import { afterAll, describe, expect, test } from "bun:test";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { Server } from "bun";
8
+ import { createApp } from "./app";
9
+ import type { EpicLookup, OpenEpic } from "./tracker";
10
+
11
+ const temporaryDirectories: string[] = [];
12
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
13
+ const MANAGED_SKILL = [
14
+ "---",
15
+ "name: behavior-first-testing",
16
+ "description: Choose tests by observable behavior.",
17
+ "metadata:",
18
+ ' devboard-managed: "true"',
19
+ "---",
20
+ "",
21
+ "# Behavior-first testing",
22
+ "",
23
+ "- Prefer observable behavior over implementation details.",
24
+ "",
25
+ ].join("\n");
26
+ const QUESTION_OPTIONS = [
27
+ { label: "Alpha", description: "Alpha leads to the first outcome.", recommended: true },
28
+ { label: "Beta", description: "Beta leads to the second outcome.", recommended: false },
29
+ ];
30
+ const CREATED_GOAL = "# Created goal\n\nThe agent wrote this.";
31
+
32
+ interface TestServer {
33
+ server: Server<unknown>;
34
+ adrDir: string;
35
+ root: string;
36
+ goalFile: string;
37
+ logPath: string;
38
+ url: string;
39
+ }
40
+
41
+ interface ServerOptions {
42
+ mode?: "create" | "revision";
43
+ writeStubGoal?: boolean;
44
+ goalText?: string;
45
+ goalFileName?: string;
46
+ agentArguments?: string;
47
+ tracker?: EpicLookup;
48
+ }
49
+
50
+ async function temporaryDirectory() {
51
+ const directory = await mkdtemp(join(tmpdir(), "devboard-goal-creation-"));
52
+ temporaryDirectories.push(directory);
53
+ return directory;
54
+ }
55
+
56
+ async function startServer(
57
+ files: Record<string, string>,
58
+ options: ServerOptions = {},
59
+ ): Promise<TestServer> {
60
+ const root = await temporaryDirectory();
61
+ const adrDir = join(root, "adrs");
62
+ const staticDir = join(root, "static");
63
+ const goalFile = join(root, options.goalFileName ?? "GOAL.md");
64
+ await mkdir(adrDir, { recursive: true });
65
+ await mkdir(staticDir, { recursive: true });
66
+ await writeFile(join(staticDir, "index.html"), "<!doctype html><title>ADR overview</title>");
67
+ if (options.goalText !== undefined) {
68
+ await writeFile(goalFile, options.goalText);
69
+ }
70
+ await mkdir(join(root, ".omp", "skills", "behavior-first-testing"), { recursive: true });
71
+ await writeFile(
72
+ join(root, ".omp", "skills", "behavior-first-testing", "SKILL.md"),
73
+ MANAGED_SKILL,
74
+ );
75
+ for (const [file, source] of Object.entries(files)) {
76
+ await writeFile(join(adrDir, file), source);
77
+ }
78
+
79
+ const logPath = join(root, "agent.log");
80
+ const mode = options.mode ?? "create";
81
+ const command = [
82
+ `bun ${STUB_AGENT}`,
83
+ "--create-goal",
84
+ mode === "revision" ? "--revise-goal" : "",
85
+ options.writeStubGoal === false ? "" : `--goal=${join(root, "GOAL.md")}`,
86
+ `--log=${logPath}`,
87
+ options.agentArguments ?? "",
88
+ ]
89
+ .filter((part) => part.length > 0)
90
+ .join(" ");
91
+
92
+ const server = createApp({
93
+ adrDir,
94
+ staticDir,
95
+ hostname: "127.0.0.1",
96
+ port: 0,
97
+ repoDir: root,
98
+ stateDir: join(root, "state"),
99
+ goalFile,
100
+ agentCommand: command,
101
+ tracker: options.tracker ?? (async () => undefined),
102
+ trackerSettle: { attempts: 1 },
103
+ });
104
+
105
+ return { server, adrDir, root, goalFile, logPath, url: `127.0.0.1:${server.port}` };
106
+ }
107
+
108
+ interface TestClient {
109
+ socket: WebSocket;
110
+ events: Record<string, unknown>[];
111
+ send(command: unknown): void;
112
+ waitFor(predicate: (message: Record<string, unknown>) => boolean): Promise<Record<string, unknown>>;
113
+ close(): void;
114
+ }
115
+
116
+ function connect(url: string): Promise<TestClient> {
117
+ const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
118
+ const socket = new WebSocket(`ws://${url}/ws`);
119
+ const events: Record<string, unknown>[] = [];
120
+ const buffered: Record<string, unknown>[] = [];
121
+ const waiters: {
122
+ predicate: (message: Record<string, unknown>) => boolean;
123
+ resolve: (message: Record<string, unknown>) => void;
124
+ }[] = [];
125
+
126
+ socket.onopen = () => {
127
+ resolve({
128
+ socket,
129
+ events,
130
+ send(command) {
131
+ socket.send(JSON.stringify(command));
132
+ },
133
+ waitFor(predicate) {
134
+ const index = buffered.findIndex(predicate);
135
+ if (index >= 0) {
136
+ const message = buffered.splice(index, 1)[0];
137
+ if (message !== undefined) return Promise.resolve(message);
138
+ }
139
+ const { promise: waiting, resolve: settle } =
140
+ Promise.withResolvers<Record<string, unknown>>();
141
+ waiters.push({ predicate, resolve: settle });
142
+ return waiting;
143
+ },
144
+ close() {
145
+ socket.close();
146
+ },
147
+ });
148
+ };
149
+
150
+ socket.onmessage = (event) => {
151
+ let message: Record<string, unknown>;
152
+ try {
153
+ message = JSON.parse(String(event.data)) as Record<string, unknown>;
154
+ } catch {
155
+ return;
156
+ }
157
+ events.push(message);
158
+ const index = waiters.findIndex((waiter) => waiter.predicate(message));
159
+ if (index >= 0) {
160
+ const waiter = waiters.splice(index, 1)[0];
161
+ if (waiter !== undefined) waiter.resolve(message);
162
+ } else {
163
+ buffered.push(message);
164
+ }
165
+ };
166
+
167
+ socket.onerror = () => reject(new Error("websocket failed"));
168
+
169
+ return promise;
170
+ }
171
+
172
+ function spawnCount(logPath: string): number {
173
+ if (!existsSync(logPath)) return 0;
174
+ return readFileSync(logPath, "utf8")
175
+ .split("\n")
176
+ .filter((line) => line.startsWith("spawn "))
177
+ .length;
178
+ }
179
+
180
+ function prompts(logPath: string): string[] {
181
+ if (!existsSync(logPath)) return [];
182
+ return readFileSync(logPath, "utf8")
183
+ .split("\n")
184
+ .filter((line) => line.startsWith("prompt "))
185
+ .map((line) => line.slice("prompt ".length));
186
+ }
187
+
188
+ function adrSnapshot(directory: string): [string, string][] {
189
+ return readdirSync(directory)
190
+ .sort()
191
+ .map((file) => [file, readFileSync(join(directory, file), "utf8")]);
192
+ }
193
+
194
+ async function eventually(predicate: () => boolean, timeout = 5000) {
195
+ const deadline = Date.now() + timeout;
196
+ while (!predicate() && Date.now() < deadline) await Bun.sleep(25);
197
+ expect(predicate()).toBe(true);
198
+ }
199
+
200
+ function runEvents(client: TestClient): Record<string, unknown>[] {
201
+ return client.events.filter(
202
+ (message) => message.type === "runStarted" || message.type === "runStopped",
203
+ );
204
+ }
205
+
206
+ function errors(client: TestClient): Record<string, unknown>[] {
207
+ return client.events.filter((message) => message.type === "error");
208
+ }
209
+
210
+ afterAll(async () => {
211
+ await Promise.all(
212
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
213
+ );
214
+ });
215
+
216
+ const PENDING_ONE = pendingRecord("0001-first.md", "First decision", "First.", "Choose the first.");
217
+ const PENDING_TWO = pendingRecord("0002-second.md", "Second decision", "Second.", "Choose the second.");
218
+ const PENDING_THREE =
219
+ pendingRecord("0003-third.md", "Third decision", "Third.", "Choose the third.");
220
+
221
+ describe("guided goal creation", () => {
222
+ test(
223
+ "starts /goal-outcome, relays activity and one question, and reports the written bytes",
224
+ async () => {
225
+ const { server, adrDir, goalFile, logPath, url } = await startServer(
226
+ { "0001-first.md": PENDING_ONE },
227
+ { agentArguments: "--delay-ms=200" },
228
+ );
229
+ const client = await connect(url);
230
+ const adrsBefore = adrSnapshot(adrDir);
231
+
232
+ try {
233
+ client.send({ type: "createGoal" });
234
+ expect(await client.waitFor((message) => message.type === "goalCreationStarted")).toEqual({
235
+ type: "goalCreationStarted",
236
+ });
237
+ expect(
238
+ await client.waitFor(
239
+ (message) => message.type === "activity" && message.text === "Creating the goal",
240
+ ),
241
+ ).toEqual({ type: "activity", text: "Creating the goal" });
242
+
243
+ const question = await client.waitFor((message) => message.type === "question");
244
+ expect(question).toMatchObject({
245
+ message: "Choose an answer",
246
+ options: QUESTION_OPTIONS,
247
+ });
248
+ expect(prompts(logPath)).toEqual(["/goal-outcome"]);
249
+ expect(spawnCount(logPath)).toBe(1);
250
+
251
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
252
+ client.send({ type: "answer", id: question.id, value: "Beta" });
253
+ const stopped = await client.waitFor((message) => message.type === "goalCreationStopped");
254
+ expect(stopped).toEqual({
255
+ type: "goalCreationStopped",
256
+ completed: true,
257
+ text: CREATED_GOAL,
258
+ });
259
+ expect(stopped.text).toBe(await readFile(goalFile, "utf8"));
260
+ expect(
261
+ client.events
262
+ .filter((message) => message.type === "activity")
263
+ .map((message) => message.text),
264
+ ).toEqual(["Creating the goal", "Answer received: Alpha", "Created the goal"]);
265
+
266
+ client.send({ type: "getGoal" });
267
+ expect(await client.waitFor((message) => message.type === "goal")).toEqual({
268
+ type: "goal",
269
+ text: CREATED_GOAL,
270
+ });
271
+
272
+ expect(errors(client)).toHaveLength(1);
273
+ expect(String(errors(client)[0]?.message)).toMatch(/question|run/);
274
+ expect(
275
+ readFileSync(logPath, "utf8")
276
+ .split("\n")
277
+ .filter((line) => line.startsWith("answer ")),
278
+ ).toEqual(["answer Alpha"]);
279
+ expect(runEvents(client)).toEqual([]);
280
+ expect(adrSnapshot(adrDir)).toEqual(adrsBefore);
281
+ expect(spawnCount(logPath)).toBe(1);
282
+ } finally {
283
+ client.close();
284
+ server.stop(true);
285
+ }
286
+ },
287
+ 20000,
288
+ );
289
+
290
+ test(
291
+ "does not spawn a peer when a client connects without asking for creation",
292
+ async () => {
293
+ const { server, logPath, url } = await startServer(
294
+ { "0001-first.md": PENDING_ONE },
295
+ { goalText: "" },
296
+ );
297
+ const client = await connect(url);
298
+
299
+ try {
300
+ client.send({ type: "listRecords" });
301
+ client.send({ type: "getGoal" });
302
+ expect(await client.waitFor((message) => message.type === "goal")).toEqual({
303
+ type: "goal",
304
+ text: "",
305
+ });
306
+
307
+ expect(spawnCount(logPath)).toBe(0);
308
+ expect(
309
+ client.events.filter(
310
+ (message) =>
311
+ message.type === "goalCreationStarted" || message.type === "goalCreationStopped",
312
+ ),
313
+ ).toEqual([]);
314
+ } finally {
315
+ client.close();
316
+ server.stop(true);
317
+ }
318
+ },
319
+ 20000,
320
+ );
321
+
322
+ test(
323
+ "replaces a non-empty goal with a new creation",
324
+ async () => {
325
+ const { server, goalFile, logPath, url } = await startServer(
326
+ { "0001-first.md": PENDING_ONE },
327
+ { goalText: "# Existing goal\n" },
328
+ );
329
+ const client = await connect(url);
330
+
331
+ try {
332
+ client.send({ type: "createGoal" });
333
+ await client.waitFor((message) => message.type === "goalCreationStarted");
334
+ const question = await client.waitFor((message) => message.type === "question");
335
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
336
+
337
+ expect(await client.waitFor((message) => message.type === "goalCreationStopped")).toEqual({
338
+ type: "goalCreationStopped",
339
+ completed: true,
340
+ text: CREATED_GOAL,
341
+ });
342
+ expect(await readFile(goalFile, "utf8")).toBe(CREATED_GOAL);
343
+ expect(spawnCount(logPath)).toBe(1);
344
+ expect(errors(client)).toEqual([]);
345
+ } finally {
346
+ client.close();
347
+ server.stop(true);
348
+ }
349
+ },
350
+ 20000,
351
+ );
352
+
353
+ test(
354
+ "reports a halt that leaves the previous goal as an incomplete creation",
355
+ async () => {
356
+ const existing = "# Existing goal\n\nDo the old thing.\n";
357
+ const { server, goalFile, logPath, url } = await startServer(
358
+ { "0001-first.md": PENDING_ONE },
359
+ { goalText: existing, writeStubGoal: false },
360
+ );
361
+ const client = await connect(url);
362
+
363
+ try {
364
+ client.send({ type: "createGoal" });
365
+ await client.waitFor((message) => message.type === "goalCreationStarted");
366
+ const question = await client.waitFor((message) => message.type === "question");
367
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
368
+
369
+ const error = await client.waitFor((message) => message.type === "error");
370
+ expect(error.message).toBe("The goal creation ended without writing a new goal.");
371
+ const stopped = await client.waitFor((message) => message.type === "goalCreationStopped");
372
+ expect(stopped).toEqual({ type: "goalCreationStopped", completed: false });
373
+ expect("text" in stopped).toBe(false);
374
+ expect(await readFile(goalFile, "utf8")).toBe(existing);
375
+ expect(spawnCount(logPath)).toBe(1);
376
+
377
+ // The unchanged document stays what getGoal reports.
378
+ client.send({ type: "getGoal" });
379
+ expect(await client.waitFor((message) => message.type === "goal")).toEqual({
380
+ type: "goal",
381
+ text: existing,
382
+ });
383
+
384
+ // The control stays available: another creation starts a new peer.
385
+ client.send({ type: "createGoal" });
386
+ await client.waitFor((message) => message.type === "goalCreationStarted");
387
+ await eventually(() => spawnCount(logPath) === 2);
388
+ } finally {
389
+ client.close();
390
+ server.stop(true);
391
+ }
392
+ },
393
+ 20000,
394
+ );
395
+
396
+ test(
397
+ "refuses creation when the goal document exists but cannot be read",
398
+ async () => {
399
+ const { server, root, logPath, url } = await startServer({
400
+ "0001-first.md": PENDING_ONE,
401
+ });
402
+ await mkdir(join(root, "GOAL.md"));
403
+ const client = await connect(url);
404
+
405
+ try {
406
+ client.send({ type: "createGoal" });
407
+ const error = await client.waitFor((message) => message.type === "error");
408
+ expect(String(error.message)).toContain("Could not read the goal");
409
+ expect(spawnCount(logPath)).toBe(0);
410
+ expect(
411
+ client.events.filter((message) => message.type === "goalCreationStarted"),
412
+ ).toEqual([]);
413
+ } finally {
414
+ client.close();
415
+ server.stop(true);
416
+ }
417
+ },
418
+ 20000,
419
+ );
420
+
421
+ test(
422
+ "stops an advancing lifecycle and starts creation",
423
+ async () => {
424
+ const epic: OpenEpic = {
425
+ number: 42,
426
+ title: "Active epic",
427
+ url: "https://example.test/issues/42",
428
+ };
429
+ const { server, goalFile, logPath, url } = await startServer(
430
+ { "0001-done.md": fixtureRecord("0001-done.md", "Done") },
431
+ {
432
+ agentArguments: "--delay-ms=1200",
433
+ goalText: "# Active goal\n",
434
+ tracker: async () => epic,
435
+ },
436
+ );
437
+ const client = await connect(url);
438
+
439
+ try {
440
+ client.send({ type: "startLifecycle" });
441
+ await client.waitFor(
442
+ (message) => message.type === "lifecycleState" && message.stage === "implementation",
443
+ );
444
+ await client.waitFor((message) => message.type === "runStarted");
445
+ await eventually(() => spawnCount(logPath) === 1);
446
+
447
+ client.send({ type: "createGoal" });
448
+ expect(
449
+ await client.waitFor(
450
+ (message) =>
451
+ message.type === "laneState" &&
452
+ Array.isArray(message.lanes) &&
453
+ message.lanes.length === 0,
454
+ ),
455
+ ).toMatchObject({ runningLanes: 0 });
456
+ await client.waitFor(
457
+ (message) => message.type === "lifecycleState" && message.stage === "goal",
458
+ );
459
+ await client.waitFor((message) => message.type === "goalCreationStarted");
460
+ await eventually(() => spawnCount(logPath) === 2);
461
+
462
+ const question = await client.waitFor((message) => message.type === "question");
463
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
464
+ expect(await client.waitFor((message) => message.type === "goalCreationStopped")).toEqual({
465
+ type: "goalCreationStopped",
466
+ completed: true,
467
+ text: CREATED_GOAL,
468
+ });
469
+ expect(await readFile(goalFile, "utf8")).toBe(CREATED_GOAL);
470
+ } finally {
471
+ client.close();
472
+ server.stop(true);
473
+ }
474
+ },
475
+ 20000,
476
+ );
477
+
478
+ test(
479
+ "stops an active ADR run and starts creation",
480
+ async () => {
481
+ const { server, goalFile, logPath, url } = await startServer(
482
+ { "0001-first.md": PENDING_ONE, "0002-second.md": PENDING_TWO },
483
+ { agentArguments: "--delay-ms=1200 --questions=0" },
484
+ );
485
+ const client = await connect(url);
486
+
487
+ try {
488
+ client.send({ type: "startRun" });
489
+ await client.waitFor(
490
+ (message) => message.type === "runStarted" && message.file === "0001-first.md",
491
+ );
492
+ await eventually(() => spawnCount(logPath) === 1);
493
+
494
+ client.send({ type: "createGoal" });
495
+ expect(await client.waitFor((message) => message.type === "runStopped")).toMatchObject({
496
+ file: "0001-first.md",
497
+ resolved: false,
498
+ });
499
+ await client.waitFor((message) => message.type === "goalCreationStarted");
500
+ await eventually(() => spawnCount(logPath) === 2);
501
+
502
+ const question = await client.waitFor((message) => message.type === "question");
503
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
504
+ expect(await client.waitFor((message) => message.type === "goalCreationStopped")).toEqual({
505
+ type: "goalCreationStopped",
506
+ completed: true,
507
+ text: CREATED_GOAL,
508
+ });
509
+ expect(await readFile(goalFile, "utf8")).toBe(CREATED_GOAL);
510
+ } finally {
511
+ client.close();
512
+ server.stop(true);
513
+ }
514
+ },
515
+ 20000,
516
+ );
517
+
518
+ test(
519
+ "ends the ADR sequence when creation preempts it",
520
+ async () => {
521
+ const { server, goalFile, logPath, url } = await startServer(
522
+ {
523
+ "0001-first.md": PENDING_ONE,
524
+ "0002-second.md": PENDING_TWO,
525
+ "0003-third.md": PENDING_THREE,
526
+ },
527
+ { agentArguments: "--questions=0 --delay-ms=500" },
528
+ );
529
+ const client = await connect(url);
530
+
531
+ try {
532
+ client.send({ type: "startRun" });
533
+ await client.waitFor(
534
+ (message) => message.type === "runStarted" && message.file === "0002-second.md",
535
+ );
536
+
537
+ client.send({ type: "createGoal" });
538
+ expect(
539
+ await client.waitFor(
540
+ (message) => message.type === "runStopped" && message.file === "0002-second.md",
541
+ ),
542
+ ).toMatchObject({
543
+ file: "0002-second.md",
544
+ resolved: false,
545
+ });
546
+ await client.waitFor((message) => message.type === "goalCreationStarted");
547
+ await eventually(() => prompts(logPath).includes("/goal-outcome"));
548
+
549
+ const question = await client.waitFor((message) => message.type === "question");
550
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
551
+ expect(await client.waitFor((message) => message.type === "goalCreationStopped")).toEqual({
552
+ type: "goalCreationStopped",
553
+ completed: true,
554
+ text: CREATED_GOAL,
555
+ });
556
+ expect(await readFile(goalFile, "utf8")).toBe(CREATED_GOAL);
557
+ expect(
558
+ client.events.filter(
559
+ (message) => message.type === "runStarted" && message.file === "0003-third.md",
560
+ ),
561
+ ).toEqual([]);
562
+ } finally {
563
+ client.close();
564
+ server.stop(true);
565
+ }
566
+ },
567
+ 20000,
568
+ );
569
+
570
+ test(
571
+ "stops an active revision and starts creation",
572
+ async () => {
573
+ const { server, goalFile, logPath, url } = await startServer(
574
+ { "0001-first.md": PENDING_ONE },
575
+ { mode: "revision", agentArguments: "--delay-ms=1200" },
576
+ );
577
+ const client = await connect(url);
578
+
579
+ try {
580
+ client.send({ type: "reviseGoal" });
581
+ await client.waitFor((message) => message.type === "goalRevisionStarted");
582
+ await eventually(() => spawnCount(logPath) === 1);
583
+
584
+ client.send({ type: "createGoal" });
585
+ expect(
586
+ await client.waitFor((message) => message.type === "goalRevisionStopped"),
587
+ ).toEqual({ type: "goalRevisionStopped", completed: false });
588
+ await client.waitFor((message) => message.type === "goalCreationStarted");
589
+ await eventually(() => spawnCount(logPath) === 2);
590
+
591
+ const question = await client.waitFor((message) => message.type === "question");
592
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
593
+ expect(await client.waitFor((message) => message.type === "goalCreationStopped")).toEqual({
594
+ type: "goalCreationStopped",
595
+ completed: true,
596
+ text: CREATED_GOAL,
597
+ });
598
+ expect(await readFile(goalFile, "utf8")).toBe(CREATED_GOAL);
599
+ } finally {
600
+ client.close();
601
+ server.stop(true);
602
+ }
603
+ },
604
+ 20000,
605
+ );
606
+
607
+ test(
608
+ "refuses other starts while creating and restarts the creation",
609
+ async () => {
610
+ const { server, goalFile, logPath, url } = await startServer(
611
+ { "0001-first.md": PENDING_ONE },
612
+ { agentArguments: "--delay-ms=600" },
613
+ );
614
+ const client = await connect(url);
615
+
616
+ try {
617
+ client.send({ type: "createGoal" });
618
+ await client.waitFor((message) => message.type === "goalCreationStarted");
619
+ await client.waitFor((message) => message.type === "question");
620
+
621
+ client.send({ type: "startRun" });
622
+ client.send({ type: "startLifecycle" });
623
+ client.send({ type: "resumeLifecycle" });
624
+ client.send({ type: "reviseGoal" });
625
+
626
+ for (let index = 0; index < 4; index += 1) {
627
+ await client.waitFor(
628
+ (message) =>
629
+ message.type === "error" && message.message === "A goal creation is active.",
630
+ );
631
+ }
632
+
633
+ expect(spawnCount(logPath)).toBe(1);
634
+ expect(runEvents(client)).toEqual([]);
635
+ expect(
636
+ client.events.filter((message) => message.type === "lifecycleState"),
637
+ ).toEqual([]);
638
+
639
+ client.send({ type: "createGoal" });
640
+ expect(
641
+ await client.waitFor((message) => message.type === "goalCreationStopped"),
642
+ ).toEqual({ type: "goalCreationStopped", completed: false });
643
+ await client.waitFor((message) => message.type === "goalCreationStarted");
644
+ await eventually(() => spawnCount(logPath) === 2);
645
+
646
+ const restarted = await client.waitFor((message) => message.type === "question");
647
+ client.send({ type: "answer", id: restarted.id, value: "Alpha" });
648
+ expect(await client.waitFor((message) => message.type === "goalCreationStopped")).toEqual({
649
+ type: "goalCreationStopped",
650
+ completed: true,
651
+ text: CREATED_GOAL,
652
+ });
653
+ expect(await readFile(goalFile, "utf8")).toBe(CREATED_GOAL);
654
+ } finally {
655
+ client.close();
656
+ server.stop(true);
657
+ }
658
+ },
659
+ 20000,
660
+ );
661
+
662
+ test(
663
+ "replays the creation start, activity so far, and pending question to a late client",
664
+ async () => {
665
+ const { server, goalFile, url } = await startServer(
666
+ { "0001-first.md": PENDING_ONE },
667
+ { agentArguments: "--delay-ms=600" },
668
+ );
669
+ const client = await connect(url);
670
+
671
+ try {
672
+ client.send({ type: "createGoal" });
673
+ await client.waitFor((message) => message.type === "goalCreationStarted");
674
+ await client.waitFor(
675
+ (message) => message.type === "activity" && message.text === "Creating the goal",
676
+ );
677
+ await client.waitFor((message) => message.type === "question");
678
+
679
+ const late = await connect(url);
680
+ try {
681
+ // The replay must precede the reply to the late client's own command.
682
+ late.send({ type: "getGoal" });
683
+ await eventually(() =>
684
+ late.events.some((message) => message.type === "goal"),
685
+ );
686
+ expect(late.events[0]).toEqual({ type: "goalCreationStarted" });
687
+ expect(late.events[1]).toEqual({ type: "activity", text: "Creating the goal" });
688
+ expect(late.events[2]).toMatchObject({
689
+ type: "question",
690
+ message: "Choose an answer",
691
+ options: QUESTION_OPTIONS,
692
+ });
693
+ expect(late.events.findIndex((message) => message.type === "goal")).toBeGreaterThan(2);
694
+
695
+ const question = late.events[2];
696
+ if (question === undefined) throw new Error("the creation question was not replayed");
697
+ late.send({ type: "answer", id: question.id, value: "Alpha" });
698
+ expect(await client.waitFor((message) => message.type === "goalCreationStopped")).toEqual({
699
+ type: "goalCreationStopped",
700
+ completed: true,
701
+ text: CREATED_GOAL,
702
+ });
703
+ expect(await readFile(goalFile, "utf8")).toBe(CREATED_GOAL);
704
+ } finally {
705
+ late.close();
706
+ }
707
+ } finally {
708
+ client.close();
709
+ server.stop(true);
710
+ }
711
+ },
712
+ 20000,
713
+ );
714
+
715
+ test(
716
+ "reports an incomplete creation when the peer leaves no readable goal",
717
+ async () => {
718
+ const { server, logPath, url } = await startServer(
719
+ { "0001-first.md": PENDING_ONE },
720
+ { writeStubGoal: false },
721
+ );
722
+ const client = await connect(url);
723
+
724
+ try {
725
+ client.send({ type: "createGoal" });
726
+ await client.waitFor((message) => message.type === "goalCreationStarted");
727
+ const question = await client.waitFor((message) => message.type === "question");
728
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
729
+
730
+ const error = await client.waitFor((message) => message.type === "error");
731
+ expect(String(error.message)).toContain("Could not read the created goal");
732
+ const stopped = await client.waitFor((message) => message.type === "goalCreationStopped");
733
+ expect(stopped).toEqual({ type: "goalCreationStopped", completed: false });
734
+ expect("text" in stopped).toBe(false);
735
+
736
+ client.send({ type: "startRun" });
737
+ expect(await client.waitFor((message) => message.type === "runStarted")).toMatchObject({
738
+ file: "0001-first.md",
739
+ });
740
+ await eventually(() => spawnCount(logPath) === 2);
741
+ } finally {
742
+ client.close();
743
+ server.stop(true);
744
+ }
745
+ },
746
+ 20000,
747
+ );
748
+
749
+ test(
750
+ "reports an absent goal document as an empty goal",
751
+ async () => {
752
+ const { server, url } = await startServer({ "0001-first.md": PENDING_ONE });
753
+ const client = await connect(url);
754
+
755
+ try {
756
+ client.send({ type: "getGoal" });
757
+ expect(await client.waitFor((message) => message.type === "goal")).toEqual({
758
+ type: "goal",
759
+ text: "",
760
+ });
761
+ expect(errors(client)).toEqual([]);
762
+ } finally {
763
+ client.close();
764
+ server.stop(true);
765
+ }
766
+ },
767
+ 20000,
768
+ );
769
+
770
+ test.each([
771
+ ["empty", ""],
772
+ ["whitespace-only", " \n\t\n"],
773
+ ])(
774
+ "reports a %s goal left by the peer as a failed creation",
775
+ async (_kind, goalText) => {
776
+ const { server, goalFile, logPath, url } = await startServer(
777
+ { "0001-first.md": PENDING_ONE },
778
+ { goalText, agentArguments: "--no-create-goal-write" },
779
+ );
780
+ const client = await connect(url);
781
+
782
+ try {
783
+ client.send({ type: "createGoal" });
784
+ await client.waitFor((message) => message.type === "goalCreationStarted");
785
+ const question = await client.waitFor((message) => message.type === "question");
786
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
787
+
788
+ const error = await client.waitFor((message) => message.type === "error");
789
+ expect(error.message).toBe("The goal creation ended without a non-empty goal.");
790
+ const stopped = await client.waitFor((message) => message.type === "goalCreationStopped");
791
+ expect(stopped).toEqual({ type: "goalCreationStopped", completed: false });
792
+ expect("text" in stopped).toBe(false);
793
+ expect(await readFile(goalFile, "utf8")).toBe(goalText);
794
+
795
+ // The control stays available: another creation starts a new peer.
796
+ client.send({ type: "createGoal" });
797
+ expect(await client.waitFor((message) => message.type === "goalCreationStarted")).toEqual({
798
+ type: "goalCreationStarted",
799
+ });
800
+ await eventually(() => spawnCount(logPath) === 2);
801
+ } finally {
802
+ client.close();
803
+ server.stop(true);
804
+ }
805
+ },
806
+ 20000,
807
+ );
808
+
809
+ test(
810
+ "creates the goal without reading or writing adrs/",
811
+ async () => {
812
+ const { server, adrDir, goalFile, url } = await startServer({
813
+ "0001-first.md": PENDING_ONE,
814
+ });
815
+ await rm(adrDir, { recursive: true, force: true });
816
+ await writeFile(adrDir, "not a directory");
817
+ const client = await connect(url);
818
+
819
+ try {
820
+ client.send({ type: "createGoal" });
821
+ await client.waitFor((message) => message.type === "goalCreationStarted");
822
+ const question = await client.waitFor((message) => message.type === "question");
823
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
824
+
825
+ expect(await client.waitFor((message) => message.type === "goalCreationStopped")).toEqual({
826
+ type: "goalCreationStopped",
827
+ completed: true,
828
+ text: CREATED_GOAL,
829
+ });
830
+ expect(await readFile(goalFile, "utf8")).toBe(CREATED_GOAL);
831
+ expect(await readFile(adrDir, "utf8")).toBe("not a directory");
832
+ expect(runEvents(client)).toEqual([]);
833
+ } finally {
834
+ client.close();
835
+ server.stop(true);
836
+ }
837
+ },
838
+ 20000,
839
+ );
840
+
841
+ test(
842
+ "accepts creation while the lifecycle is paused and replays the suspended conversation",
843
+ async () => {
844
+ const epic: OpenEpic = {
845
+ number: 42,
846
+ title: "Active epic",
847
+ url: "https://example.test/issues/42",
848
+ };
849
+ const { server, goalFile, url } = await startServer(
850
+ { "0001-done.md": fixtureRecord("0001-done.md", "Done") },
851
+ {
852
+ agentArguments: "--questions=1",
853
+ goalText: "# Active goal\n",
854
+ tracker: async () => epic,
855
+ },
856
+ );
857
+ const client = await connect(url);
858
+
859
+ try {
860
+ client.send({ type: "startLifecycle" });
861
+ await client.waitFor(
862
+ (message) => message.type === "lifecycleState" && message.stage === "implementation",
863
+ );
864
+ await client.waitFor((message) => message.type === "question");
865
+ client.send({ type: "pauseLifecycle" });
866
+ await client.waitFor(
867
+ (message) => message.type === "lifecycleState" && message.paused === true,
868
+ );
869
+
870
+ client.send({ type: "saveGoal", text: "" });
871
+ await client.waitFor((message) => message.type === "goal" && message.text === "");
872
+
873
+ client.send({ type: "createGoal" });
874
+ expect(await client.waitFor((message) => message.type === "goalCreationStarted")).toEqual({
875
+ type: "goalCreationStarted",
876
+ });
877
+ const creationQuestion = await client.waitFor(
878
+ (message) => message.type === "question" && message.message === "Choose an answer",
879
+ );
880
+ client.send({ type: "answer", id: creationQuestion.id, value: "Alpha" });
881
+
882
+ const stopped = await client.waitFor((message) => message.type === "goalCreationStopped");
883
+ expect(stopped).toEqual({
884
+ type: "goalCreationStopped",
885
+ completed: true,
886
+ text: CREATED_GOAL,
887
+ });
888
+ expect(await readFile(goalFile, "utf8")).toBe(CREATED_GOAL);
889
+
890
+ const after = client.events.slice(client.events.indexOf(stopped) + 1);
891
+ expect(after.filter((message) => message.type === "runStarted")).toEqual([]);
892
+ expect(
893
+ [...client.events].reverse().find((message) => message.type === "laneState"),
894
+ ).toMatchObject({
895
+ lanes: [
896
+ {
897
+ kind: "implementation",
898
+ status: "paused",
899
+ activity: ["Working on epic 42"],
900
+ question: { message: "Choose an answer" },
901
+ },
902
+ ],
903
+ });
904
+ } finally {
905
+ client.close();
906
+ server.stop(true);
907
+ }
908
+ },
909
+ 20000,
910
+ );
911
+
912
+ test(
913
+ "writes <repoDir>/GOAL.md while getGoal and saveGoal keep using the configured GOAL_FILE",
914
+ async () => {
915
+ const { server, root, logPath, url } = await startServer(
916
+ { "0001-first.md": PENDING_ONE },
917
+ { goalFileName: "custom.md" },
918
+ );
919
+ const customGoal = join(root, "custom.md");
920
+ await writeFile(customGoal, "# Configured goal\n");
921
+ const client = await connect(url);
922
+
923
+ try {
924
+ client.send({ type: "getGoal" });
925
+ expect(await client.waitFor((message) => message.type === "goal")).toEqual({
926
+ type: "goal",
927
+ text: "# Configured goal\n",
928
+ });
929
+
930
+ client.send({ type: "createGoal" });
931
+ await client.waitFor((message) => message.type === "goalCreationStarted");
932
+ const question = await client.waitFor((message) => message.type === "question");
933
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
934
+
935
+ expect(await client.waitFor((message) => message.type === "goalCreationStopped")).toEqual({
936
+ type: "goalCreationStopped",
937
+ completed: true,
938
+ text: CREATED_GOAL,
939
+ });
940
+ expect(await readFile(join(root, "GOAL.md"), "utf8")).toBe(CREATED_GOAL);
941
+ expect(await readFile(customGoal, "utf8")).toBe("# Configured goal\n");
942
+
943
+ client.send({ type: "getGoal" });
944
+ expect(await client.waitFor((message) => message.type === "goal")).toEqual({
945
+ type: "goal",
946
+ text: "# Configured goal\n",
947
+ });
948
+ expect(prompts(logPath)).toEqual(["/goal-outcome"]);
949
+ expect(spawnCount(logPath)).toBe(1);
950
+ } finally {
951
+ client.close();
952
+ server.stop(true);
953
+ }
954
+ },
955
+ 20000,
956
+ );
957
+ });