@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,479 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { createApp } from "./app";
6
+ import {
7
+ GOAL_PROPOSALS_DIRECTORY,
8
+ goalProposalBody,
9
+ goalProposalName,
10
+ normalizeGoalSlug,
11
+ parseGoalProposalSubmission,
12
+ writeGoalProposal,
13
+ } from "./goal-proposal";
14
+
15
+ const temporaryDirectories: string[] = [];
16
+ const temporaryRoot = join(import.meta.dir, "..", ".tmp");
17
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
18
+
19
+ interface StartOptions {
20
+ /**
21
+ * `fallback` keeps unrelated tests deterministic; `agent` runs the real ACP
22
+ * naming path against the stub agent; a function is the injected seam.
23
+ */
24
+ slug?: "fallback" | "agent" | ((report: string) => Promise<string>);
25
+ /** What the stub agent answers the naming turn with. */
26
+ slugAnswer?: string;
27
+ }
28
+
29
+ afterAll(async () => {
30
+ await Promise.all(
31
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
32
+ );
33
+ });
34
+
35
+ async function temporaryDirectory(): Promise<string> {
36
+ await mkdir(temporaryRoot, { recursive: true });
37
+ const directory = await mkdtemp(join(temporaryRoot, "goal-proposal-"));
38
+ temporaryDirectories.push(directory);
39
+ return directory;
40
+ }
41
+
42
+ async function startServer(options: StartOptions = {}) {
43
+ const root = await temporaryDirectory();
44
+ const adrDir = join(root, "adrs");
45
+ const staticDir = join(root, "static");
46
+ const recordPath = join(root, "gh-call.json");
47
+ await mkdir(adrDir, { recursive: true });
48
+ await mkdir(staticDir, { recursive: true });
49
+ await writeFile(join(staticDir, "index.html"), "<!doctype html><title>Proposals</title>");
50
+
51
+ const slug = options.slug ?? "fallback";
52
+ const goalSlug =
53
+ slug === "agent"
54
+ ? undefined
55
+ : typeof slug === "function"
56
+ ? slug
57
+ : async (report: string) => goalProposalName(report);
58
+ const agentCommand =
59
+ slug === "agent"
60
+ ? `bun ${STUB_AGENT} --goal-slug=${options.slugAnswer ?? ""}`
61
+ : undefined;
62
+
63
+ const server = createApp({
64
+ adrDir,
65
+ staticDir,
66
+ hostname: "127.0.0.1",
67
+ port: 0,
68
+ repoDir: root,
69
+ stateDir: join(root, "state"),
70
+ ...(agentCommand === undefined ? {} : { agentCommand }),
71
+ ...(goalSlug === undefined ? {} : { goalSlug }),
72
+ });
73
+ return { server, root, url: `127.0.0.1:${server.port}`, recordPath };
74
+ }
75
+
76
+ function propose(
77
+ url: string,
78
+ payload: unknown,
79
+ headers: Record<string, string> = {},
80
+ ): Promise<Response> {
81
+ return fetch(`http://${url}/api/goal-proposals`, {
82
+ method: "POST",
83
+ headers: { "content-type": "application/json", ...headers },
84
+ body: JSON.stringify(payload),
85
+ });
86
+ }
87
+
88
+ function proposal(overrides: Record<string, unknown> = {}): Record<string, unknown> {
89
+ return {
90
+ report: "The Start button is clipped on narrow windows.",
91
+ route: "/",
92
+ url: "http://127.0.0.1:8123/",
93
+ selector: "main > section.run > button.start-button",
94
+ dom_excerpt: '<button class="start-button">Start</button>',
95
+ visible_text: "Start",
96
+ viewport: "1280x720 @1x",
97
+ captured_at: "2026-10-22T10:00:00.000Z",
98
+ ...overrides,
99
+ };
100
+ }
101
+
102
+ async function drafts(root: string): Promise<string[]> {
103
+ const directory = join(root, GOAL_PROPOSALS_DIRECTORY);
104
+ if (!existsSync(directory)) return [];
105
+ return (await readdir(directory)).filter((name) => name.endsWith(".md")).sort();
106
+ }
107
+
108
+ async function responsePath(response: Response): Promise<string> {
109
+ const body = (await response.json()) as unknown;
110
+ if (
111
+ typeof body !== "object" ||
112
+ body === null ||
113
+ !("path" in body) ||
114
+ typeof body.path !== "string"
115
+ ) {
116
+ throw new Error("The endpoint returned no draft path.");
117
+ }
118
+ return body.path;
119
+ }
120
+
121
+ describe("goal proposal drafts", () => {
122
+ test("writes one draft with the operator text and captured context", async () => {
123
+ const { server, root, url } = await startServer();
124
+
125
+ try {
126
+ const report = " The Start button is clipped.\n\n Second paragraph stays.\n";
127
+ const response = await propose(url, proposal({ report }));
128
+
129
+ expect(response.status).toBe(201);
130
+ expect(await response.json()).toEqual({
131
+ path: ".devboard/goal-proposals/the-start-button-is-clipped.md",
132
+ });
133
+
134
+ const files = await drafts(root);
135
+ expect(files).toEqual(["the-start-button-is-clipped.md"]);
136
+ const text = await readFile(
137
+ join(root, GOAL_PROPOSALS_DIRECTORY, files[0]!),
138
+ "utf8",
139
+ );
140
+ expect(text.startsWith("# Proposed goal: The Start button is clipped.\n\n")).toBe(true);
141
+ expect(text).not.toContain("Unprocessed draft");
142
+ expect(text).not.toContain("/goal-spec");
143
+ expect(text).toContain(report);
144
+ expect(text).toContain("## Captured UI context");
145
+ expect(text).toContain("- Route: `/`");
146
+ expect(text).toContain("- URL: `http://127.0.0.1:8123/`");
147
+ expect(text).toContain(
148
+ "- Selected element: `main > section.run > button.start-button`",
149
+ );
150
+ expect(text).toContain("- Viewport: `1280x720 @1x`");
151
+ expect(text).toContain("- Captured at: `2026-10-22T10:00:00.000Z`");
152
+ expect(text).toContain("### Visible text\n\nStart");
153
+ expect(text).toContain("### DOM excerpt\n\n```html\n");
154
+ expect(text).toContain('<button class="start-button">Start</button>\n```');
155
+ for (const heading of [
156
+ "## Success criteria",
157
+ "## Verification",
158
+ "## Boundaries",
159
+ "## Stop conditions",
160
+ ]) {
161
+ expect(text).not.toContain(heading);
162
+ }
163
+ } finally {
164
+ server.stop(true);
165
+ }
166
+ });
167
+
168
+ test("carries the Ask conversation into the draft as context", async () => {
169
+ const { server, root, url } = await startServer();
170
+
171
+ try {
172
+ const response = await propose(
173
+ url,
174
+ proposal({
175
+ conversation: [
176
+ { role: "developer", text: "Why is this button clipped?" },
177
+ { role: "agent", text: "The flex row has no min-width." },
178
+ ],
179
+ }),
180
+ );
181
+
182
+ expect(response.status).toBe(201);
183
+ const path = await responsePath(response);
184
+ const text = await readFile(join(root, path), "utf8");
185
+ expect(text).toContain("## Ask conversation");
186
+ expect(text).toContain("**You:**\n\nWhy is this button clipped?");
187
+ expect(text).toContain("**Agent:**\n\nThe flex row has no min-width.");
188
+ expect(text.indexOf("## Ask conversation")).toBeLessThan(
189
+ text.indexOf("## Captured UI context"),
190
+ );
191
+ } finally {
192
+ server.stop(true);
193
+ }
194
+ });
195
+
196
+ test("never overwrites a draft and leaves no other side effect", async () => {
197
+ const { server, root, url, recordPath } = await startServer();
198
+ const goalsDir = join(root, ".devboard", "goals");
199
+ await mkdir(goalsDir, { recursive: true });
200
+ await writeFile(join(goalsDir, "kept.md"), "# Kept goal\n");
201
+ await writeFile(join(root, "GOAL.md"), "# Goal\n\nKeep it.\n");
202
+
203
+ try {
204
+ const first = await propose(url, proposal());
205
+ const firstPath = await responsePath(first);
206
+ const firstText = await readFile(join(root, firstPath), "utf8");
207
+
208
+ const second = await propose(url, proposal());
209
+ expect(second.status).toBe(201);
210
+ expect(await second.json()).toEqual({
211
+ path: ".devboard/goal-proposals/the-start-button-is-clipped-on-2.md",
212
+ });
213
+
214
+ expect(await drafts(root)).toEqual([
215
+ "the-start-button-is-clipped-on-2.md",
216
+ "the-start-button-is-clipped-on.md",
217
+ ]);
218
+ expect(await readFile(join(root, firstPath), "utf8")).toBe(firstText);
219
+ expect(await readFile(join(goalsDir, "kept.md"), "utf8")).toBe("# Kept goal\n");
220
+ expect(await readFile(join(root, "GOAL.md"), "utf8")).toBe("# Goal\n\nKeep it.\n");
221
+ // No `gh` call and no GitHub issue: the stub never started.
222
+ expect(existsSync(recordPath)).toBe(false);
223
+ } finally {
224
+ server.stop(true);
225
+ }
226
+ });
227
+
228
+ test("keeps traversal-shaped text inside the proposal directory", async () => {
229
+ const { server, root, url } = await startServer();
230
+
231
+ try {
232
+ const response = await propose(
233
+ url,
234
+ proposal({ report: "../escape / \\ \u0000 name\n../../outside.md" }),
235
+ );
236
+
237
+ expect(response.status).toBe(201);
238
+ const body = await responsePath(response);
239
+ expect(body.startsWith(`${GOAL_PROPOSALS_DIRECTORY}/`)).toBe(true);
240
+ expect(body).not.toContain("..");
241
+ expect(body).not.toContain("\\");
242
+ const files = await drafts(root);
243
+ expect(files).toHaveLength(1);
244
+ expect(body).toBe(`${GOAL_PROPOSALS_DIRECTORY}/${files[0]!}`);
245
+ expect(existsSync(join(root, "outside.md"))).toBe(false);
246
+ } finally {
247
+ server.stop(true);
248
+ }
249
+ });
250
+
251
+ test("refuses a missing or whitespace-only report", async () => {
252
+ const { server, root, url } = await startServer();
253
+
254
+ try {
255
+ const missing = await propose(url, proposal({ report: undefined }));
256
+ expect(missing.status).toBe(400);
257
+ expect(await missing.json()).toEqual({ error: "report is required." });
258
+
259
+ const blank = await propose(url, proposal({ report: " \n\t " }));
260
+ expect(blank.status).toBe(400);
261
+ expect(await blank.json()).toEqual({ error: "report must not be empty." });
262
+
263
+ expect(await drafts(root)).toEqual([]);
264
+ } finally {
265
+ server.stop(true);
266
+ }
267
+ });
268
+
269
+ test("applies the feedback field limits", async () => {
270
+ const { server, root, url } = await startServer();
271
+
272
+ try {
273
+ expect((await propose(url, proposal({ report: "x".repeat(8001) }))).status).toBe(400);
274
+ expect(
275
+ (await propose(url, proposal({ dom_excerpt: "x".repeat(8001) }))).status,
276
+ ).toBe(400);
277
+ expect((await propose(url, proposal({ selector: "x".repeat(2001) }))).status).toBe(400);
278
+ expect(await drafts(root)).toEqual([]);
279
+ } finally {
280
+ server.stop(true);
281
+ }
282
+ });
283
+
284
+ test("refuses a cross-origin browser request", async () => {
285
+ const { server, root, url } = await startServer();
286
+
287
+ try {
288
+ const response = await propose(url, proposal(), { origin: "http://evil.example" });
289
+
290
+ expect(response.status).toBe(403);
291
+ expect(await drafts(root)).toEqual([]);
292
+ } finally {
293
+ server.stop(true);
294
+ }
295
+ });
296
+
297
+ test("refuses a non-POST request", async () => {
298
+ const { server, url } = await startServer();
299
+
300
+ try {
301
+ const response = await fetch(`http://${url}/api/goal-proposals`);
302
+ expect(response.status).toBe(405);
303
+ expect(response.headers.get("allow")).toBe("POST");
304
+ } finally {
305
+ server.stop(true);
306
+ }
307
+ });
308
+
309
+ test("refuses an oversized body", async () => {
310
+ const { server, root, url } = await startServer();
311
+
312
+ try {
313
+ const response = await propose(url, proposal({ report: "x".repeat(70_000) }));
314
+
315
+ expect(response.status).toBe(413);
316
+ expect(await drafts(root)).toEqual([]);
317
+ } finally {
318
+ server.stop(true);
319
+ }
320
+ });
321
+
322
+ test("reports a write failure without a partial draft", async () => {
323
+ const { server, root, url } = await startServer();
324
+ // A file where the harness directory belongs makes the directory creation
325
+ // fail, so the write cannot land.
326
+ await rm(join(root, ".devboard"), { recursive: true, force: true });
327
+ await writeFile(join(root, ".devboard"), "not a directory\n");
328
+
329
+ try {
330
+ const response = await propose(url, proposal());
331
+
332
+ expect(response.status).toBe(500);
333
+ const body: unknown = await response.json();
334
+ const error =
335
+ typeof body === "object" &&
336
+ body !== null &&
337
+ "error" in body &&
338
+ typeof body.error === "string"
339
+ ? body.error
340
+ : "";
341
+ expect(error.length).toBeGreaterThan(0);
342
+ expect(await readFile(join(root, ".devboard"), "utf8")).toBe("not a directory\n");
343
+ expect(await drafts(root)).toEqual([]);
344
+ } finally {
345
+ server.stop(true);
346
+ }
347
+ });
348
+ });
349
+
350
+ describe("goal proposal naming and body", () => {
351
+ test("derives a bounded lowercase name", () => {
352
+ expect(goalProposalName("The Start Button is clipped on narrow windows.")).toBe(
353
+ "the-start-button-is-clipped-on",
354
+ );
355
+ expect(goalProposalName("\n\n Make it Pretty! \nsecond line")).toBe(
356
+ "make-it-pretty",
357
+ );
358
+ expect(goalProposalName("../..")).toBe("ui-goal");
359
+ expect(goalProposalName("\u0000\u0000")).toBe("ui-goal");
360
+ });
361
+
362
+ test("bounds the fallback name to a usable file name", () => {
363
+ expect(goalProposalName("a".repeat(8000))).toBe("a".repeat(64));
364
+ for (const report of [
365
+ "word ".repeat(2000),
366
+ `${"a".repeat(63)}b`,
367
+ `${"a".repeat(40)} ${"b".repeat(40)}`,
368
+ "a-".repeat(4000),
369
+ ]) {
370
+ const name = goalProposalName(report);
371
+ expect(name.length).toBeGreaterThan(0);
372
+ expect(name.length).toBeLessThanOrEqual(64);
373
+ expect(name.endsWith("-")).toBe(false);
374
+ }
375
+ });
376
+
377
+ test("writes a draft when a long report falls back to the derived name", async () => {
378
+ const root = await temporaryDirectory();
379
+ const submission = parseGoalProposalSubmission({ report: "a".repeat(8000) });
380
+ const written = await writeGoalProposal(submission, {
381
+ repoDir: root,
382
+ slugger: async () => {
383
+ throw new Error("The naming agent did not answer in time.");
384
+ },
385
+ });
386
+
387
+ const filename = `${"a".repeat(64)}.md`;
388
+ expect(written.path).toBe(`.devboard/goal-proposals/${filename}`);
389
+ expect(await readdir(join(root, GOAL_PROPOSALS_DIRECTORY))).toEqual([filename]);
390
+ });
391
+
392
+ test("bounds the heading and omits empty optional context", () => {
393
+ const submission = parseGoalProposalSubmission({
394
+ report: `${"word ".repeat(100)}`,
395
+ });
396
+ const body = goalProposalBody(submission);
397
+ const heading = body.split("\n")[0]!;
398
+ expect(heading.startsWith("# Proposed goal: ")).toBe(true);
399
+ expect(heading.length).toBeLessThanOrEqual("# Proposed goal: ".length + 200);
400
+ expect(body).toContain("No page context was captured.");
401
+ expect(body).not.toContain("### Visible text");
402
+ expect(body).not.toContain("### DOM excerpt");
403
+ });
404
+
405
+ test("normalizes an agent's slug and rejects unusable answers", () => {
406
+ expect(normalizeGoalSlug("clipped-start-button")).toBe("clipped-start-button");
407
+ expect(normalizeGoalSlug("`clipped-start-button`")).toBe("clipped-start-button");
408
+ expect(normalizeGoalSlug("Slug: clipped-start-button\n")).toBe("clipped-start-button");
409
+ expect(normalizeGoalSlug("```\nclipped-start-button\n```")).toBe("clipped-start-button");
410
+ expect(normalizeGoalSlug("Here is the slug:\n\n`clipped-start-button`")).toBe(
411
+ "clipped-start-button",
412
+ );
413
+ expect(normalizeGoalSlug("**clipped-start-button**.")).toBe("clipped-start-button");
414
+ expect(normalizeGoalSlug("Clipped Start Button")).toBeNull();
415
+ expect(normalizeGoalSlug("one-two-three-four-five-six-seven")).toBeNull();
416
+ expect(normalizeGoalSlug("a".repeat(65))).toBeNull();
417
+ expect(normalizeGoalSlug("clipped-start-button.")).toBe("clipped-start-button");
418
+ expect(normalizeGoalSlug("")).toBeNull();
419
+ });
420
+
421
+ test("uses a chosen name and falls back when naming fails", async () => {
422
+ const root = await temporaryDirectory();
423
+ const submission = parseGoalProposalSubmission({
424
+ report: "Make the start button visible.",
425
+ });
426
+
427
+ const chosen = await writeGoalProposal(submission, {
428
+ repoDir: root,
429
+ slugger: async () => "start-button-visibility",
430
+ });
431
+ expect(chosen.path).toBe(".devboard/goal-proposals/start-button-visibility.md");
432
+
433
+ const failed = await writeGoalProposal(submission, {
434
+ repoDir: root,
435
+ slugger: async () => {
436
+ throw new Error("The naming agent did not answer in time.");
437
+ },
438
+ });
439
+ expect(failed.path).toBe(".devboard/goal-proposals/make-the-start-button-visible.md");
440
+ });
441
+
442
+ test("names the draft with the ACP naming agent's slug", async () => {
443
+ const { server, root, url } = await startServer({
444
+ slug: "agent",
445
+ slugAnswer: "clipped-start-button",
446
+ });
447
+
448
+ try {
449
+ const response = await propose(url, proposal());
450
+
451
+ expect(response.status).toBe(201);
452
+ expect(await response.json()).toEqual({
453
+ path: ".devboard/goal-proposals/clipped-start-button.md",
454
+ });
455
+ expect(await drafts(root)).toEqual(["clipped-start-button.md"]);
456
+ } finally {
457
+ server.stop(true);
458
+ }
459
+ });
460
+
461
+ test("falls back to the deterministic name when the agent's answer is unusable", async () => {
462
+ const { server, root, url } = await startServer({
463
+ slug: "agent",
464
+ slugAnswer: "INVALID_SLUG",
465
+ });
466
+
467
+ try {
468
+ const response = await propose(url, proposal());
469
+
470
+ expect(response.status).toBe(201);
471
+ expect(await response.json()).toEqual({
472
+ path: ".devboard/goal-proposals/the-start-button-is-clipped-on.md",
473
+ });
474
+ expect(await drafts(root)).toEqual(["the-start-button-is-clipped-on.md"]);
475
+ } finally {
476
+ server.stop(true);
477
+ }
478
+ });
479
+ });