@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,611 @@
1
+ import { fixtureRecord, frontmatterStatus, pendingRecord } from "./fixtures/adr-source";
2
+ import { afterAll, describe, expect, test } from "bun:test";
3
+ import { existsSync, readFileSync } 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
+
10
+ const temporaryDirectories: string[] = [];
11
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
12
+ const QUESTION_OPTIONS = [
13
+ { label: "Alpha", description: "Alpha leads to the first outcome.", recommended: true },
14
+ { label: "Beta", description: "Beta leads to the second outcome.", recommended: false },
15
+ ];
16
+
17
+ interface TestServer {
18
+ server: Server<unknown>;
19
+ adrDir: string;
20
+ logPath: string;
21
+ url: string;
22
+ }
23
+
24
+ async function temporaryDirectory() {
25
+ const directory = await mkdtemp(join(tmpdir(), "devboard-run-"));
26
+ temporaryDirectories.push(directory);
27
+ return directory;
28
+ }
29
+
30
+ async function startServer(
31
+ files: Record<string, string>,
32
+ agentArguments = "",
33
+ options: { restart?: () => void } = {},
34
+ ): Promise<TestServer> {
35
+ const root = await temporaryDirectory();
36
+ const adrDir = join(root, "adrs");
37
+ const staticDir = join(root, "static");
38
+ await mkdir(adrDir, { recursive: true });
39
+ await mkdir(staticDir, { recursive: true });
40
+ await Bun.write(join(adrDir, ".keep"), "");
41
+ await writeFile(join(staticDir, "index.html"), "<!doctype html><title>ADR overview</title>");
42
+ for (const [file, source] of Object.entries(files)) {
43
+ await writeFile(join(adrDir, file), source);
44
+ }
45
+
46
+ const logPath = join(root, "agent.log");
47
+ const server = createApp({
48
+ adrDir,
49
+ staticDir,
50
+ hostname: "127.0.0.1",
51
+ port: 0,
52
+ repoDir: root,
53
+ stateDir: join(root, "state"),
54
+ agentCommand: `bun ${STUB_AGENT} --log=${logPath} ${agentArguments}`.trim(),
55
+ restart: options.restart,
56
+ });
57
+
58
+ return { server, adrDir, logPath, url: `127.0.0.1:${server.port}` };
59
+ }
60
+
61
+ interface TestClient {
62
+ socket: WebSocket;
63
+ events: Record<string, unknown>[];
64
+ send(command: unknown): void;
65
+ waitFor(predicate: (message: Record<string, unknown>) => boolean): Promise<Record<string, unknown>>;
66
+ close(): void;
67
+ }
68
+
69
+ function connect(url: string): Promise<TestClient> {
70
+ const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
71
+ const socket = new WebSocket(`ws://${url}/ws`);
72
+ const events: Record<string, unknown>[] = [];
73
+ const buffered: Record<string, unknown>[] = [];
74
+ const waiters: {
75
+ predicate: (message: Record<string, unknown>) => boolean;
76
+ resolve: (message: Record<string, unknown>) => void;
77
+ reject: (error: Error) => void;
78
+ }[] = [];
79
+
80
+ socket.onopen = () => {
81
+ resolve({
82
+ socket,
83
+ events,
84
+ send(command) {
85
+ socket.send(JSON.stringify(command));
86
+ },
87
+ waitFor(predicate) {
88
+ const index = buffered.findIndex(predicate);
89
+ if (index >= 0) {
90
+ const message = buffered.splice(index, 1)[0];
91
+ if (message !== undefined) return Promise.resolve(message);
92
+ }
93
+ const { promise: waiting, resolve: settle, reject: fail } =
94
+ Promise.withResolvers<Record<string, unknown>>();
95
+ waiters.push({ predicate, resolve: settle, reject: fail });
96
+ return waiting;
97
+ },
98
+ close() {
99
+ socket.close();
100
+ },
101
+ });
102
+ };
103
+
104
+ socket.onmessage = (event) => {
105
+ let message: Record<string, unknown>;
106
+ try {
107
+ message = JSON.parse(String(event.data)) as Record<string, unknown>;
108
+ } catch {
109
+ return;
110
+ }
111
+ events.push(message);
112
+ const index = waiters.findIndex((waiter) => waiter.predicate(message));
113
+ if (index >= 0) {
114
+ const waiter = waiters.splice(index, 1)[0];
115
+ if (waiter !== undefined) waiter.resolve(message);
116
+ } else {
117
+ buffered.push(message);
118
+ }
119
+ };
120
+
121
+ socket.onerror = () => reject(new Error("websocket failed"));
122
+ socket.onclose = () => {
123
+ for (const waiter of waiters.splice(0)) {
124
+ waiter.reject(new Error("websocket closed"));
125
+ }
126
+ };
127
+
128
+ return promise;
129
+ }
130
+
131
+ function spawnCount(logPath: string): number {
132
+ if (!existsSync(logPath)) return 0;
133
+ return readFileSync(logPath, "utf8")
134
+ .split("\n")
135
+ .filter((line) => line.startsWith("spawn ")).length;
136
+ }
137
+
138
+ async function waitForStop(client: TestClient) {
139
+ return await client.waitFor((message) => message.type === "runStopped");
140
+ }
141
+
142
+ function runSequence(client: TestClient): string[] {
143
+ return client.events
144
+ .filter(
145
+ (message) =>
146
+ message.type === "runStarted" ||
147
+ message.type === "runStopped" ||
148
+ message.type === "records",
149
+ )
150
+ .map((message) =>
151
+ message.type === "records"
152
+ ? `records:${String(message.next)}`
153
+ : `${String(message.type)}:${String(message.file)}:${String(message.resolved ?? "")}`,
154
+ );
155
+ }
156
+
157
+ function starts(client: TestClient): unknown[] {
158
+ return client.events.filter((message) => message.type === "runStarted").map((message) => message.file);
159
+ }
160
+
161
+ afterAll(async () => {
162
+ await Promise.all(
163
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
164
+ );
165
+ });
166
+
167
+ const PENDING_ONE = pendingRecord("0001-first.md", "First decision", "First.", "Choose the first.");
168
+ const PENDING_TWO = pendingRecord("0002-second.md", "Second decision", "Second.", "Choose the second.");
169
+ const PENDING_THREE = pendingRecord("0003-third.md", "Third decision", "Third.", "Choose the third.");
170
+
171
+ describe("agent runs", () => {
172
+ test(
173
+ "resolves every pending ADR in file-name order from one start",
174
+ async () => {
175
+ const { server, adrDir, logPath, url } = await startServer(
176
+ {
177
+ "0001-first.md": PENDING_ONE,
178
+ "0002-second.md": PENDING_TWO,
179
+ "0003-third.md": PENDING_THREE,
180
+ },
181
+ "--questions=0",
182
+ );
183
+ const client = await connect(url);
184
+
185
+ try {
186
+ client.send({ type: "startRun" });
187
+ expect(
188
+ await client.waitFor(
189
+ (message) => message.type === "records" && message.next === null,
190
+ ),
191
+ ).toMatchObject({
192
+ next: null,
193
+ records: [
194
+ { file: "0001-first.md", pending: false },
195
+ { file: "0002-second.md", pending: false },
196
+ { file: "0003-third.md", pending: false },
197
+ ],
198
+ });
199
+
200
+ expect(runSequence(client)).toEqual([
201
+ "runStarted:0001-first.md:",
202
+ "runStopped:0001-first.md:true",
203
+ "records:0002-second.md",
204
+ "runStarted:0002-second.md:",
205
+ "runStopped:0002-second.md:true",
206
+ "records:0003-third.md",
207
+ "runStarted:0003-third.md:",
208
+ "runStopped:0003-third.md:true",
209
+ "records:null",
210
+ ]);
211
+
212
+ expect(
213
+ client.events
214
+ .filter((message) => message.type === "runStopped")
215
+ .map((message) => message.status),
216
+ ).toEqual(["Accepted", "Accepted", "Accepted"]);
217
+ expect(
218
+ client.events
219
+ .filter((message) => message.type === "activity")
220
+ .map((message) => message.text),
221
+ ).toEqual([
222
+ "Inspecting 0001-first.md",
223
+ "Updated 0001-first.md",
224
+ "Inspecting 0002-second.md",
225
+ "Updated 0002-second.md",
226
+ "Inspecting 0003-third.md",
227
+ "Updated 0003-third.md",
228
+ ]);
229
+ expect(spawnCount(logPath)).toBe(3);
230
+
231
+ for (const file of ["0001-first.md", "0002-second.md", "0003-third.md"]) {
232
+ expect(frontmatterStatus(await readFile(join(adrDir, file), "utf8"))).toBe("accepted");
233
+ }
234
+ } finally {
235
+ client.close();
236
+ server.stop(true);
237
+ }
238
+ },
239
+ 20000,
240
+ );
241
+
242
+ test(
243
+ "defers an idle restart until the active run ends",
244
+ async () => {
245
+ let restarts = 0;
246
+ const { server, url } = await startServer(
247
+ { "0001-first.md": PENDING_ONE },
248
+ "--delay-ms=800 --questions=0",
249
+ { restart: () => (restarts += 1) },
250
+ );
251
+ const client = await connect(url);
252
+
253
+ try {
254
+ client.send({ type: "startRun", file: "0001-first.md" });
255
+ await client.waitFor((message) => message.type === "runStarted");
256
+
257
+ client.send({ type: "restartServer", whenIdle: true });
258
+ expect(await client.waitFor((message) => message.type === "upgradePending")).toEqual({
259
+ type: "upgradePending",
260
+ });
261
+ expect(restarts).toBe(0);
262
+
263
+ await client.waitFor(
264
+ (message) => message.type === "runStopped" && message.resolved === true,
265
+ );
266
+ expect(await client.waitFor((message) => message.type === "serverRestarting")).toEqual({
267
+ type: "serverRestarting",
268
+ });
269
+ expect(restarts).toBe(1);
270
+ } finally {
271
+ client.close();
272
+ server.stop(true);
273
+ }
274
+ },
275
+ 20000,
276
+ );
277
+
278
+ test(
279
+ "starts the selected ADR, then the lowest remaining pending records",
280
+ async () => {
281
+ const { server, logPath, url } = await startServer(
282
+ {
283
+ "0001-first.md": PENDING_ONE,
284
+ "0002-second.md": PENDING_TWO,
285
+ "0003-third.md": PENDING_THREE,
286
+ },
287
+ "--questions=0",
288
+ );
289
+ const client = await connect(url);
290
+
291
+ try {
292
+ client.send({ type: "startRun", file: "0002-second.md" });
293
+ expect(
294
+ await client.waitFor(
295
+ (message) => message.type === "records" && message.next === null,
296
+ ),
297
+ ).toMatchObject({ next: null });
298
+
299
+ expect(starts(client)).toEqual([
300
+ "0002-second.md",
301
+ "0001-first.md",
302
+ "0003-third.md",
303
+ ]);
304
+ expect(spawnCount(logPath)).toBe(3);
305
+ } finally {
306
+ client.close();
307
+ server.stop(true);
308
+ }
309
+ },
310
+ 20000,
311
+ );
312
+
313
+ test(
314
+ "refuses a second start during a run and between runs, spawning one process per record",
315
+ async () => {
316
+ const { server, logPath, url } = await startServer(
317
+ {
318
+ "0001-first.md": PENDING_ONE,
319
+ "0002-second.md": PENDING_TWO,
320
+ "0003-third.md": PENDING_THREE,
321
+ },
322
+ "--questions=0",
323
+ );
324
+ const client = await connect(url);
325
+
326
+ try {
327
+ client.send({ type: "startRun" });
328
+ await client.waitFor(
329
+ (message) => message.type === "runStarted" && message.file === "0001-first.md",
330
+ );
331
+
332
+ client.send({ type: "startRun" });
333
+ expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
334
+ message: "A run is already active.",
335
+ });
336
+
337
+ expect(await waitForStop(client)).toMatchObject({
338
+ file: "0001-first.md",
339
+ resolved: true,
340
+ });
341
+
342
+ client.send({ type: "startRun" });
343
+ expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
344
+ message: "A run is already active.",
345
+ });
346
+
347
+ expect(
348
+ await client.waitFor(
349
+ (message) => message.type === "records" && message.next === null,
350
+ ),
351
+ ).toMatchObject({ next: null });
352
+
353
+ expect(
354
+ client.events
355
+ .filter((message) => message.type === "error")
356
+ .map((message) => message.message),
357
+ ).toEqual(["A run is already active.", "A run is already active."]);
358
+ expect(starts(client)).toEqual([
359
+ "0001-first.md",
360
+ "0002-second.md",
361
+ "0003-third.md",
362
+ ]);
363
+ expect(spawnCount(logPath)).toBe(3);
364
+ expect(
365
+ readFileSync(logPath, "utf8")
366
+ .split("\n")
367
+ .filter((line) => line.startsWith("prompt ")).length,
368
+ ).toBe(3);
369
+ } finally {
370
+ client.close();
371
+ server.stop(true);
372
+ }
373
+ },
374
+ 20000,
375
+ );
376
+
377
+ test(
378
+ "relays each question and sends exactly one answer per elicitation",
379
+ async () => {
380
+ const { server, logPath, url } = await startServer(
381
+ { "0001-first.md": PENDING_ONE },
382
+ "--questions=2",
383
+ );
384
+ const client = await connect(url);
385
+
386
+ try {
387
+ client.send({ type: "startRun" });
388
+ await client.waitFor((message) => message.type === "runStarted");
389
+
390
+ const first = await client.waitFor((message) => message.type === "question");
391
+ expect(first).toMatchObject({
392
+ message: "Choose an answer",
393
+ options: QUESTION_OPTIONS,
394
+ });
395
+
396
+ client.send({ type: "answer", id: first.id, value: "Gamma" });
397
+ client.send({ type: "answer", id: first.id, value: "Gamma" });
398
+
399
+ expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
400
+ message: "No pending question with that id.",
401
+ });
402
+ await client.waitFor(
403
+ (message) => message.type === "activity" && message.text === "Answer received: Gamma",
404
+ );
405
+
406
+ const second = await client.waitFor((message) => message.type === "question");
407
+ expect(second).toMatchObject({
408
+ message: "Choose an answer 2",
409
+ options: QUESTION_OPTIONS,
410
+ });
411
+
412
+ client.send({ type: "answer", id: second.id, value: "Beta" });
413
+ await client.waitFor(
414
+ (message) => message.type === "activity" && message.text === "Answer received: Beta",
415
+ );
416
+ expect(await waitForStop(client)).toMatchObject({ resolved: true });
417
+
418
+ const answers = readFileSync(logPath, "utf8")
419
+ .split("\n")
420
+ .filter((line) => line.startsWith("answer "));
421
+ expect(answers).toEqual(["answer Gamma", "answer Beta"]);
422
+ } finally {
423
+ client.close();
424
+ server.stop(true);
425
+ }
426
+ },
427
+ 20000,
428
+ );
429
+
430
+ test(
431
+ "recommends the first option when the elicitation omits a default",
432
+ async () => {
433
+ const { server, url } = await startServer(
434
+ { "0001-first.md": PENDING_ONE },
435
+ "--questions=1 --questions-unrecommended",
436
+ );
437
+ const client = await connect(url);
438
+
439
+ try {
440
+ client.send({ type: "startRun" });
441
+ await client.waitFor((message) => message.type === "runStarted");
442
+
443
+ const question = await client.waitFor((message) => message.type === "question");
444
+ expect(question).toMatchObject({
445
+ message: "Choose an answer",
446
+ options: [
447
+ { label: "Alpha", recommended: true },
448
+ { label: "Beta", recommended: false },
449
+ ],
450
+ });
451
+ } finally {
452
+ client.close();
453
+ server.stop(true);
454
+ }
455
+ },
456
+ 20000,
457
+ );
458
+
459
+ test(
460
+ "ends the sequence without starting another record when the agent turn does not resolve",
461
+ async () => {
462
+ const { server, adrDir, logPath, url } = await startServer(
463
+ { "0001-first.md": PENDING_ONE, "0002-second.md": PENDING_TWO },
464
+ "--questions=0 --no-resolve",
465
+ );
466
+ const client = await connect(url);
467
+
468
+ try {
469
+ client.send({ type: "startRun" });
470
+ await client.waitFor((message) => message.type === "runStarted");
471
+ expect(await waitForStop(client)).toMatchObject({
472
+ file: "0001-first.md",
473
+ resolved: false,
474
+ status: "Proposed",
475
+ });
476
+
477
+ const refreshed = await client.waitFor((message) => message.type === "records");
478
+ expect(refreshed).toMatchObject({ next: "0001-first.md" });
479
+
480
+ // Integration test: the sequence outcome is observable only as the
481
+ // absence of another runStarted from the real server subprocess.
482
+ await Bun.sleep(300);
483
+ expect(starts(client)).toEqual(["0001-first.md"]);
484
+ expect(spawnCount(logPath)).toBe(1);
485
+ expect(frontmatterStatus(await readFile(join(adrDir, "0001-first.md"), "utf8"))).toBe("proposed");
486
+ expect(frontmatterStatus(await readFile(join(adrDir, "0002-second.md"), "utf8"))).toBe("proposed");
487
+ } finally {
488
+ client.close();
489
+ server.stop(true);
490
+ }
491
+ },
492
+ 20000,
493
+ );
494
+
495
+ test(
496
+ "ends the sequence when the active record file is removed during its run",
497
+ async () => {
498
+ const { server, adrDir, logPath, url } = await startServer(
499
+ { "0001-first.md": PENDING_ONE, "0002-second.md": PENDING_TWO },
500
+ "--questions=0 --remove-record",
501
+ );
502
+ const client = await connect(url);
503
+
504
+ try {
505
+ client.send({ type: "startRun" });
506
+ await client.waitFor((message) => message.type === "runStarted");
507
+ expect(await waitForStop(client)).toMatchObject({
508
+ file: "0001-first.md",
509
+ resolved: false,
510
+ });
511
+
512
+ expect(await client.waitFor((message) => message.type === "records")).toMatchObject({
513
+ next: "0002-second.md",
514
+ });
515
+
516
+ // Integration test: the sequence outcome is observable only as the
517
+ // absence of another runStarted from the real server subprocess.
518
+ await Bun.sleep(300);
519
+ expect(starts(client)).toEqual(["0001-first.md"]);
520
+ expect(spawnCount(logPath)).toBe(1);
521
+ expect(existsSync(join(adrDir, "0001-first.md"))).toBe(false);
522
+ expect(frontmatterStatus(await readFile(join(adrDir, "0002-second.md"), "utf8"))).toBe("proposed");
523
+ } finally {
524
+ client.close();
525
+ server.stop(true);
526
+ }
527
+ },
528
+ 20000,
529
+ );
530
+
531
+ test(
532
+ "keeps resolving after the only client disconnects",
533
+ async () => {
534
+ const { server, adrDir, logPath, url } = await startServer(
535
+ {
536
+ "0001-first.md": PENDING_ONE,
537
+ "0002-second.md": PENDING_TWO,
538
+ "0003-third.md": PENDING_THREE,
539
+ },
540
+ "--questions=0",
541
+ );
542
+ const client = await connect(url);
543
+ const files = ["0001-first.md", "0002-second.md", "0003-third.md"];
544
+
545
+ try {
546
+ client.send({ type: "startRun" });
547
+ await client.waitFor((message) => message.type === "runStarted");
548
+ client.close();
549
+
550
+ const deadline = Date.now() + 15000;
551
+ let statuses: string[] = [];
552
+ while (Date.now() < deadline) {
553
+ statuses = await Promise.all(
554
+ files.map((file) => readFile(join(adrDir, file), "utf8")),
555
+ );
556
+ if (statuses.every((source) => frontmatterStatus(source) === "accepted")) break;
557
+ await Bun.sleep(100);
558
+ }
559
+
560
+ expect(statuses.every((source) => frontmatterStatus(source) === "accepted")).toBe(true);
561
+ expect(spawnCount(logPath)).toBe(3);
562
+ } finally {
563
+ server.stop(true);
564
+ }
565
+ },
566
+ 20000,
567
+ );
568
+
569
+ test(
570
+ "refuses to start when no ADR is pending and spawns no process",
571
+ async () => {
572
+ const { server, logPath, url } = await startServer({
573
+ "0001-first.md": fixtureRecord("0001-first.md", "First decision"),
574
+ });
575
+ const client = await connect(url);
576
+
577
+ try {
578
+ client.send({ type: "startRun" });
579
+ const error = await client.waitFor((message) => message.type === "error");
580
+ expect(error).toMatchObject({ message: "No pending ADRs to start." });
581
+ expect(spawnCount(logPath)).toBe(0);
582
+ } finally {
583
+ client.close();
584
+ server.stop(true);
585
+ }
586
+ },
587
+ 20000,
588
+ );
589
+
590
+ test(
591
+ "refuses an explicitly selected ADR that is not pending",
592
+ async () => {
593
+ const { server, logPath, url } = await startServer({
594
+ "0001-first.md": fixtureRecord("0001-first.md", "First decision"),
595
+ });
596
+ const client = await connect(url);
597
+
598
+ try {
599
+ client.send({ type: "startRun", file: "0001-first.md" });
600
+ expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
601
+ message: "That ADR is not pending.",
602
+ });
603
+ expect(spawnCount(logPath)).toBe(0);
604
+ } finally {
605
+ client.close();
606
+ server.stop(true);
607
+ }
608
+ },
609
+ 20000,
610
+ );
611
+ });